SkipUI
为 Skip 应用提供 SwiftUI 支持。
要在项目中包含此框架,请将以下依赖项添加到
Package.swift 文件:
let package = Package( name: "my-package", products: [ .library(name: "MyProduct", targets: ["MyTarget"]), ], dependencies: [ .package(url: "https://source.skip.dev/skip-ui.git", from: "1.0.0"), ], targets: [ .target(name: "MyTarget", dependencies: [ .product(name: "SkipUI", package: "skip-ui") ]) ])关于 SkipUI
Section titled “关于 SkipUI”SkipUI 提供 skip.ui Kotlin 包。它基于 Jetpack Compose,在 Android 上为 Kotlin 重新实现 SwiftUI,目标是尽可能完整地映射 SwiftUI。
{: .diagram-vector }
Skip Lite 转译后的 Swift 会直接使用 SkipUI;Skip Fuse 编译后的 Swift 则通过 SkipFuseUI 原生框架间接使用它。
SkipUI 依赖 skip ↗ 转译器插件;该插件既要转译 SkipUI 自身的源码,SkipUI 也依赖它对 SwiftUI 代码的转换。详情参见实现策略。此外还依赖 SkipFoundation 与 SkipModel。
SkipUI 属于核心的 Core Skip Frameworks,并不建议直接导入。
编译型 Swift 导入 SwiftUI 时会透明引入此模块;对于转译代码,import SwiftUI 会转换成 import skip.ui.*。
Android 库
Section titled “Android 库”- SkipUI 添加 Android 端的 Coil ↗ 依赖,用于实现
AsyncImage。 - SkipUI 包含 ComposeReorderable ↗ 的源码,用于实现
List的拖动排序。
SkipUI 已可靠支持 SwiftUI 的基础能力,包括状态流和声明式语法,也实现了相当大比例的组件与修饰器。利用现有组件集,已可以完全使用 SwiftUI 编写 Android 应用。
不过,SwiftUI 庞大的 API 中仍有部分尚未实现。完整列表参见支持的 SwiftUI。
需要尚未实现的 SwiftUI 结构时,可以寻找只使用受支持组件的替代方案、直接嵌入 Compose 代码,或为 SkipUI 添加支持。若增强 SkipUI 本身,请考虑将代码贡献回来。
ComposeView
Section titled “ComposeView”ComposeView 是仅限 Android 的 SwiftUI 视图,可把 Compose 直接嵌入 SwiftUI 视图树;它在 Skip Fuse 编译代码和 Skip Lite 转译代码中的用法不同。
Skip Fuse
Section titled “Skip Fuse”下面的 SkipFuseUI 示例先用 SwiftUI Text 显示“Hello from SwiftUI”,再调用 Compose 的 androidx.compose.material3.Text() 在下方显示“Hello from Compose”。在 Skip Fuse 中集成 Compose 分两步:
- 在转译的
#if SKIP代码块中定义ContentComposer。 - 使用
ComposeView在 SwiftUI 视图树中渲染ContentComposer。
import SwiftUI
...
VStack { Text("Hello from SwiftUI") #if os(Android) ComposeView { MessageComposer(message: "Hello from Compose") } #endif}
#if SKIPstruct MessageComposer : ContentComposer { let message: String
@Composable func Compose(context: ComposeContext) { androidx.compose.material3.Text(message) }}#endifContentComposer 协议只包含一个函数:
public protocol ContentComposer { @Composable func Compose(context: ComposeContext)}传给 ContentComposer 的数据必须从编译后的 Swift 桥接到 #if SKIP 转译出的 Kotlin;上例中的 String 等简单类型会自动桥接。
Skip 还允许传递许多 SwiftUI 内置类型,它们会桥接到可在 Compose 中使用的 SkipUI 实现,例如:
Color:传入任意Color后,在 Compose 中调用SkipUI.Color.asComposeColor()获得androidx.compose.ui.graphics.Color。Font:传入任意Font后,调用SkipUI.Font.asComposeTextStyle()获得androidx.compose.ui.text.TextStyle。Image:可传入原生Image并接收等价的SkipUI.Image。图像没有一一对应的 Compose 值类型,但仍可调用Image.Compose(context:)渲染。Text:适合封装可本地化字符串;在 Compose 中调用SkipUI.Text.localizedTextString()可取得本地化值。
下面的示例使用了其中一些技巧:
...
#if os(Android)ComposeView { MessageComposer(message: Text("Welcome"), textColor: .red)}#endif
...
#if SKIPstruct MessageComposer : ContentComposer { let message: Text let textColor: Color
@Composable override func Compose(context: ComposeContext) { androidx.compose.material3.Text(message.localizedTextString(), color: textColor.asComposeColor()) }}#endif桥接自定义数据类型请参阅桥接文档。
Skip Lite
Section titled “Skip Lite”下面的转译示例先用 SwiftUI Text 显示“Hello from SwiftUI”,再调用 Compose 的 androidx.compose.material3.Text() 在下方显示“Hello from Compose”:
import SwiftUI
...
VStack { Text("Hello from SwiftUI") #if SKIP ComposeView { _ in androidx.compose.material3.Text("Hello from Compose") } #endif}与 Skip Fuse 不同,Skip Lite 转译后的 Swift 可直接在 ComposeView 主体中调用 Compose 函数。
Compose(context:)
Section titled “Compose(context:)”SkipUI 为所有 SwiftUI 视图增加 Compose(context:) 方法,使其可在 Compose 中使用。下面仍用 SwiftUI Text 显示“Hello from SwiftUI”,但这次是在 ComposeView 内。
Skip Fuse:
#if os(Android)ComposeView { ColumnComposer() }#endif
...
#if SKIPstruct ColumnComposer : ContentComposer { @Composable func Compose(context: ComposeContext) { androidx.compose.foundation.layout.Column(modifier: context.modifier) { Text("Hello from SwiftUI").Compose(context: context.content()) androidx.compose.material3.Text("Hello from Compose") } }}#endifSkip Lite:
#if SKIPComposeView { context in androidx.compose.foundation.layout.Column(modifier: context.modifier) { Text("Hello from SwiftUI").Compose(context: context.content()) androidx.compose.material3.Text("Hello from Compose") }}#endif借助 ComposeView 和 Compose(context:),可以在 SwiftUI 与 Compose 代码间灵活切换;它们同样适用于自定义视图和函数。
其他注意事项
Section titled “其他注意事项”将 SwiftUI 集成到不由 Skip 管理的 Compose 应用时还需注意:SwiftUI 使用 @AppStorage、导航路径绑定等自身机制保存和恢复 Activity UI 状态,与 Android 的恢复机制不兼容。可采用以下模式将 SwiftUI 排除在 Activity 状态恢复之外:
val stateHolder = rememberSaveableStateHolder()stateHolder.SaveableStateProvider("myKey") { MySwiftUIRootView().Compose() SideEffect { stateHolder.removeState("myKey") }}这种模式让 SkipUI 在内部利用 Compose 的 UI 状态机制,同时不参与 Activity 状态恢复。
composeModifier
Section titled “composeModifier”除上述 ComposeView 外,SkipUI 还提供 SwiftUI 的 composeModifier 修饰器,可向底层 Compose 视图应用任意 Compose 修饰器。
Skip Fuse
Section titled “Skip Fuse”在 Skip Fuse 中使用 composeModifier 与使用 ComposeView 很相似:
- 在转译的
#if SKIP代码块中定义ContentModifier。 - 使用
.composeModifier将ContentModifier应用到目标 SwiftUI 视图。
在 ContentModifier 中可向目标视图应用任意 SkipUI 修饰器,包括下文的 Material 修饰器,或转译后的同名 composeModifier。后者的代码块接收并返回 androidx.compose.ui.Modifier。以下示例在 Android 上为 SwiftUI 应用 Compose 的 imePadding:
import SwiftUI
...
VStack { TextField("Enter username:", text: $username) #if os(Android) .composeModifier { IMEPaddingModifier() } #endif}
#if SKIPimport androidx.compose.foundation.layout.imePadding
struct IMEPaddingModifier : ContentModifier { func modify(view: any View) -> any View { view.composeModifier { $0.imePadding() } }}#endifContentModfiier 协议只包含一个函数:
public protocol ContentModifier { func modify(view: any View) -> any View}Skip Lite
Section titled “Skip Lite”若使用 Skip Lite 编写 SwiftUI,无需定义 ContentModfifier,可直接应用 Material 修饰器或 .composeModifier:
#if SKIPimport androidx.compose.foundation.layout.imePadding#endif
...
TextField("Enter username:", text: $username) #if SKIP .composeModifier { $0.imePadding() } #endif还可以应用作用域修饰器 ↗。例如,在 LazyHStack 中可使用限定于 LazyItemScope ↗ 的 animateItem()。
#if SKIPimport androidx.compose.foundation.lazy.LazyItemScope#endif
...
LazyHStack { ForEach(0..<count, id: \.self) { i in Color.red .frame(width: 20, height: 20) .id(i + 1) #if SKIP .composeModifier(scope: LazyItemScope.self) { $0.animateItem() } #endif }}Material
Section titled “Material”SkipUI 底层使用 Android 的 Material 3 颜色与组件。多数样式应使用 SwiftUI 内置配色方案和修饰器,但部分 Android 定制能力没有 SwiftUI 对应项,因此 Skip 增加了仅限 Android 的 Material 调整 API。
Material 颜色
Section titled “Material 颜色”默认情况下,Skip 在受支持设备上使用 Material 3 动态颜色,否则回退到标准颜色。可在 Compose 代码中使用以下函数定制:
Material3ColorScheme(scheme: (@Composable (ColorScheme, Boolean) -> ColorScheme)?, content: @Composable () -> Unit)scheme 参数接收两个参数:Skip 默认的 androidx.compose.material3.ColorScheme,以及是否请求深色模式。闭包返回用于指定内容的颜色方案。
例如,要定制整个应用的表面颜色,可按如下方式编辑 Main.kt:
@Composableinternal fun PresentationRootView(context: ComposeContext) { Material3ColorScheme({ colors, isDark -> colors.copy(surface = if (isDark) Color.purple.asComposeColor() else Color.yellow.asComposeColor()) }, content = { // ... Original content of this function ... })}Skip 还提供 SwiftUI 的 .material3ColorScheme(_:) 修饰器来定制视图层级。它接收与 Kotlin Material3ColorScheme 相同的闭包,并通过上一节的 .composeModifier 技巧应用。例如:
Skip Fuse:
MyView() #if os(Android) .composeModifier { ColorSchemeModifier() } #endif
...
#if SKIPstruct ColorSchemeModifier : ContentModifier { func modify(view: any View) -> any View { view.material3ColorScheme { colors, isDark in colors.copy(surface: isDark ? Color.purple.asComposeColor() : Color.yellow.asComposeColor()) } }}#endifSkip Lite:
MyView() #if SKIP .material3ColorScheme { colors, isDark in colors.copy(surface: isDark ? Color.purple.asComposeColor() : Color.yellow.asComposeColor()) } #endifSkip 内置组件使用以下 Material 3 颜色,可按需定制:
surfaceprimaryonBackgroundoutlineoutlineVariant
Material 组件
Section titled “Material 组件”除 .material3ColorScheme 外,Skip 还为底层 Material 3 组件提供许多 .material3 修饰器,它们采用共同的 API 模式:
- 修饰器接收闭包;闭包获得按 Skip 默认值配置的
Material3<Component>Options,并返回所需修改后的结构。 - 每个
Material3<Component>Options都实现 Kotlincopy方法,可在一次调用中完成复制和修改。 - 修饰器将闭包放入 SwiftUI
Environment,因此在根视图应用即可影响所有子视图。.material3系列始终应放在要影响的视图本身或其外层。 - 这些修饰器作用于 Skip 的 SwiftUI 封装层之下,因此使用 Compose 术语和类型;其选项结构属性通常与对应 Material 3 组件函数参数完全一致。
以下示例修改 Android 标签栏的选中指示器颜色;该标签栏由 Material 3 NavigationBar 实现:
Skip Fuse:
TabView { ...}#if os(Android).composeModifier { NavigationBarModifier() }#endif
...
#if SKIPstruct NavigationBarModifier : ContentModifier { func modify(view: any View) -> any View { view.material3NavigationBar { options in let updatedColors = options.itemColors.copy(selectedIndicatorColor: Color.green.asComposeColor()) return options.copy(itemColors: updatedColors) } }}#endifSkip Lite:
TabView { ...}#if SKIP.material3NavigationBar { options in let updatedColors = options.itemColors.copy(selectedIndicatorColor: Color.green.asComposeColor()) return options.copy(itemColors: updatedColors)}#endifSkipUI 目前包含以下 Material 修饰器:
extension View { public func material3BottomAppBar(_ options: @Composable (Material3BottomAppBarOptions) -> Material3BottomAppBarOptions) -> some View}
public struct Material3BottomAppBarOptions { public var modifier: androidx.compose.ui.Modifier public var containerColor: androidx.compose.ui.graphics.Color public var contentColor: androidx.compose.ui.graphics.Color public var tonalElevation: androidx.compose.ui.unit.Dp public var contentPadding: androidx.compose.foundation.layout.PaddingValues}
extension View { public func material3Button(_ options: @Composable (Material3ButtonOptions) -> Material3ButtonOptions) -> some View}
public struct Material3ButtonOptions { public var onClick: () -> Void public var modifier: androidx.compose.ui.Modifier public var enabled: Bool public var shape: androidx.compose.ui.graphics.Shape public var colors: androidx.compose.material3.ButtonColors public var elevation: androidx.compose.material3.ButtonElevation? public var border: androidx.compose.foundation.BorderStroke? public var contentPadding: androidx.compose.foundation.layout.PaddingValues public var interactionSource: androidx.compose.foundation.interaction.MutableInteractionSource?}
extension View { public func material3NavigationBar(_ options: @Composable (Material3NavigationBarOptions) -> Material3NavigationBarOptions) -> some View}
public struct Material3NavigationBarOptions { public var modifier: androidx.compose.ui.Modifier public var containerColor: androidx.compose.ui.graphics.Color public var contentColor: androidx.compose.ui.graphics.Color public var tonalElevation: androidx.compose.ui.unit.Dp public var onItemClick: (Int) -> Void public var itemIcon: @Composable (Int) -> Void public var itemModifier: @Composable (Int) -> androidx.compose.ui.Modifier public var itemEnabled: (Int) -> Boolean public var itemLabel: (@Composable (Int) -> Void)? public var alwaysShowItemLabels: Bool public var itemColors: androidx.compose.material3.NavigationBarItemColors public var itemInteractionSource: androidx.compose.foundation.interaction.MutableInteractionSource?}
extension View { public func material3SegmentedButton(_ options: @Composable (Material3SegmentedButtonOptions) -> Material3SegmentedButtonOptions) -> some View}
public struct Material3SegmentedButtonOptions { public let index: Int public let count: Int public var selected: Boolean public var onClick: () -> Void public var modifier: androidx.compose.ui.Modifier public var enabled: Bool public var shape: androidx.compose.ui.graphics.Shape public var colors: androidx.compose.material3.SegmentedButtonColors public var border: androidx.compose.foundation.BorderStroke? public var contentPadding: androidx.compose.foundation.layout.PaddingValues public var interactionSource: androidx.compose.foundation.interaction.MutableInteractionSource? public var icon: @Composable () -> Void}
extension View { public func material3Text(_ options: @Composable (Material3TextOptions) -> Material3TextOptions) -> some View}
public struct Material3TextOptions { public var text: String? public var annotatedText: AnnotatedString? public var modifier: androidx.compose.ui.Modifier public var color: androidx.compose.ui.graphics.Color public var fontSize: androidx.compose.ui.unit.TextUnit public var fontStyle: androidx.compose.ui.text.font.FontStyle? public var fontWeight: androidx.compose.ui.text.font.FontWeight? public var fontFamily: androidx.compose.ui.text.font.FontFamily? public var letterSpacing: androidx.compose.ui.unit.TextUnit public var textDecoration: androidx.compose.ui.text.style.TextDecoration? public var textAlign: androidx.compose.ui.text.style.TextAlign? public var lineHeight: androidx.compose.ui.unit.TextUnit public var overflow: androidx.compose.ui.text.style.TextOverflow public var softWrap: Bool public var maxLines: Int public var minLines: Int public var onTextLayout: ((androidx.compose.ui.text.TextLayoutResult) -> Void)? public var style: androidx.compose.ui.text.style.TextStyle}
extension View { public func material3TextField(_ options: @Composable (Material3TextFieldOptions) -> Material3TextFieldOptions) -> some View}
public struct Material3TextFieldOptions { public var value: androidx.compose.ui.text.input.TextFieldValue public var onValueChange: (androidx.compose.ui.text.input.TextFieldValue) -> Void public var modifier: androidx.compose.ui.Modifier public var enabled: Bool public var readOnly: Bool public var textStyle: androidx.compose.ui.text.TextStyle public var label: (@Composable () -> Void)? public var placeholder: (@Composable () -> Void)? public var leadingIcon: (@Composable () -> Void)? public var trailingIcon: (@Composable () -> Void)? public var prefix: (@Composable () -> Void)? public var suffix: (@Composable () -> Void)? public var supportingText: (@Composable () -> Void)? public var isError: Bool public var visualTransformation: androidx.compose.ui.text.input.VisualTransformation public var keyboardOptions: androidx.compose.foundation.text.KeyboardOptions public var keyboardActions: androidx.compose.foundation.text.KeyboardActions public var singleLine: Bool public var maxLines: Int public var minLines: Int public var interactionSource: androidx.compose.foundation.interaction.MutableInteractionSource? public var shape: androidx.compose.ui.graphics.Shape public var colors: androidx.compose.material3.TextFieldColors}
extension View { public func material3TopAppBar(_ options: @Composable (Material3TopAppBarOptions) -> Material3TopAppBarOptions) -> some View}
public enum Material3TopAppBarNavigationIconButtonStyle { case iconButton case filledIconButton}
public struct Material3TopAppBarOptions { public var title: @Composable () -> Void public var modifier: androidx.compose.ui.Modifier public var navigationIcon: @Composable () -> Void public var navigationIconButtonStyle: Material3TopAppBarNavigationIconButtonStyle public var navigationIconButtonColors: androidx.compose.material3.IconButtonColors? public var colors: androidx.compose.material3.TopAppBarColors public var scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior? public var preferCenterAlignedStyle: Bool public var preferLargeStyle: Bool}请注意,.material3TopAppBar 涉及 Compose 的实验性 API,因此必须在使用它的 Skip Fuse ContentModfifier 或 Skip Lite View 中添加以下内容:
// SKIP INSERT: @OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)struct MyContentModifier : ContentModifier { ...}Material 效果
Section titled “Material 效果”点击组件时,Compose 会自动应用“涟漪”效果。可用 material3Ripple 定制颜色和透明度;若要完全禁用,请从闭包返回 nil。
extension View { public func material3Ripple(_ options: @Composable (Material3RippleOptions?) -> Material3RippleOptions?) -> some View}
public struct Material3RippleOptions { public var color: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Unspecified public var rippleAlpha: androidx.compose.material.ripple.RippleAlpha? = nil}支持的 SwiftUI
Section titled “支持的 SwiftUI”下表汇总 SkipUI 在 Android 上对 SwiftUI 的支持。未列出的内容很可能尚不支持。在仅用于 iOS 的 #if !os(Android) 代码块中可使用任意 SwiftUI API。
支持级别:
- ✅ – 完整支持
- 🟢 – 高度支持
- 🟡 – 中等支持
- 🟠 – 少量支持
| 支持度 | API |
|---|---|
| 🟢 |
|
| ✅ | @Bindable |
| ✅ | @Binding (example) |
| ✅ | |
| ✅ | @EnvironmentObject (example) |
| 🟢 |
|
| 🟡 |
|
| ✅ | @ObservedObject (example) |
| ✅ | @State (example) |
| ✅ | @StateObject (example) |
| ✅ | AsyncImage (example) |
| ✅ | Button (example) |
| ✅ | Capsule (example) |
| ✅ | Circle (example) |
| 🟢 |
|
| 🟡 |
|
| 🟡 |
|
| ✅ | Divider (example) |
| 🟢 | |
| 🟡 |
|
| ✅ | EmptyModifier |
| ✅ | EmptyView |
| 🟡 |
|
| 🟢 |
|
| ✅ | Form (example) |
| 🟡 |
|
| 🟡 |
|
| ✅ | Group |
| ✅ | HStack (example) |
| 🟢 | |
| 🟢 | |
| 🟡 |
|
| 🟡 |
|
| 🟡 |
|
| 🟡 |
|
| ✅ | LinearGradient (example) |
| ✅ | Link (example) |
| 🟢 | |
| 🟢 | |
| 🟢 | |
| ✅ | Menu (example) |
| 🟢 | |
| 🟢 |
|
| 🟢 | |
| ✅ | Oval (example) |
| ✅ | Picker (example) |
| 🟢 |
|
| ✅ | RadialGradient (example) |
| ✅ | Rectangle (example) |
| 🟢 | |
| ✅ | RoundedRectangle (example) |
| ✅ | ScrollView (example) |
| ✅ |
|
| 🟡 |
|
| 🟡 |
|
| 🟢 | |
| ✅ | SecureField (example) |
| 🟠 |
|
| 🟡 |
|
| 🟢 |
|
| 🟡 |
|
| 🟢 | |
| 🟠 |
|
| 🟠 |
|
| 🟠 |
|
| 🟢 | |
| 🟢 | |
| 🟢 |
|
| 🟢 |
|
| 🟢 |
|
| 🟡 |
|
| 🟢 |
|
| ✅ | ToolbarItem (example) |
| ✅ | ToolbarItemGroup (example) |
| ✅ | ToolbarSpacer |
| ✅ | ToolbarTitleMenu |
| 🟠 |
|
| ✅ | UnevenRoundedRectangle (example) |
| ✅ | Custom Views |
| ✅ | Custom ViewModifiers |
| ✅ | ViewThatFits |
| ✅ | |
| 🟡 |
|
| ✅ | ZStack (example) |
| 🟢 |
|
| ✅ | .accessibilityHeading |
| ✅ | .accessibilityHidden |
| ✅ | .accessibilityIdentifier |
| ✅ | .accessibilityLabel |
| ✅ | .accessibilityValue |
| ✅ | .alert |
| ✅ | .allowsHitTesting |
| 🟡 |
|
| 🟡 |
|
| ✅ | .autocorrectionDisabled |
| 🟢 | |
| ✅ | .backgroundStyle |
| ✅ | .badge |
| 🟢 |
|
| 🟢 |
|
| ✅ | .bold |
| ✅ | .border (example) |
| ✅ | .brightness |
| 🟢 |
|
| 🟡 |
|
| ✅ | .clipShape |
| ✅ |
|
| ✅ | .colorMultiply |
| ✅ | .confirmationDialog (example) |
| 🟠 | .contextMenu |
| ✅ | .contrast |
| ✅ | .cornerRadius |
| 🟠 |
|
| ✅ | .deleteDisabled |
| ✅ | .disabled |
| 🟢 |
|
| ✅ | .environment |
| ✅ | .environmentObject |
| ✅ | .fill |
| ✅ | .focused |
| ✅ | .flipsForRightToLeftLayoutDirection |
| ✅ | .font (example) |
| ✅ | .foregroundColor |
| ✅ | .foregroundStyle |
| 🟢 | |
| 🟢 |
|
| 🟢 | |
| 🟢 | |
| ✅ | .gradient (example) |
| ✅ | .grayscale |
| ✅ | .hidden |
| ✅ | .hueRotation |
| 🟢 |
|
| ✅ | .inset |
| ✅ |
|
| ✅ | .italic |
| ✅ | .keyboardType (example) |
| ✅ | .labelsHidden |
| ✅ |
|
| 🟢 |
|
| ✅ | .linespacing |
| ✅ | .listItemTint |
| ✅ | .listRowBackground |
| ✅ | .listRowSeparator |
| ✅ | .listStyle |
| ✅ | .luminanceToAlpha |
| ✅ | .mask |
| ✅ | .minimumScaleFactor |
| ✅ | .modifier (example) |
| ✅ | .monospaced |
| ✅ | .moveDisabled |
| ✅ | .multilineTextAlignment |
| ✅ | .navigationBarBackButtonHidden |
| ✅ | .navigationBarTitleDisplayMode |
| ✅ | .navigationDestination |
| 🟢 |
|
| ✅ | .offset (example) |
| ✅ | .onAppear |
| ✅ | .onChange |
| ✅ | .onDelete |
| ✅ | .onDisappear |
| 🟢 | |
| ✅ | .onMove |
| ✅ | .onOpenURL |
| ✅ | .onPreferenceChange |
| ✅ | .onReceive |
| ✅ | .onSubmit |
| 🟢 |
|
| ✅ | .opacity |
| ✅ | .overlay (example) |
| ✅ | .padding |
| 🟡 |
|
| ✅ | .position |
| ✅ | .preference |
| ✅ |
|
| 🟢 |
|
| 🟡 |
|
| ✅ | .refreshable |
| 🟠 |
|
| 🟢 |
|
| ✅ |
|
| 🟢 |
|
| ✅ | .saturation |
| 🟢 |
|
| 🟡 |
|
| 🟡 |
|
| ✅ |
|
| ✅ | .scrollContentBackground |
| 🟢 |
|
| 🟠 |
|
| 🟠 |
|
| 🟠 |
|
| 🟡 |
|
| 🟢 |
|
| 🟢 | |
| 🟢 |
|
| ✅ | .stroke |
| ✅ | .strokeBorder |
| ✅ | .submitLabel |
| ✅ | .symbolVariant |
| ✅ |
|
| ✅ |
|
| ✅ | .tag |
| ✅ | .task |
| ✅ | .textCase |
| 🟢 | .textContentType |
| ✅ | .textEditorStyle |
| 🟡 |
|
| ✅ | .textInputAutocapitalization |
| ✅ | .tint |
| 🟢 |
|
| ✅ | .toolbarBackground |
| ✅ | .toolbarColorScheme |
| ✅ | .toolbarTitleDisplayMode |
| ✅ | .toolbarTitleMenu |
| ✅ | .tracking |
| 🟢 |
|
| 🟢 |
|
| ✅ | .truncationMode |
| ✅ | .zIndex (example) |
支持的 UIKit
Section titled “支持的 UIKit”SkipUI 不支持 UIKit 视图本身,但支持 UIKit 的一个子集,例如作为 Android 底层服务接口的剪贴板和触觉反馈类。
下表汇总 SkipUI 在 Android 上对 UIKit 的支持。未列出的内容很可能尚不支持;在仅用于 iOS 的 #if !SKIP 代码块中可使用任意 UIKit API。
支持级别:
- ✅ – 完整支持
- 🟢 – 高度支持
- 🟡 – 中等支持
- 🟠 – 少量支持
| 支持度 | API |
|---|---|
| 🟠 |
|
| ✅ | #colorLiteral() |
| 🟠 |
|
| 🟠 |
|
| ✅ | UIImpactFeedbackGenerator |
| ✅ | UINotificationFeedbackGenerator |
| 🟠 |
|
| ✅ | UISelectionFeedbackGenerator |
支持的 UserNotifications
Section titled “支持的 UserNotifications”Skip 将对 UserNotifications 框架的支持集成在 SkipUI 中。
重要: Android 状态栏通知图标必须具有透明背景和纯色形状(通常为白色)才能正确显示。建议使用专用图标,并在 AndroidManifest.xml 的 application 标签内通过以下代码指定:
<meta-data android:name="com.google.firebase.messaging.default_notification_icon" android:resource="@drawable/ic_notification" />Skip 假定图标名为 ic_notification 且位于 drawable 资源中;否则回退使用应用图标(如 @mipmap/ic_launcher),但此时很可能显示为纯白圆点。
下表汇总 SkipUI 在 Android 上对 UserNotifications 的支持。未列出的内容很可能尚不支持;在仅用于 iOS 的 #if !SKIP 代码块中可使用任意 UserNotifications API。
支持级别:
- ✅ – 完整支持
- 🟢 – 高度支持
- 🟡 – 中等支持
- 🟠 – 少量支持
| 支持度 | API |
|---|---|
| 🟡 |
|
| 🟡 |
|
| ✅ | UNNotification |
| 🟡 |
|
| 🟡 |
|
| ✅ | UNNotificationRequest |
| ✅ | UNNotificationResponse |
| 🟡 |
|
| 🟡 |
|
| 🟡 |
|
| ✅ | UNUserNotificationCenterDelegate |
Skip 在 Android 上支持 SwiftUI 的 .animation、.transition 修饰器以及 withAnimation 函数。
目前可为以下属性添加动画:
.background颜色.border颜色.fill颜色.font字号.foregroundColor.foregroundStyle颜色.frame宽度和高度.offset.opacity.padding.rotationEffect.scaleEffect.stroke颜色
Android 支持 SwiftUI 的所有内置过渡。不过,要使用过渡或为视图的添加/移除设置动画,必须为父级 HStack、VStack 或 ZStack 中的每个视图分配唯一 .id:
VStack { FirstView() .id(100) if condition { SecondView() .transition(.scale) .id(200) }}.animation(.default)Skip 会将各种 SwiftUI 动画类型转换为 Compose 对应类型。许多 SwiftUI 弹簧动画使用简单的 EaseInOutBack 缓动;只有通过 SwiftUI.Spring(mass:stiffness:damping:) 构造时才会生成真正的 Compose 弹簧动画。这可以避开 Compose 弹簧的以下限制:
- 真正的弹簧动画不能设置持续时间
- 真正的弹簧动画不能设置延迟
- 真正的弹簧动画不能重复
不支持自定义 Animatable 和 Transition。如果嵌套 withAnimation 代码块,Android 会把最内层动画应用到代码块中的所有操作。
除了以编程方式使用 .tint,iOS 还允许通过 Assets 资源目录中的 AccentColor 设置应用强调色。Skip 应用的 Assets 位于 Darwin 文件夹。
Skip 也支持这些机制,但生成的 Android 应用无法访问 Darwin 内容。要为 Android 定义强调色,请在 Sources/<YourAppModule>/Resources/Module 资源目录中创建名为 AccentColor 的颜色集。详情参见下方“命名颜色”。
命名颜色可打包在 SwiftPM 模块 Resources 文件夹的资源目录中。Package.swift 的模块 .target 应包含 Resources 以进行资源处理(skip init 创建的项目默认如此):
.target(name: "MyModule", dependencies: ..., resources: [.process("Resources")], plugins: skipstone)将资源目录加入 Resources 后,可用 Color(_:bundle:) 加载并显示任意命名颜色。例如:
Color("WarningYellow", bundle: .module)命名颜色的具体示例见 Skip Showcase 应用的 ColorPlayground;应用模块 .xcassets 的配置见其 Xcode 项目文件(截图 ↗)。
使用 skip init 创建项目时,会生成包含应用图标的项目级 Assets.xcassets 和空的模块级 Module.xcassets。请将资源添加到 Module.xcassets。 转译器只处理模块级资源目录。
在 Android 上,Skip 只使用为“Universal”设备设置的命名颜色;可通过 RGB 值或“Universal System Color”常量定义。
ColorScheme
Section titled “ColorScheme”SkipUI 完整支持 .preferredColorScheme。若应用由 v0.8.26 之前的 skip 工具创建,需要参照最新 Main.kt ↗ 更新 Android/app/src/main/kotlin/.../Main.kt:
- 用最新
Main.kt中的导入语句替换全部导入 - 用最新
Main.kt的内容替换setContent { ... }代码块 - 用最新
Main.kt的PresentationRootView(context:)替换MaterialThemeRootView()
完成这些更新后即可正常使用 .preferredColorScheme。
可使用 Font.custom 嵌入并引用自定义字体。iOS 使用完整 PostScript 名称,Android 使用不含扩展名的字体文件名。
Android 字体文件名只能包含字母、数字和下划线,因此应把嵌入字体命名为 PostScript 名称的小写下划线形式。SkipUI 的 Font.custom 会自动把“Protest Guerrilla”等名称转换为 protest_guerrilla.ttf。
Text("Custom Font") .font(Font.custom("Protest Guerrilla", size: 30.0)) // protest_guerrilla.ttf各平台嵌入字体的方式不同。Android 中应创建 Android/app/src/main/res/font/ 并加入字体文件,系统会自动将其嵌入为资源。
iOS 中需把字体加入 Xcode 应用 target,确认其位于“Build Phases”→“Copy Bundle Resources”,并在“Info”→“Custom Application Target Properties”中添加“Fonts provided by application”(原始键名 UIAppFonts),把各字体文件名加入字符串数组。
自定义字体示例见 Skip Showcase 的 TextPlayground;字体在 iOS 与 Android 两端的配置见其 Xcode 项目文件(截图 ↗)。
SwiftUI 的内置环境键定义于 EnvironmentValues,通常通过 @Environment 访问。除自定义环境键外,SkipUI 还公开以下内置环境键:
accessibilityEnabled(只读)accessibilityInvertColors(只读)accessibilityReduceMotion(只读)accessibilityReduceTransparency(只读;Android 映射为“减少模糊效果”)accessibilitySwitchControlEnabled(只读)accessibilityVoiceOverEnabled(只读)autocorrectionDisabled(只读)backgroundStylecolorSchemeContrast(只读)dismissfonthorizontalSizeClassisEnabledisSearching(只读)layoutDirectionlegibilityWeight(只读)lineLimitlocaleopenURLrefreshscenePhasescrollDismissesKeyboardModetimeZonetruncationModeverticalSizeClass
ForEach
Section titled “ForEach”SwiftUI ForEach 可为范围或集合生成视图。SkipUI 支持任意 Int 范围和 RandomAccessCollection。若元素不遵循 Identifiable,请指定能唯一标识元素的属性键路径;id 必须遵守标识符限制。
ForEach([person1, person2, person3], id: \.fullName) { person in HStack { Text(person.fullName) Spacer() Text(person.age) }}重要: 当 ForEach 主体包含多个顶层视图,或包含会展开出其他视图的单个视图(如 Section 或嵌套 ForEach)时,SkipUI 必须“展开”循环,将每个视图分别交给 Compose。这意味着 ForEach 会预先完整迭代,但生成的视图尚不会渲染。
SkipUI 目前支持点击、长按、拖动、缩放和旋转手势。可使用 .gesture、用于受支持观察器的 .simultaneousGesture,或 .onTapGesture 等专用修饰器。限制如下:
@GestureState仅在 Skip Fuse 中支持;请用Gesture.onEnded重置状态。- 不支持点击次数大于 2。
- 手势速度始终报告为零,预测结束位置始终为当前位置。
- 仅支持
onChanged和onEnded。 - 通常不支持自定义最短触摸时长、距离等参数。
.simultaneousGesture仅部分支持:可在同一渲染视图上观察受支持的手势,包括滚动期间的拖动;但只有.all和.none具有实际遮罩行为,不区分.gesture与.subviews。- 向偏移视图应用手势时,请把手势修饰器放在
.offset之前。
最后一项限制有一个例外:可以创建 DragGesture(minimumDistance: 0) 来立即检测按下事件。
SwiftUI 会自动为形状和路径应用遮罩,避免外部触摸触发手势。SkipUI 模拟了此功能,但不支持应用 .stroke 的自定义形状和路径;它们会在整个边界内响应触摸。需要手势遮罩时可改用 .strokeBorder。
SkipUI 使用原生 Compose 网格渲染 SwiftUI 网格,以获得最佳性能和 Android 原生体验。由于两者能力不同,存在以下限制:
- 不支持固定页眉和页脚。
- 将
LazyHGrid或LazyVGrid放入ScrollView时,它必须是唯一子视图,因为 Compose 网格拥有内部滚动容器。若需其他内容,可将非网格内容放入网格内空Section的header。ScrollView {LazyVGrid(columns: [GridItem(.adaptive(minimum: 80))]) {Section {} header: {Text("Insert non-grid content here")}ForEach(0..<50) { index inText("Grid Cell \(index)")}Section {} header: {Text("More non-grid content here")}}} - 使用
GridItem数组定义网格时,Android 网格以第一个GridItem为准。Compose 不支持每行或每列不同规格,因此 SkipUI 会把首个规格应用到全部项目。 - 会忽略
GridItem最大尺寸。 - 另请参见
ForEach专题。
SkipUI 支持 UIKit 的 UIFeedbackGenerator API,用于在设备上生成触觉反馈。示例如下:
// 冲击触觉反馈UIImpactFeedbackGenerator(style: .light).impactOccurred()UIImpactFeedbackGenerator(style: .medium).impactOccurred()UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
UIImpactFeedbackGenerator().impactOccurred(intensity: 0.5)
// 通知触觉反馈UINotificationFeedbackGenerator().notificationOccurred(.success)UINotificationFeedbackGenerator().notificationOccurred(.warning)UINotificationFeedbackGenerator().notificationOccurred(.error)
// 选择触觉反馈UISelectionFeedbackGenerator().selectionChanged()SkipUI 支持使用 SwiftUI AsyncImage 从网络 URL 加载图像。Android 实现通过 Coil ↗ 下载,并支持如下加载指示器:
AsyncImage(url: URL(string: "https://picsum.photos/id/237/200/300")) { image in image.resizable()} placeholder: { ProgressView()}图像可打包在 SwiftPM 模块 Resources 文件夹的资源目录中。Package.swift 的模块 .target 应包含 Resources 以进行资源处理(skip init 项目默认如此):
.target(name: "MyModule", dependencies: ..., resources: [.process("Resources")], plugins: skipstone)将资源目录加入 Resources 后,可使用 Image(name:bundle:) 加载并显示捆绑图像。例如:
Image("Cat", bundle: .module, label: Text("Cat JPEG image"))使用 skip init 创建项目时,会生成包含应用图标的项目级 Assets.xcassets 和空的模块级 Module.xcassets。请将资源添加到 Module.xcassets。 转译器只处理模块级资源目录。
捆绑图像示例见 Skip Showcase 的 ImagePlayground;应用模块 .xcassets 配置见其 Xcode 项目文件(截图 ↗)。
除 .png、.jpg 位图外,资源目录还支持 .svg 和 .pdf 矢量图,适合无损缩放和图标。SVG 来源见下方系统符号。PDF 必须在 Xcode 中启用“Preserve Vector Data”(截图 ↗),才能通过 .foregroundStyle(color) 着色;否则始终使用 PDF 自带颜色。
Image("baseball-icon", bundle: .module, label: Text("Baseball Icon")) .resizable() .aspectRatio(contentMode: .fit) .foregroundStyle(Color.cyan) .frame(width: 30, height: 30)除资源目录外,也可把图像直接放入 Resources,通过 AsyncImage 引用本地图像。此方式同时适用于 iOS 和 Skip Android。例如以下代码可显示 Sources/MyModule/Resources/sample.jpg:
AsyncImage(url: Bundle.module.url(forResource: "sample", withExtension: "jpg"))Image(systemName:) 用于显示 Darwin 平台的标准系统符号。Android 没有内置对应项,但可手动添加同名矢量符号:Image(systemName: "folder.fill") 在 iOS 使用内置符号,在 Android 使用捆绑的 folder.fill.svg。
- 若尚不存在,请在顶层应用模块的
Resources中创建Module.xcassets。 - 从 Google Material Icons ↗ 下载所需符号,并选择 iOS SVG 格式 ↗;也可从 SF Symbols ↗ 导出。
- 将文件命名为要在 Android 上表示的同名 iOS 符号,并保留
.svg扩展名。 - 将文件拖入
Module.xcassets。
导出系统符号的示例见 Skip Showcase 的 ImagePlayground;符号加入 .xcassets 的方式见项目文件(截图 ↗)。
SkipUI 目前支持用 foregroundStyle 和 fontWeight 定制符号颜色与粗细,尚不支持 symbolVariant、symbolRenderingMode 等其他符号修饰器。
下载的符号可直接使用,也可用 SVG 编辑器制作自定义矢量符号,参见为应用创建自定义符号图像 ↗。使用 Image(systemName:) 加载系统符号,使用 Image(_:bundle) 加载自定义符号:
// 显示系统符号图像Image(systemName: "multiply.circle.fill")
// 显示模块资源目录中的自定义符号图像Image("custom.multiply.circle", bundle: .module)更多说明参见加载符号图像 ↗。
若顶层应用模块的资源目录中找不到同名系统符号,SkipUI 会回退到一小组预定义名称,并映射为等价的 Compose Material 符号 ↗。它们不会与 iOS 完全一致,但能近似表达形状与含义。
| iOS | Android |
|---|---|
| arrow.clockwise.circle | Icons.Outlined.Refresh |
| arrow.forward | Icons.Outlined.ArrowForward |
| arrow.forward.square | Icons.Outlined.ExitToApp |
| arrow.left | Icons.Outlined.ArrowBack |
| arrowtriangle.down.fill | Icons.Outlined.ArrowDropDown |
| bell | Icons.Outlined.Notifications |
| bell.fill | Icons.Filled.Notifications |
| calendar | Icons.Outlined.DateRange |
| cart | Icons.Outlined.ShoppingCart |
| cart.fill | Icons.Filled.ShoppingCart |
| checkmark | Icons.Outlined.Check |
| checkmark.circle | Icons.Outlined.CheckCircle |
| checkmark.circle.fill | Icons.Filled.CheckCircle |
| chevron.down | Icons.Outlined.KeyboardArrowDown |
| chevron.left | Icons.Outlined.KeyboardArrowLeft |
| chevron.right | Icons.Outlined.KeyboardArrowRight |
| chevron.up | Icons.Outlined.KeyboardArrowUp |
| ellipsis | Icons.Outlined.MoreVert |
| envelope | Icons.Outlined.Email |
| envelope.fill | Icons.Filled.Email |
| exclamationmark.triangle | Icons.Outlined.Warning |
| exclamationmark.triangle.fill | Icons.Filled.Warning |
| face.smiling | Icons.Outlined.Face |
| gearshape | Icons.Outlined.Settings |
| gearshape.fill | Icons.Filled.Settings |
| hand.thumbsup | Icons.Outlined.ThumbUp |
| hand.thumbsup.fill | Icons.Filled.ThumbUp |
| heart | Icons.Outlined.FavoriteBorder |
| heart.fill | Icons.Outlined.Favorite |
| house | Icons.Outlined.Home |
| house.fill | Icons.Filled.Home |
| info.circle | Icons.Outlined.Info |
| info.circle.fill | Icons.Filled.Info |
| line.3.horizontal | Icons.Outlined.Menu |
| list.bullet | Icons.Outlined.List |
| location | Icons.Outlined.LocationOn |
| location.fill | Icons.Filled.LocationOn |
| lock | Icons.Outlined.Lock |
| lock.fill | Icons.Filled.Lock |
| magnifyingglass | Icons.Outlined.Search |
| mappin.circle | Icons.Outlined.Place |
| mappin.circle.fill | Icons.Filled.Place |
| paperplane | Icons.Outlined.Send |
| paperplane.fill | Icons.Filled.Send |
| pencil | Icons.Outlined.Create |
| person | Icons.Outlined.Person |
| person.crop.circle | Icons.Outlined.AccountCircle |
| person.crop.circle.fill | Icons.Filled.AccountCircle |
| person.crop.square | Icons.Outlined.AccountBox |
| person.crop.square.fill | Icons.Filled.AccountBox |
| person.fill | Icons.Filled.Person |
| phone | Icons.Outlined.Call |
| phone.fill | Icons.Filled.Call |
| play | Icons.Outlined.PlayArrow |
| play.fill | Icons.Filled.PlayArrow |
| plus | Icons.Outlined.Add |
| plus.circle.fill | Icons.Outlined.AddCircle |
| square.and.arrow.up | Icons.Outlined.Share |
| square.and.arrow.up.fill | Icons.Filled.Share |
| star.fill | Icons.Filled.Star |
| trash | Icons.Outlined.Delete |
| trash.fill | Icons.Filled.Delete |
| wrench | Icons.Outlined.Build |
| wrench.fill | Icons.Filled.Build |
| xmark | Icons.Outlined.Clear |
在仅 Android 的代码中,也可把任意 androidx.compose.material.icons.Icons 名称作为 systemName。例如:
#if SKIPImage(systemName: "Icons.Filled.Settings")#endif首帧渲染与 AsyncImagePhase 的特殊行为
Section titled “首帧渲染与 AsyncImagePhase 的特殊行为”SkipUI 的 AsyncImage 基于受 SwiftUI 启发的 Coil ↗。它同样允许指定 URL 与占位内容,或响应图像加载阶段变化。
两者的主要区别是:首帧时 Coil 尚不知道布局约束尺寸,因此无法判断图像能否从内存缓存成功渲染。
这会影响 AsyncImage(url:scale:transaction:content:) ↗:其 content 回调接收具有三个阶段的 AsyncImagePhase ↗。
empty:尚未加载图像failure(any Error):图像加载失败success(Image):图像加载成功
Coil 有四个阶段:Success、Failure、Loading 和 Empty;Empty 表示尚不能确定图像是否就绪。
为表达这一状态,SkipUI 的 empty 实际为 empty(Image?),可通过 phase.image 读取。若阶段为 empty 且图像为 nil,说明 Coil 正在加载,应显示占位内容。
若 empty 中图像不为 nil,可以自行决定处理方式。
可以采用以下方案:
-
方案一(乐观):先渲染图像并期待命中缓存,同时延后显示占位内容。
AsyncImage(url: url) { phase inswitch phase {case .empty:if let image = phase.image {image.resizable()} else {ProgressView()}case .failure:Color.redcase .success(let image):image.resizable()}}注意:这可能导致布局偏移。 若图像尚未就绪,它会以 0×0 尺寸渲染。可考虑采用下方方案二的透明
Color.clear占位。 -
方案二(ZStack):阶段为
empty时,在ZStack中把占位内容渲染在图像下方。若图像成功渲染且不透明,会完全遮住占位内容。AsyncImage(url: url) { phase inswitch phase {case .empty:if let image = phase.image {ZStack {ProgressView() // or Color.clearif let image = phase.image {image.resizable()}}} else {ProgressView()}case .failure:Color.redcase .success(let image):image.resizable()}}使用方案二时,透明图像首帧可能透出下层占位内容。此时可在
empty且存在图像时使用Color.clear等透明占位。 -
方案三(保守):先渲染占位内容,直到确定图像就绪后再显示图像。
AsyncImage(url: url) { phase inswitch phase {case .empty:ProgressView()case .failure:Color.redcase .success(let image):image.resizable()}}使用惯用 SwiftUI 写法调用该构造器时会得到此行为。
使用 AsyncImage(url:scale:) ↗ 或 AsyncImage(url:scale:content:placeholder:) ↗ 时,SkipUI 默认选择方案二(ZStack),尽快渲染图像。首帧使用 Color.clear 防止布局偏移;若图像未立即加载,可见占位内容会延迟一帧。
使用 .subcomposeAsyncImage() 避开首帧渲染特殊行为(会牺牲性能)
Section titled “使用 .subcomposeAsyncImage() 避开首帧渲染特殊行为(会牺牲性能)”Coil 还提供不使用 Empty 阶段的 SubcomposeAsyncImage,它借助 BoxWithConstraints 在渲染前测量约束尺寸。
Coil 文档警告 SubcomposeAsyncImage“较慢”。
子组合比常规组合慢,因此可能不适合 UI 中性能敏感的部分(如
LazyList)。
具体而言,只有需要观察
AsyncImagePainter.state,且首轮组合与首帧不能为Empty时,SubcomposeAsyncImage才有用。
要启用 SubcomposeAsyncImage,可使用仅限 Android 的 .subcomposeAsyncImage() 修饰器。
AsyncImage(url: url) { image in image.resizable()} placeholder: { Color.gray}#if os(Android).subcomposeAsyncImage()#endif.subcomposeAsyncImage() 设置环境值,可在高层级启用并影响树中所有图像,也可用 .subcomposeAsyncImage(false) 为整个子树关闭。
是否需要它,取决于你的首要目标:
-
是否最看重尽快渲染图像?
若是,请勿使用
.subcomposeAsyncImage();SkipUI 默认行为已为此优化。 -
是否更重视尽快显示占位内容,即使会延迟图像?
此时使用
AsyncImage(url:scale:transaction:content:)↗,在case empty:中保守地渲染占位内容(见上方方案三)。 -
是否更重视首帧正确,即使主 UI 线程需要更多工作?
此时应使用
.subcomposeAsyncImage();它较慢,但首帧即可显示正确 UI。
SkipUI 完整支持 HStack、VStack、ZStack 和 .frame 等 SwiftUI 布局机制。若发现 Android 与 iOS 不一致的边缘情况,请提交 Issue。已知差异如下:
- Skip 不会像 SwiftUI 那样隐式创建
VStack;多个视图必须显式放入所需类型的栈。 - 嵌套栈中的
Spacer或无限宽度 frame 可能测量不同;可尝试减少嵌套。 - 若明确设置的
maxWidth或maxHeight大于父级实际尺寸,Android 上视图可能超出父容器。可用GeometryReader计算父级尺寸,再设置明确的宽高。参见 Issue #339 ↗。
注意:若应用依赖旧版 Skip 的布局细节,可在视图层级应用仅限 Android 的 .layoutImplementationVersion() 来模拟旧行为:
SomeRootView() #if os(Android) .layoutImplementationVersion(0) #endifSwiftUI List 功能强大且灵活。SkipUI 目前支持以下内容定义方式。
静态内容:直接在 List 中为每行嵌入子视图:
List { Text("Row 1") Text("Row 2") Text("Row 3")}索引内容:指定 Int 范围和闭包,为每个索引创建一行:
List(1...100) { index in Text("Row \(index)")}集合内容:提供任意 RandomAccessCollection(通常是 Array)和为每个元素创建行的闭包。若元素不遵循 Identifiable,请指定唯一标识属性的键路径:
List([person1, person2, person3], id: \.fullName) { person in HStack { Text(person.fullName) Spacer() Text(person.age) }}ForEach 内容:使用 ForEach 指定索引或集合内容,从而混合不同内容类型。
List { Text("People").bold() ForEach([person1, person2, person3], id: \.fullName) { person in HStack { Text(person.fullName) Spacer() Text(person.age) } }}使用集合内容时,可通过集合绑定和相应编辑操作启用滑动删除与拖动排序。
List($people, id: \.fullName, editActions: .all) { $person in Text(person.fullName) .deleteDisabled(!person.isDeletable) }}也可在 ForEach 上使用 .onDelete 和 .onMove 启用编辑,并确保为每项提供 id。
- Compose 要求
List中每个id全局唯一,即使列表包含多个Section或ForEach。 id还必须遵守标识符限制。ForEach与Section的嵌套能力有限。- 另请参见
ForEach专题。
SwiftUI 主要有 TabView、NavigationStack 和模态呈现三种导航形式,SkipUI 均已实现,但存在下述限制。
SkipUI 的 TabView 尚不支持溢出标签行为;标签过多时不会自动生成“更多”。TabSections 等顶部或侧栏标签功能也会被忽略,除此之外行为符合预期。
SwiftUI 使用 NavigationLink 向 NavigationStack 推入视图。目标既可直接嵌入,也可提供一个值并通过 .navigationDestination 映射为视图,如下:
NavigationStack { ListView() .navigationTitle(Self.title)}
struct ListView : View { var body: some View { List(City.allCases) { city in NavigationLink(value: city) { rowView(city: city) } } .navigationDestination(for: City.self) { city in CityView(city: city) } }}.navigationDestination 还有一种接收绑定与目标视图的形式:
func navigationDestination(isPresented: Binding<Bool>, @ViewBuilder destination: () -> any View) -> some ViewSkipUI 支持上述所有模型。使用 .navigationDestination(isPresented:destination:) 时,手动将 isPresented 设为 false 不会关闭视图,请使用标准关闭机制。按类型绑定目标时也需谨慎:推入视图若为键类型 T 定义新目标,会覆盖栈中此前的 T 映射。不要在同一导航栈中无意重复映射同一键类型。
Compose 还有一项限制:.navigationDestination 的数据键类型必须能转换为字符串。参见下方标识符限制。
使用提示避免布局偏移
Section titled “使用提示避免布局偏移”Android Compose TopAppBar 要求客户端在组合导航栏前选择尺寸并传入 title。SwiftUI 则通过内容上的 navigationBarTitleDisplayMode 与 navigationTitle 设置;底层会写入 SwiftUI 偏好值 ↗以改变 NavigationStack 行为。
若内容组合较慢,NavigationStack 会先猜测导航栏高度,随后发现真实高度并上下移动内容,造成“布局偏移”。
为避免布局偏移,可使用 navigationStackLayoutHints:
struct MyView: View { var body: some View { NavigationStack{ Text("My Content") .navigationTitle("My Title") .navigationBarTitleDisplayMode(.inline) } #if os(Android) .navigationStackLayoutHints( expectedTitle: Text("My Title"), expectedTitleDisplayMode: .inline ) #endif }}这些提示让 NavigationStack 首次即可渲染正确的导航栏内容,从而消除布局偏移。
若各导航目标的预期不同,可通过 navigationDestinationLayoutHints 分别设置:
struct CityPickerView: View { var body: some View { NavigationStack { List(City.allCases) { city in NavigationLink(value: city) { rowView(city: city) } } .navigationTitle("Cities") .navigationBarTitleDisplayMode(.inline) .navigationDestination(for: City.self) { city in CityView(city: city) .navigationTitle(city.rawValue) .navigationBarTitleDisplayMode(.large) } #if os(Android) .navigationDestinationLayoutHints( for: City.self, expectedTitleDisplayMode: .large ) #endif } #if os(Android) .navigationStackLayoutHints( expectedTitle: Text("Cities"), expectedTitleDisplayMode: .inline ) #endif }}配置导航过渡动画
Section titled “配置导航过渡动画”SkipUI 使用 Navigation 3 ↗ 为 NavigationStack 的推入/弹出和 TabView 的切换添加动画。TabView 使用快速淡化,NavigationStack 使用滑动加淡化模拟 SwiftUI 默认动画。
可用 .navigationStackTransitions 和 .tabViewTransitions 配置导航动画,传入返回 NavDisplayTransitionOptions 的闭包。
public struct NavDisplayTransitionOptions { public enum TransitionPreset { case `default` // `default` uses NavDisplay.defaultTransitionSpec, .defaultPopTransitionSpec, and .defaultPredictivePopTransitionSpec case slideIn case slideOut case fade case slideInAndFade // This is the default NavigationStack transition case slideOutAndFade case none // instant transition with no animation }
public init( transition: TransitionPreset = .default, popTransition: TransitionPreset = .default, predictivePopTransition: TransitionPreset = .default )
public init(_ transitionPresets: TransitionPreset)
public func copy( transition: TransitionPreset? = nil, popTransition: TransitionPreset? = nil, predictivePopTransition: TransitionPreset? = nil ) -> NavDisplayTransitionOptions
#if SKIP public let transitionSpec: ContentTransform? public let popTransitionSpec: ContentTransform? public let predictivePopTransitionSpec: ContentTransform?
public init( transitionSpec: ContentTransform?, popTransitionSpec: ContentTransform?, predictivePopTransitionSpec: ContentTransform? )
public init(_ transitionSpecs: ContentTransform?)
public func copy( transitionSpec: ContentTransform? = self.transitionSpec, popTransitionSpec: ContentTransform? = self.popTransitionSpec, predictivePopTransitionSpec: ContentTransform? = self.predictivePopTransitionSpec ) -> NavDisplayTransitionOptions #endif}闭包可以返回自定义 NavDisplayTransitionOptions,也可以复制默认选项并只修改某个过渡。
#if os(Android).tabViewTransitions { options in NavDisplayTransitionOptions(.none)}.navigationStackTransitions { _ in options.copy(predictivePopTransition: .fade)}#endif在转译的 #if SKIP 代码中可指定自定义动画规格;Skip Fuse 中则使用 composeModifier。
#if os(Android).composeModifier { CustomNavigationAnimationSpecModifier()}#endif
// ...
#if SKIPstruct CustomNavigationAnimationSpecModifier : ContentModifier { func modify(view: any View) -> any View { view.composeModifier { $0.navigationStackTransitions { _ in NavDisplayTransitionOptions( // this example is just .slideInAndFade, but you could customize it however you like (slideInHorizontally { $0 } + fadeIn()).togetherWith(slideOutHorizontally { -$0 } + fadeOut()) ) } } }}#endifSkip 支持标准模态呈现。Android 通常允许用返回键关闭模态视图;可用仅限 Android 的 backDismissDisabled 选择性禁用。此修饰器必须放在 .sheet 或 .fullScreenCover 的顶层嵌入视图上:
SomeContentView() .sheet(isPresented: $isSheetPresented) { SheetContentView() #if os(Android) .backDismissDisabled() #endif }受 Compose 限制,模态视图呈现期间修改 .backDismissDisabled 无效,只采用呈现时的值。
可将 backDismissDisabled 与 .interactiveDismissDisabled 搭配,进一步禁止下拉关闭。
Compose 要求所有状态值可序列化。使用 @State 等包装器时,SkipUI 会自动跟踪对象并向 Compose 提供可序列化标识符,因此通常无感。但 navigationDestination 值和 List 项目标识符必须直接存入 Compose,此时 SkipUI 按以下算法生成 String:
- 若值遵循
Identifiable,使用String(describing: value.id) - 若值遵循
RawRepresentable,使用String(describing: value.rawValue) - 否则使用
String(describing: value)
使用这些 API 时,请确保上述算法能为不同值生成唯一且稳定的字符串。
与 iPhone 类似,Android 可在顶部状态栏和底部手势区等系统栏后渲染内容。使用 .ignoresSafeArea 时,SwiftUI 与 SkipUI 基本一致,但有两个例外:
- SkipUI 忽略
SafeAreaRegions.keyboard;屏幕键盘不作为安全区域,而是缩小内容区使其位于键盘上方。 - 该
.background修饰器的第二个参数目前默认为[]而非.all;若要忽略安全区域,请显式指定边缘:
MyView() .background(.yellow, ignoresSafeAreaEdges: .all)可使用 #if SKIP 代码块,把 .ignoresSafeArea 调用限定在 iOS 或 Android。
启用或禁用边到边显示
Section titled “启用或禁用边到边显示”新版 SkipUI 默认启用 Jetpack Compose 的边到边模式。若应用由 v0.8.32 之前的 skip 创建,需要参照最新 Main.kt ↗ 更新项目文件,才能在系统栏后渲染:
- 添加导入:
import androidx.activity.enableEdgeToEdge - 向
MainActivity.onCreate(savedInstanceState:)添加以下代码:
override fun onCreate(savedInstanceState: android.os.Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() // <--- Add this line ...完成更新后,内容会延伸到系统栏下方。现代 SkipUI 若要禁用边到边模式,只需移除 enableEdgeToEdge()。
ScrollView 通常与 iOS 表现一致,但内容受到以下限制:
- 忽略
ScrollView和ScrollViewProxy的UnitRect参数。 ScrollViewProxy仅适用于List和各类惰性容器。.scrollPosition(id:)仅适用于惰性容器,且不支持anchor参数。- 不支持
.scrollPosition(_ position: Binding<ScrollPosition>, anchor: UnitPoint? = nil)。 - 放入
ScrollView的惰性容器必须是其唯一内容。 - 使用
.scrollTargetBehavior的ScrollView必须只包含一个应用.scrollTargetLayout的惰性容器,如下:
ScrollView(.horizontal) { LazyHStack { ... } .scrollTargetLayout()}.scrollTargetBehavior(.viewAligned)自定义 Intent
Section titled “自定义 Intent”UIApplication.open 的实现使用 Android Intent ↗ 系统启动与 URL 关联的服务。
除 HTTPS、tel:、sms: 等标准链接外,Skip 还允许把自定义 Intent 名称作为特殊的 OpenExternalURLOptionsKey 值 intent,以便在 Android 上启动指定 Intent。例如:
#if os(Android)Button("Send Email") { let mailto = URL(string: "mailto:hello@example.com")!
var options: [UIApplication.OpenExternalURLOptionsKey : Any] = [:] // specify the exact intent to use options[UIApplication.OpenExternalURLOptionsKey(rawValue: "intent")] = "android.intent.action.SENDTO" // add values for the keys supported by the intent // https://developer.android.com/reference/android/content/Intent#EXTRA_SUBJECT options[UIApplication.OpenExternalURLOptionsKey(rawValue: "android.intent.extra.SUBJECT")] = "Email Subject Line" options[UIApplication.OpenExternalURLOptionsKey(rawValue: "android.intent.extra.TEXT")] = "Email body"
Task { await UIApplication.shared.open(mailto, options: options) }}#endif欢迎为 SkipUI 贡献代码。Skip 产品文档提供了本地开发 Skip 库的说明与技巧。
当前最需要实现更多核心组件和视图修饰器。Compose Modifier 常有可复现 SwiftUI 修饰器的内置函数,因此许多修饰器很容易补齐。
若要帮助补齐 SkipUI 中尚未实现的 API:
- 找到尚未实现的 API。它们位于
#if !SKIP代码块中,或标记为@available(*, unavailable);大多数未实现的View修饰器位于View.swift。 - 编写合适的 Compose 实现,参见下方实现策略。
- 向 SkipFuseUI 添加编译型 Swift 包装器。
- 编写 Showcase 代码演示并测试组件,参见测试。
- 提交 PR ↗。
也欢迎测试用例、注释和文档等其他形式的贡献!
测试 SkipUI 对 SwiftUI 组件支持的常见方式是使用 Skip Showcase 和 Skip Showcase Fuse 应用。每次新增或更新可见元素支持时,都应添加相应展示视图,以验证外观和行为,并持续展示 Android 已支持的组件。
SkipLite 代码转换
Section titled “SkipLite 代码转换”SkipUI 无法独立工作。用于 Skip Lite 转译 Swift 时,它依赖 skip ↗ 插件对 SwiftUI 代码的转换。虽然 Skip 通常力求生成接近手写的 Kotlin,但这些 SwiftUI 转换并不适合手工编写。下面先了解其机制。
SwiftUI 与 Compose 都是声明式 UI 框架,都会跟踪状态并在变化时自动重绘。但 SwiftUI 以 View 对象建模,Compose 以 @Composable 函数建模,因此转译器必须把 View 图转换为函数调用,主要包含两项转换:
- 转译器插入代码,将
@State、@EnvironmentObject等特殊View成员与 Compose 状态机制双向同步。 - 转译器把包括
View.body在内的@ViewBuilder转换为@Composable函数调用。
第二项转换有助于理解 SkipUI 内部 API。请看以下简单示例:
struct V: View { let isHello: Bool
var body: some View { if isHello { Text("Hello!") } else { Text("Goodbye!") } }}转译结果大致如下:
class V: View { val isHello: Bool
constructor(isHello: Bool) { this.isHello = isHello }
override fun body(): View { return ComposeBuilder { composectx -> if (isHello) { Text("Hello!").Compose(context = composectx) } else { Text("Goodbye!").Compose(context = composectx) } ComposeResult.ok } }
...}请注意 body 的变化:转译后不再返回任意视图树,而是始终返回单个 ComposeBuilder。原 body 逻辑进入其 @Composable 代码块,原本会返回的各 View 则调用自身 Compose(context:) 渲染对应 Compose 组件。
因此,转译器可将任意 View.body(实际为任意 @ViewBuilder)转换为 ComposeBuilder,即用于渲染目标内容的 Compose 代码块。前文说明了如何使用 ComposeView 在 SwiftUI 与 Compose 间切换。
SkipUI 包含整个 SwiftUI 框架的存根,API 通常经历三个阶段:
- 尚未开始移植的代码位于
#if !SKIP块中,对转译器隐藏。 - 第一步是将代码移出
#if !SKIP以参与转译。即使暂时只标记为@available(*, unavailable),也能向用户提供清晰错误,而不是依赖 Kotlin 编译器报告 API 不存在。- 移出代码时请删除 Apple 的大量 API 注释,避免重复官方文档并掩盖 Skip 特有注释。
- SwiftUI 大量使用复杂泛型,而 Swift 与 Kotlin 的泛型系统差异显著。可能需要用较宽松类型替换部分泛型或约束;用户代码仍由 Swift 编译器执行类型检查。
- 减少 Swift 扩展并将 API 合并到类型主声明,可提高 Skip 内部符号存储效率;但组件专属的
View修饰器仍应留在组件源文件中。
- 最后添加 Compose 实现,并移除
unavailable属性。
整个过程中 SkipUI 都应保持可构建;在 Xcode 中成功编译有助于验证移植组件仍与框架其余部分兼容。
实现组件前,请先熟悉 Sources/View/View.swift 中的 View 协议和 Sources/Compose 目录,并参考已移植组件与修饰器。参见支持的 SwiftUI。
以下简化的 Text 展示了典型 SwiftUI 组件实现:
public struct Text: View, Renderable Equatable, Sendable { let text: String
public init(_ text: String) { self.text = text }
...
#if SKIP @Composable override func Render(context: ComposeContext) { let modifier = context.modifier let font = EnvironmentValues.shared.font ?? Font(fontImpl: { LocalTextStyle.current }) ... androidx.compose.material3.Text(text: text, modifier: modifier, style: font.fontImpl(), ...) } #else public var body: some View { stubView() } #endif}如你所见,Text 的定义与 SwiftUI 一致。随后在 #if SKIP 中为 Android 实现 Renderable.Render,并为 Swift 编译器提供 body 存根。Render 调用必要的 Compose API,应用上下文修饰器和环境值;若有子视图,则调用其 Compose(context:)。
修饰器则通过 ModifiedContent 执行操作,包括修改传给视图的 androidx.compose.ui.Modifier。下面是 .opacity:
extension View { public func opacity(_ opacity: Double) -> some View { #if SKIP return ModifiedContent(content: self, modifier: RenderModifier { context in return context.modifier.alpha(Float(opacity)) }) #else return self #endif }}有些修饰器拥有自己的渲染逻辑,使用不同的 RenderModifier 构造器定义组合。例如 .frame 会在具有适当尺寸和对齐方式的 Compose Box 中组合视图:
extension View { public func frame(width: CGFloat? = nil, height: CGFloat? = nil, alignment: Alignment = .center) -> some View { #if SKIP return ModifiedContent(content: self, modifier: RenderModifier { renderable, context in var modifier = context.modifier if let width { modifier = modifier.width(width.dp) } if let height { modifier = modifier.height(height.dp) } let contentContext = context.content() ComposeContainer(modifier: modifier, fixedWidth: width != nil, fixedHeight: height != nil) { modifier in Box(modifier: modifier, contentAlignment: alignment.asComposeAlignment()) { renderable.Render(context: contentContext) } } }) #else return self #endif }}另一些修饰器不影响渲染,而是执行副作用或修改环境,此时向 ModifiedContent 传入 SideEffectModifier 或 EnvironmentModifier。
与其他 SwiftUI 组件一样,修饰器使用 #if SKIP ... #else ... 提供 Swift 存根,使 SkipUI 可在 Xcode 中构建。
skiptools/skip-ui ↗ 最近发布的 10 个版本:
- 版本 1.59.3 ↗ — 2026-09-03
- 版本 1.59.2 ↗ — 2026-08-19
- 版本 1.59.1 ↗ — 2026-07-23
- 版本 1.59.0 ↗ — 2026-07-18
- 版本 1.58.0 ↗ — 2026-07-02
- 版本 1.57.0 ↗ — 2026-06-22
- 版本 1.56.0 ↗ — 2026-06-13
- 版本 1.55.0 ↗ — 2026-06-01
- 版本 1.54.0 ↗ — 2026-05-14
- 版本 1.53.2 ↗ — 2026-05-13
完整历史记录: github.com/skiptools/skip-ui/releases ↗ — .atom 订阅源 ↗