跳转到内容
Skip 中文站
3.2k

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 提供 skip.ui Kotlin 包。它基于 Jetpack Compose,在 Android 上为 Kotlin 重新实现 SwiftUI,目标是尽可能完整地映射 SwiftUI。

SkipUI 架构图 {: .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.*。

  • SkipUI 添加 Android 端的 Coil ↗ 依赖,用于实现 AsyncImage。
  • SkipUI 包含 ComposeReorderable ↗ 的源码,用于实现 List 的拖动排序。

SkipUI 已可靠支持 SwiftUI 的基础能力,包括状态流和声明式语法,也实现了相当大比例的组件与修饰器。利用现有组件集,已可以完全使用 SwiftUI 编写 Android 应用。

不过,SwiftUI 庞大的 API 中仍有部分尚未实现。完整列表参见支持的 SwiftUI。

需要尚未实现的 SwiftUI 结构时,可以寻找只使用受支持组件的替代方案、直接嵌入 Compose 代码,或为 SkipUI 添加支持。若增强 SkipUI 本身,请考虑将代码贡献回来。

ComposeView 是仅限 Android 的 SwiftUI 视图,可把 Compose 直接嵌入 SwiftUI 视图树;它在 Skip Fuse 编译代码和 Skip Lite 转译代码中的用法不同。

下面的 SkipFuseUI 示例先用 SwiftUI Text 显示“Hello from SwiftUI”,再调用 Compose 的 androidx.compose.material3.Text() 在下方显示“Hello from Compose”。在 Skip Fuse 中集成 Compose 分两步:

  1. 在转译的 #if SKIP 代码块中定义 ContentComposer。
  2. 使用 ComposeView 在 SwiftUI 视图树中渲染 ContentComposer。
import SwiftUI
...
VStack {
Text("Hello from SwiftUI")
#if os(Android)
ComposeView { MessageComposer(message: "Hello from Compose") }
#endif
}
#if SKIP
struct MessageComposer : ContentComposer {
let message: String
@Composable func Compose(context: ComposeContext) {
androidx.compose.material3.Text(message)
}
}
#endif

ContentComposer 协议只包含一个函数:

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 SKIP
struct MessageComposer : ContentComposer {
let message: Text
let textColor: Color
@Composable override func Compose(context: ComposeContext) {
androidx.compose.material3.Text(message.localizedTextString(), color: textColor.asComposeColor())
}
}
#endif

桥接自定义数据类型请参阅桥接文档。

下面的转译示例先用 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 函数。

SkipUI 为所有 SwiftUI 视图增加 Compose(context:) 方法,使其可在 Compose 中使用。下面仍用 SwiftUI Text 显示“Hello from SwiftUI”,但这次是在 ComposeView 内。

Skip Fuse:

#if os(Android)
ComposeView { ColumnComposer() }
#endif
...
#if SKIP
struct 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")
}
}
}
#endif

Skip Lite:

#if SKIP
ComposeView { 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 代码间灵活切换;它们同样适用于自定义视图和函数。

将 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 状态恢复。

除上述 ComposeView 外,SkipUI 还提供 SwiftUI 的 composeModifier 修饰器,可向底层 Compose 视图应用任意 Compose 修饰器。

在 Skip Fuse 中使用 composeModifier 与使用 ComposeView 很相似:

  1. 在转译的 #if SKIP 代码块中定义 ContentModifier。
  2. 使用 .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 SKIP
import androidx.compose.foundation.layout.imePadding
struct IMEPaddingModifier : ContentModifier {
func modify(view: any View) -> any View {
view.composeModifier { $0.imePadding() }
}
}
#endif

ContentModfiier 协议只包含一个函数:

public protocol ContentModifier {
func modify(view: any View) -> any View
}

若使用 Skip Lite 编写 SwiftUI,无需定义 ContentModfifier,可直接应用 Material 修饰器或 .composeModifier:

#if SKIP
import androidx.compose.foundation.layout.imePadding
#endif
...
TextField("Enter username:", text: $username)
#if SKIP
.composeModifier { $0.imePadding() }
#endif

还可以应用作用域修饰器 ↗。例如,在 LazyHStack 中可使用限定于 LazyItemScope ↗ 的 animateItem()。

#if SKIP
import 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
}
}

SkipUI 底层使用 Android 的 Material 3 颜色与组件。多数样式应使用 SwiftUI 内置配色方案和修饰器,但部分 Android 定制能力没有 SwiftUI 对应项,因此 Skip 增加了仅限 Android 的 Material 调整 API。

默认情况下,Skip 在受支持设备上使用 Material 3 动态颜色,否则回退到标准颜色。可在 Compose 代码中使用以下函数定制:

Material3ColorScheme(scheme: (@Composable (ColorScheme, Boolean) -> ColorScheme)?, content: @Composable () -> Unit)

scheme 参数接收两个参数:Skip 默认的 androidx.compose.material3.ColorScheme,以及是否请求深色模式。闭包返回用于指定内容的颜色方案。

例如,要定制整个应用的表面颜色,可按如下方式编辑 Main.kt:

@Composable
internal 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 SKIP
struct ColorSchemeModifier : ContentModifier {
func modify(view: any View) -> any View {
view.material3ColorScheme { colors, isDark in
colors.copy(surface: isDark ? Color.purple.asComposeColor() : Color.yellow.asComposeColor())
}
}
}
#endif

Skip Lite:

MyView()
#if SKIP
.material3ColorScheme { colors, isDark in
colors.copy(surface: isDark ? Color.purple.asComposeColor() : Color.yellow.asComposeColor())
}
#endif

Skip 内置组件使用以下 Material 3 颜色,可按需定制:

  • surface
  • primary
  • onBackground
  • outline
  • outlineVariant

除 .material3ColorScheme 外,Skip 还为底层 Material 3 组件提供许多 .material3 修饰器,它们采用共同的 API 模式:

  • 修饰器接收闭包;闭包获得按 Skip 默认值配置的 Material3<Component>Options,并返回所需修改后的结构。
  • 每个 Material3<Component>Options 都实现 Kotlin copy 方法,可在一次调用中完成复制和修改。
  • 修饰器将闭包放入 SwiftUI Environment,因此在根视图应用即可影响所有子视图。.material3 系列始终应放在要影响的视图本身或其外层。
  • 这些修饰器作用于 Skip 的 SwiftUI 封装层之下,因此使用 Compose 术语和类型;其选项结构属性通常与对应 Material 3 组件函数参数完全一致。

以下示例修改 Android 标签栏的选中指示器颜色;该标签栏由 Material 3 NavigationBar 实现:

Skip Fuse:

TabView {
...
}
#if os(Android)
.composeModifier { NavigationBarModifier() }
#endif
...
#if SKIP
struct 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)
}
}
}
#endif

Skip Lite:

TabView {
...
}
#if SKIP
.material3NavigationBar { options in
let updatedColors = options.itemColors.copy(selectedIndicatorColor: Color.green.asComposeColor())
return options.copy(itemColors: updatedColors)
}
#endif

SkipUI 目前包含以下 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 {
...
}

点击组件时,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
}

下表汇总 SkipUI 在 Android 上对 SwiftUI 的支持。未列出的内容很可能尚不支持。在仅用于 iOS 的 #if !os(Android) 代码块中可使用任意 SwiftUI API。

支持级别:

  • ✅ – 完整支持
  • 🟢 – 高度支持
  • 🟡 – 中等支持
  • 🟠 – 少量支持
支持度API
🟢
@AppStorage (example)
  • 不支持可选值
✅ @Bindable
✅ @Binding (example)
✅
@Environment (example)
✅ @EnvironmentObject (example)
🟢
@FocusState
  • FocusState.Binding is not supported, though you can manually create a Binding(get: { myFocusState.wrappedValue }, set: { myFocusState.wrappedValue = $0 })
🟡
@GestureState
  • 仅在 SkipFuseUI 编译型 Swift 中支持
✅ @ObservedObject (example)
✅ @State (example)
✅ @StateObject (example)
✅ AsyncImage (example)
✅ Button (example)
✅ Capsule (example)
✅ Circle (example)
🟢
Color (example)
  • init(red: Double, green: Double, blue: Double, opacity: Double = 1.0)
  • init(white: Double, opacity: Double = 1.0)
  • init(hue: Double, saturation: Double, brightness: Double, opacity: Double = 1.0)
  • init(_ color: UIColor)
  • init(uiColor: UIColor)
  • init(_ name: String, bundle: Bundle? = nil)
  • static let accentColor: Color
  • static let primary: Color
  • static let secondary: Color
  • static let clear: Color
  • static let white: Color
  • static let black: Color
  • static let gray: Color
  • static let red: Color
  • static let orange: Color
  • static let yellow: Color
  • static let green: Color
  • static let mint: Color
  • static let teal: Color
  • static let cyan: Color
  • static let blue: Color
  • static let indigo: Color
  • static let purple: Color
  • static let pink: Color
  • static let brown: Color
  • func opacity(_ opacity: Double) -> Color
  • var gradient: AnyGradient
  • 参见颜色
🟡
DatePicker (example)
  • init(selection: Binding<Date>, displayedComponents: DatePickerComponents = [.hourAndMinute, .date], @ViewBuilder label: () -> any View)
  • init(selection: Binding<Date>, in: ClosedRange<Date>, displayedComponents: DatePickerComponents = [.hourAndMinute, .date], @ViewBuilder label: () -> any View)
  • init(_ title: String, selection: Binding<Date>, displayedComponents: DatePickerComponents = [.hourAndMinute, .date])
🟡
DisclosureGroup (example)
  • init(isExpanded: Binding<Bool>, @ViewBuilder content: @escaping () -> any View, @ViewBuilder label: () -> any View)
  • init(_ titleKey: LocalizedStringKey, isExpanded: Binding<Bool>, @ViewBuilder content: @escaping () -> any View)
  • init(_ titleResource: LocalizedStringResource, isExpanded: Binding<Bool>, @ViewBuilder content: @escaping () -> any View)
  • init(_ label: String, isExpanded: Binding<Bool>, @ViewBuilder content: @escaping () -> any View)
  • 作为 List 或 Form 项目时不会产生动画
  • 不作为 List 或 Form 项目时始终产生动画
✅ Divider (example)
🟢
DragGesture (example)
🟡
EllipticalGradient (example)
  • 除非渐变本身作为 View 使用,否则会填充为圆形而非椭圆形渐变
✅ EmptyModifier
✅ EmptyView
🟡
Font
  • static let largeTitle: Font
  • static let title: Font
  • static let title2: Font
  • static let title3: Font
  • static let headline: Font
  • static let subheadline: Font
  • static let body: Font
  • static let callout: Font
  • static let footnote: Font
  • static let caption: Font
  • static let caption2: Font
  • static func system(_ style: Font.TextStyle, design: Font.Design? = nil, weight: Font.Weight? = nil) -> Font
  • static func system(size: CGFloat, weight: Font.Weight? = nil, design: Font.Design? = nil) -> Font
  • static func custom(_ name: String, size: CGFloat) -> Font
  • static func custom(_ name: String, size: CGFloat, relativeTo textStyle: Font.TextStyle) -> Font
  • static func custom(_ name: String, fixedSize: CGFloat) -> Font
  • func italic(_ isActive: Bool = true) -> Font
  • func weight(_ weight: Font.Weight) -> Font
  • func bold(_ isActive: Bool = true) -> Font
  • func monospaced(_ isActive: Bool = true) -> Font
  • func pointSize(_ size: CGFloat) -> Font
  • func scaledBy(_ factor: CGFloat) -> Font
🟢
ForEach
✅ Form (example)
🟡
GeometryProxy
  • var size: CGSize
  • func frame(in coordinateSpace: some CoordinateSpaceProtocol) -> CGRect
  • var safeAreaInsets: EdgeInsets
  • 仅支持 .local 和 .global 坐标空间
🟡
GeometryReader
  • 参见 GeometryProxy
✅ Group
✅ HStack (example)
🟢
Image (example)
  • init(_ name: String, bundle: Bundle? = Bundle.main)
  • init(_ name: String, bundle: Bundle? = Bundle.main, label: Text)
  • init(systemName: String)
  • init(uiImage: UIImage)
  • 参见图像
🟢
Label (example)
  • init(@ViewBuilder title: () -> any View, @ViewBuilder icon: () -> any View)
  • init(_ title: String, systemImage: String)
  • init(_ title: String, image: String)
  • 参见图像
🟡
LazyHGrid
🟡
LazyHStack
  • 不支持固定的页眉和页脚
  • 放入 ScrollView 时,必须是该视图唯一的子视图
🟡
LazyVGrid
🟡
LazyVStack
  • 不支持固定的页眉和页脚
  • 放入 ScrollView 时,必须是该视图唯一的子视图
✅ LinearGradient (example)
✅ Link (example)
🟢
List (example)
🟢
LongPressGesture (example)
🟢
MagnifyGesture (example)
✅ Menu (example)
🟢
NavigationLink (example)
🟢
NavigationPath
  • init()
  • init(_ elements: any Sequence)
  • var count: Int
  • var isEmpty: Bool
  • mutating func append(_ value: Any)
  • mutating func removeLast(_ k: Int = 1)
  • 不支持 `codable` 属性
🟢
NavigationStack (example)
✅ Oval (example)
✅ Picker (example)
🟢
ProgressView (example)
  • init()
  • init(value: Double?, total: Double = 1.0)
  • init(@ViewBuilder label: () -> any View)
  • init(_ titleKey: LocalizedStringKey)
  • init(_ titleResource: LocalizedStringResource)
  • init(_ title: String)
  • init(value: Double?, total: Double = 1.0, @ViewBuilder label: () -> any View)
  • init(_ titleKey: LocalizedStringKey, value: Double?, total: Double = 1.0)
  • init(_ titleResource: LocalizedStringResource, value: Double?, total: Double = 1.0)
  • init(_ title: String, value: Double?, total: Double = 1.0)
✅ RadialGradient (example)
✅ Rectangle (example)
🟢
RotateGesture (example)
✅ RoundedRectangle (example)
✅ ScrollView (example)
✅
ScrollView
🟡
ScrollViewProxy
🟡
ScrollViewReader
🟢
Section (example)
✅ SecureField (example)
🟠
ShareLink (example)
  • 仅支持共享 String 或 URL 数据
🟡
Slider (example)
  • init(value: Binding<Double>, in bounds: ClosedRange<Double> = 0.0...1.0, step: Double? = nil)
  • init(value: Binding<Double>, in bounds: ClosedRange<Double> = 0.0...1.0, step: Double? = nil, @ViewBuilder label: () -> any View)
  • init(value: Binding<Double>, in bounds: ClosedRange<Double> = 0.0...1.0, step: Double? = nil, onEditingChanged: (Bool) -> Void)
  • init(value: Binding<Double>, in bounds: ClosedRange<Double> = 0.0...1.0, step: Double? = nil, @ViewBuilder label: () -> any View, onEditingChanged: (Bool) -> Void)
🟢
Spacer (example)
  • 在 Compose 中,多个元素需要扩展时会平均分配可用空间
🟡
Stepper
  • init(value: Binding<Int>, step: Int = 1, @ViewBuilder label: () -> any View)
  • init(value: Binding<Int>, in bounds: ClosedRange<Int>, step: Int = 1, @ViewBuilder label: () -> any View) (Fuse bridging)
  • init(value: Binding<Double>, step: Double = 1.0, @ViewBuilder label: () -> any View)
  • init(value: Binding<Double>, in bounds: ClosedRange<Double>, step: Double = 1.0, @ViewBuilder label: () -> any View) (Fuse bridging)
  • init(_ title: String, value: Binding<Int>, step: Int = 1)
  • init(_ title: String, value: Binding<Int>, in bounds: ClosedRange<Int>, step: Int = 1) (Fuse bridging)
  • init(_ title: String, value: Binding<Double>, step: Double = 1.0)
  • init(_ title: String, value: Binding<Double>, in bounds: ClosedRange<Double>, step: Double = 1.0) (Fuse bridging)
  • init(_ title: String, onIncrement: (() -> Void)?, onDecrement: (() -> Void)?)
  • init(@ViewBuilder label: () -> any View, onIncrement: (() -> Void)?, onDecrement: (() -> Void)?)
🟢
Tab (example)
🟠
Table
  • init(_ data: any RandomAccessCollection<ObjectType>, @ViewBuilder content: () -> some View)
  • init(_ data: any RandomAccessCollection<ObjectType>, selection: Binding<ObjectType?>, @ViewBuilder columns: () -> some View)
  • init(_ data: any RandomAccessCollection<ObjectType>, selection: Binding<Set<ObjectType>>, @ViewBuilder columns: () -> some View)
  • 所有 TableColumns 必须直接嵌套在父级 Table 内容块中
  • 不支持多选
🟠
TableColumn
  • init(_ title: String, value: (ObjectType) -> String, comparator: Comparator? = nil)
  • init(_ title: String, value: Value? = nil, comparator: Comparator? = nil, @ViewBuilder content: @escaping (ObjectType) -> some View)
  • init(_ title: Text, value: (ObjectType) -> String, comparator: Comparator? = nil)
  • init(_ title: Text, value: Value? = nil, comparator: Comparator? = nil, @ViewBuilder content: @escaping (ObjectType) -> some View)
  • func width(_ value: CGFloat? = nil)
  • func width(min: CGFloat? = nil, ideal: CGFloat? = nil, max: CGFloat? = nil)
🟠
TabSection
🟢
TabView (example)
🟢
TapGesture (example)
🟢
Text (example)
  • Text(...) + Text(...) is not supported
  • 格式化器仅支持 Text.DateStyle.date 和 Text.DateStyle.time
🟢
TextEditor
  • .font, .lineSpacing, etc modifiers have no effect
🟢
TextField (example)
  • init(_ title: String, text: Binding<String>, selection: Binding<TextSelection?>? = nil, prompt: Text? = nil)
🟡
Toggle (example)
  • init(isOn: Binding<Bool>, @ViewBuilder label: () -> any View)
🟢
ToolbarContent
  • 同一自定义 ToolbarContent 视图中的所有项目必须使用相同的 ToolbarItemPlacement
✅ ToolbarItem (example)
✅ ToolbarItemGroup (example)
✅ ToolbarSpacer
✅ ToolbarTitleMenu
🟠
UIKit
✅ UnevenRoundedRectangle (example)
✅ Custom Views
✅ Custom ViewModifiers
✅ ViewThatFits
✅
VStack (example)
🟡
withAnimation
✅ ZStack (example)
🟢
.accessibilityAddTraits
  • 仅使用可映射到 Compose 无障碍角色的特征
✅ .accessibilityHeading
✅ .accessibilityHidden
✅ .accessibilityIdentifier
✅ .accessibilityLabel
✅ .accessibilityValue
✅ .alert
✅ .allowsHitTesting
🟡
.animation
🟡
.aspectRatio
  • contentMode is only supported for images
✅ .autocorrectionDisabled
🟢
.background (example)
✅ .backgroundStyle
✅ .badge
🟢
.blendMode
  • .plusDarker and .plusLighter both map to Compose Plus
🟢
.blur
  • opaque parameter is ignored
✅ .bold
✅ .border (example)
✅ .brightness
🟢
.buttonStyle
  • .automatic
  • .plain
  • .borderless
  • .bordered
  • .borderedProminent
  • 不支持自定义样式
🟡
.clipped
  • Compose 中的大多数内容会自动裁剪
✅ .clipShape
✅
.colorScheme
✅ .colorMultiply
✅ .confirmationDialog (example)
🟠 .contextMenu
✅ .contrast
✅ .cornerRadius
🟠
.datePickerStyle
  • .automatic
  • .compact
  • 不支持自定义样式
✅ .deleteDisabled
✅ .disabled
🟢
.drawingGroup
  • opaque and colorMode parameters are ignored
✅ .environment
✅ .environmentObject
✅ .fill
✅ .focused
✅ .flipsForRightToLeftLayoutDirection
✅ .font (example)
✅ .foregroundColor
✅ .foregroundStyle
🟢
.frame (example)
🟢
.fullScreenCover
  • func fullScreenCover(isPresented: Binding<Bool>, onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping () -> any View) -> some View
  • 参见模态视图
🟢
.gesture (example)
🟢
.simultaneousGesture (example)
✅ .gradient (example)
✅ .grayscale
✅ .hidden
✅ .hueRotation
🟢
.ignoresSafeArea
✅ .inset
✅
.interactiveDismissDisabled
✅ .italic
✅ .keyboardType (example)
✅ .labelsHidden
✅
.labelStyle
  • .automatic
  • .titleOnly
  • .iconOnly
  • .titleAndIcon
  • 不支持自定义样式
🟢
.lineLimit
  • func lineLimit(_ number: Int?) -> some View
  • func lineLimit(_ number: Int, reservesSpace: Bool) -> some View
✅ .linespacing
✅ .listItemTint
✅ .listRowBackground
✅ .listRowSeparator
✅ .listStyle
✅ .luminanceToAlpha
✅ .mask
✅ .minimumScaleFactor
✅ .modifier (example)
✅ .monospaced
✅ .moveDisabled
✅ .multilineTextAlignment
✅ .navigationBarBackButtonHidden
✅ .navigationBarTitleDisplayMode
✅ .navigationDestination
🟢
.navigationTitle
  • func navigationTitle(_ title: String) -> some View
  • func navigationTitle(_ title: Text) -> some View
✅ .offset (example)
✅ .onAppear
✅ .onChange
✅ .onDelete
✅ .onDisappear
🟢
.onLongPressGesture (example)
✅ .onMove
✅ .onOpenURL
✅ .onPreferenceChange
✅ .onReceive
✅ .onSubmit
🟢
.onTapGesture
✅ .opacity
✅ .overlay (example)
✅ .padding
🟡
.pickerStyle
  • .automatic
  • .navigationLink
  • .menu
  • .segmented
  • 不支持自定义样式
✅ .position
✅ .preference
✅
.preferredColorScheme
🟢
.progressViewStyle
  • .automatic
  • .linear
  • .circular
  • 不支持自定义样式
🟡
.redacted
  • 仅支持 RedactionReasons.placeholder
✅ .refreshable
🟠
.resizable
  • func resizable() -> Image
🟢
.rotation
  • func rotation(_ angle: Angle) -> any Shape
✅
.rotationEffect
  • func rotationEffect(_ angle: Angle) -> some View
  • func rotationEffect(_ angle: Angle, anchor: UnitPoint) -> some View
🟢
.rotation3DEffect
  • func rotation3DEffect(_ angle: Angle, axis: (x: CGFloat, y: CGFloat, z: CGFloat), perspective: CGFloat = 1.0) -> some View
✅ .saturation
🟢
.scale
  • func scale(_ scale: CGFloat) -> any Shape
  • func scale(x: CGFloat = 1.0, y: CGFloat = 1.0) -> any Shape
🟡
.scaledToFill
  • 仅支持图像
🟡
.scaledToFit
  • 仅支持图像
✅
.scaleEffect
  • func scaleEffect(_ scale: CGSize, anchor: UnitPoint = .center) -> some View
  • func scaleEffect(_ s: CGFloat, anchor: UnitPoint = .center) -> some View
  • func scaleEffect(x: CGFloat = 1.0, y: CGFloat = 1.0, anchor: UnitPoint = .center) -> some View
✅ .scrollContentBackground
🟢
.scrollDismissesKeyboard
  • 在 Compose 中,默认行为(.automatic)是滚动时永不收起
  • .interactively behaves like .immediately
🟠
.scrollTargetBehavior
  • 仅支持 .viewAligned
  • 参见滚动
🟠
.scrollTargetLayout
🟠
.scrollPosition
  • 仅支持 scrollPosition(id: Binding<(some Hashable)?>)
  • 仅适用于惰性容器:LazyHStack、LazyVStack、LazyHGrid、LazyVGrid 和 List
  • 不支持 anchor 参数
  • scrollPosition(_ position: Binding<ScrollPosition>, anchor: UnitPoint? = nil) is not supported
  • 参见滚动
🟡
.searchable (example)
  • func searchable(text: Binding<String>, prompt: Text? = nil) -> some View
  • func searchable(text: Binding<String>, prompt: String) -> some View
🟢
.shadow (example)
  • 请将此修饰器放在 .background、.overlay 之前
🟢
.sheet (example)
  • func sheet(isPresented: Binding<Bool>, onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping () -> any View) -> some View
  • 参见模态视图
🟢
.strikethrough
  • pattern and color are ignored
✅ .stroke
✅ .strokeBorder
✅ .submitLabel
✅ .symbolVariant
✅
.tabItem
✅
.tabViewStyle
  • .automatic
  • .page
  • .tabBarOnly
  • 不支持自定义样式
✅ .tag
✅ .task
✅ .textCase
🟢 .textContentType
✅ .textEditorStyle
🟡
.textFieldStyle
  • .automatic
  • .roundedBorder
✅ .textInputAutocapitalization
✅ .tint
🟢
.toolbar
  • func toolbar(@ViewBuilder content: () -> any View) -> some View
  • func toolbar(_ visibility: Visibility, for bars: ToolbarPlacement...) -> some View
✅ .toolbarBackground
✅ .toolbarColorScheme
✅ .toolbarTitleDisplayMode
✅ .toolbarTitleMenu
✅ .tracking
🟢
.transition
🟢
.underline
  • pattern and color are ignored
✅ .truncationMode
✅ .zIndex (example)

SkipUI 不支持 UIKit 视图本身,但支持 UIKit 的一个子集,例如作为 Android 底层服务接口的剪贴板和触觉反馈类。

下表汇总 SkipUI 在 Android 上对 UIKit 的支持。未列出的内容很可能尚不支持;在仅用于 iOS 的 #if !SKIP 代码块中可使用任意 UIKit API。

支持级别:

  • ✅ – 完整支持
  • 🟢 – 高度支持
  • 🟡 – 中等支持
  • 🟠 – 少量支持
支持度API
🟠
UIApplication
  • static var shared: UIApplication
  • var applicationState: UIApplication.State
  • var isIdleTimerDisabled: Bool
  • func open(_ url: URL, options: [UIApplication.OpenExternalURLOptionsKey : Any] = [:]) async -> Bool
✅ #colorLiteral()
🟠
UIColor
  • init(red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat)
🟠
UIImage
  • init?(contentsOfFile: String)
  • init?(data: Data)
  • init?(data: Data, scale: CGFloat)
✅ UIImpactFeedbackGenerator
✅ UINotificationFeedbackGenerator
🟠
UIPasteboard
  • static var general: UIPasteboard
  • static var changedNotification: Notification.Name
  • var numberOfItems: Int
  • var hasStrings: Bool
  • var string: String?
  • var strings: [String]?
  • var hasURLs: Bool
  • var url: URL?
  • var urls: [URL]?
✅ UISelectionFeedbackGenerator

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
🟡
UNAuthorizationOptions
  • 在 Android 上会被忽略
🟡
UNMutableNotificationContent
  • 参见 `UNNotificationContent`
✅ UNNotification
🟡
UNNotificationContent
  • 仅使用 `title`、`body`、`userInfo` 和 `public.image` 类型的附件
🟡
UNNotificationPresentationOptions
  • 仅使用 `.banner` 和 `.alert`
✅ UNNotificationRequest
✅ UNNotificationResponse
🟡
UNNotificationSound
  • 在 Android 上会被忽略
🟡
UNNotificationTrigger
  • 只计算 `nextTriggerDate`,不处理 `repeat` 值
🟡
UNUserNotificationCenter
  • static func current() -> UNUserNotificationCenter
  • func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool
  • var delegate: (any UNUserNotificationCenterDelegate)?
  • func add(_ request: UNNotificationRequest) async throws
  • `add` 函数忽略所有重复选项,只会立即发送通知,或在到达下一触发日期后发送。
  • func pendingNotificationRequests() async -> [UNNotificationRequest]
  • func removePendingNotificationRequests(withIdentifiers identifiers: [String])
  • func removeAllPendingNotificationRequests()
  • func deliveredNotifications() async -> [UNNotification]
  • func removeDeliveredNotifications(withIdentifiers identifiers: [String])
  • func removeAllDeliveredNotifications()
✅ 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”常量定义。

SkipUI 完整支持 .preferredColorScheme。若应用由 v0.8.26 之前的 skip 工具创建,需要参照最新 Main.kt ↗ 更新 Android/app/src/main/kotlin/.../Main.kt:

  1. 用最新 Main.kt 中的导入语句替换全部导入
  2. 用最新 Main.kt 的内容替换 setContent { ... } 代码块
  3. 用最新 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(只读)
  • backgroundStyle
  • colorSchemeContrast(只读)
  • dismiss
  • font
  • horizontalSizeClass
  • isEnabled
  • isSearching(只读)
  • layoutDirection
  • legibilityWeight(只读)
  • lineLimit
  • locale
  • openURL
  • refresh
  • scenePhase
  • scrollDismissesKeyboardMode
  • timeZone
  • truncationMode
  • verticalSizeClass

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 in
    Text("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。

  1. 若尚不存在,请在顶层应用模块的 Resources 中创建 Module.xcassets。
  2. 从 Google Material Icons ↗ 下载所需符号,并选择 iOS SVG 格式 ↗;也可从 SF Symbols ↗ 导出。
  3. 将文件命名为要在 Android 上表示的同名 iOS 符号,并保留 .svg 扩展名。
  4. 将文件拖入 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 完全一致,但能近似表达形状与含义。

iOSAndroid
arrow.clockwise.circleIcons.Outlined.Refresh
arrow.forwardIcons.Outlined.ArrowForward
arrow.forward.squareIcons.Outlined.ExitToApp
arrow.leftIcons.Outlined.ArrowBack
arrowtriangle.down.fillIcons.Outlined.ArrowDropDown
bellIcons.Outlined.Notifications
bell.fillIcons.Filled.Notifications
calendarIcons.Outlined.DateRange
cartIcons.Outlined.ShoppingCart
cart.fillIcons.Filled.ShoppingCart
checkmarkIcons.Outlined.Check
checkmark.circleIcons.Outlined.CheckCircle
checkmark.circle.fillIcons.Filled.CheckCircle
chevron.downIcons.Outlined.KeyboardArrowDown
chevron.leftIcons.Outlined.KeyboardArrowLeft
chevron.rightIcons.Outlined.KeyboardArrowRight
chevron.upIcons.Outlined.KeyboardArrowUp
ellipsisIcons.Outlined.MoreVert
envelopeIcons.Outlined.Email
envelope.fillIcons.Filled.Email
exclamationmark.triangleIcons.Outlined.Warning
exclamationmark.triangle.fillIcons.Filled.Warning
face.smilingIcons.Outlined.Face
gearshapeIcons.Outlined.Settings
gearshape.fillIcons.Filled.Settings
hand.thumbsupIcons.Outlined.ThumbUp
hand.thumbsup.fillIcons.Filled.ThumbUp
heartIcons.Outlined.FavoriteBorder
heart.fillIcons.Outlined.Favorite
houseIcons.Outlined.Home
house.fillIcons.Filled.Home
info.circleIcons.Outlined.Info
info.circle.fillIcons.Filled.Info
line.3.horizontalIcons.Outlined.Menu
list.bulletIcons.Outlined.List
locationIcons.Outlined.LocationOn
location.fillIcons.Filled.LocationOn
lockIcons.Outlined.Lock
lock.fillIcons.Filled.Lock
magnifyingglassIcons.Outlined.Search
mappin.circleIcons.Outlined.Place
mappin.circle.fillIcons.Filled.Place
paperplaneIcons.Outlined.Send
paperplane.fillIcons.Filled.Send
pencilIcons.Outlined.Create
personIcons.Outlined.Person
person.crop.circleIcons.Outlined.AccountCircle
person.crop.circle.fillIcons.Filled.AccountCircle
person.crop.squareIcons.Outlined.AccountBox
person.crop.square.fillIcons.Filled.AccountBox
person.fillIcons.Filled.Person
phoneIcons.Outlined.Call
phone.fillIcons.Filled.Call
playIcons.Outlined.PlayArrow
play.fillIcons.Filled.PlayArrow
plusIcons.Outlined.Add
plus.circle.fillIcons.Outlined.AddCircle
square.and.arrow.upIcons.Outlined.Share
square.and.arrow.up.fillIcons.Filled.Share
star.fillIcons.Filled.Star
trashIcons.Outlined.Delete
trash.fillIcons.Filled.Delete
wrenchIcons.Outlined.Build
wrench.fillIcons.Filled.Build
xmarkIcons.Outlined.Clear

在仅 Android 的代码中,也可把任意 androidx.compose.material.icons.Icons 名称作为 systemName。例如:

#if SKIP
Image(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,可以自行决定处理方式。

可以采用以下方案:

  1. 方案一(乐观):先渲染图像并期待命中缓存,同时延后显示占位内容。

    AsyncImage(url: url) { phase in
    switch phase {
    case .empty:
    if let image = phase.image {
    image.resizable()
    } else {
    ProgressView()
    }
    case .failure:
    Color.red
    case .success(let image):
    image.resizable()
    }
    }

    注意:这可能导致布局偏移。 若图像尚未就绪,它会以 0×0 尺寸渲染。可考虑采用下方方案二的透明 Color.clear 占位。

  2. 方案二(ZStack):阶段为 empty 时,在 ZStack 中把占位内容渲染在图像下方。若图像成功渲染且不透明,会完全遮住占位内容。

    AsyncImage(url: url) { phase in
    switch phase {
    case .empty:
    if let image = phase.image {
    ZStack {
    ProgressView() // or Color.clear
    if let image = phase.image {
    image.resizable()
    }
    }
    } else {
    ProgressView()
    }
    case .failure:
    Color.red
    case .success(let image):
    image.resizable()
    }
    }

    使用方案二时,透明图像首帧可能透出下层占位内容。此时可在 empty 且存在图像时使用 Color.clear 等透明占位。

  3. 方案三(保守):先渲染占位内容,直到确定图像就绪后再显示图像。

    AsyncImage(url: url) { phase in
    switch phase {
    case .empty:
    ProgressView()
    case .failure:
    Color.red
    case .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) 为整个子树关闭。

是否需要它,取决于你的首要目标:

  1. 是否最看重尽快渲染图像?

    若是,请勿使用 .subcomposeAsyncImage();SkipUI 默认行为已为此优化。

  2. 是否更重视尽快显示占位内容,即使会延迟图像?

    此时使用 AsyncImage(url:scale:transaction:content:) ↗,在 case empty: 中保守地渲染占位内容(见上方方案三)。

  3. 是否更重视首帧正确,即使主 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)
#endif

SwiftUI 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 View

SkipUI 支持上述所有模型。使用 .navigationDestination(isPresented:destination:) 时,手动将 isPresented 设为 false 不会关闭视图,请使用标准关闭机制。按类型绑定目标时也需谨慎:推入视图若为键类型 T 定义新目标,会覆盖栈中此前的 T 映射。不要在同一导航栈中无意重复映射同一键类型。

Compose 还有一项限制:.navigationDestination 的数据键类型必须能转换为字符串。参见下方标识符限制。

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
}
}

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 SKIP
struct 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())
)
}
}
}
}
#endif

Skip 支持标准模态呈现。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。

新版 SkipUI 默认启用 Jetpack Compose 的边到边模式。若应用由 v0.8.32 之前的 skip 创建,需要参照最新 Main.kt ↗ 更新项目文件,才能在系统栏后渲染:

  1. 添加导入:import androidx.activity.enableEdgeToEdge
  2. 向 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)

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:

  1. 找到尚未实现的 API。它们位于 #if !SKIP 代码块中,或标记为 @available(*, unavailable);大多数未实现的 View 修饰器位于 View.swift。
  2. 编写合适的 Compose 实现,参见下方实现策略。
  3. 向 SkipFuseUI 添加编译型 Swift 包装器。
  4. 编写 Showcase 代码演示并测试组件,参见测试。
  5. 提交 PR ↗。

也欢迎测试用例、注释和文档等其他形式的贡献!

测试 SkipUI 对 SwiftUI 组件支持的常见方式是使用 Skip Showcase 和 Skip Showcase Fuse 应用。每次新增或更新可见元素支持时,都应添加相应展示视图,以验证外观和行为,并持续展示 Android 已支持的组件。

SkipUI 无法独立工作。用于 Skip Lite 转译 Swift 时,它依赖 skip ↗ 插件对 SwiftUI 代码的转换。虽然 Skip 通常力求生成接近手写的 Kotlin,但这些 SwiftUI 转换并不适合手工编写。下面先了解其机制。

SwiftUI 与 Compose 都是声明式 UI 框架,都会跟踪状态并在变化时自动重绘。但 SwiftUI 以 View 对象建模,Compose 以 @Composable 函数建模,因此转译器必须把 View 图转换为函数调用,主要包含两项转换:

  1. 转译器插入代码,将 @State、@EnvironmentObject 等特殊 View 成员与 Compose 状态机制双向同步。
  2. 转译器把包括 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 通常经历三个阶段:

  1. 尚未开始移植的代码位于 #if !SKIP 块中,对转译器隐藏。
  2. 第一步是将代码移出 #if !SKIP 以参与转译。即使暂时只标记为 @available(*, unavailable),也能向用户提供清晰错误,而不是依赖 Kotlin 编译器报告 API 不存在。
    • 移出代码时请删除 Apple 的大量 API 注释,避免重复官方文档并掩盖 Skip 特有注释。
    • SwiftUI 大量使用复杂泛型,而 Swift 与 Kotlin 的泛型系统差异显著。可能需要用较宽松类型替换部分泛型或约束;用户代码仍由 Swift 编译器执行类型检查。
    • 减少 Swift 扩展并将 API 合并到类型主声明,可提高 Skip 内部符号存储效率;但组件专属的 View 修饰器仍应留在组件源文件中。
  3. 最后添加 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 个版本:

完整历史记录: github.com/skiptools/skip-ui/releases ↗ — .atom 订阅源 ↗