skiptools / skip-ui

SwiftUI for Android
https://skip.tools
GNU Lesser General Public License v3.0
125 stars 14 forks source link

SkipUI

SwiftUI support for Skip apps.

About SkipUI

SkipUI vends the skip.ui Kotlin package. It is a reimplementation of SwiftUI for Kotlin on Android using Jetpack Compose. Its goal is to mirror as much of SwiftUI as possible, allowing Skip developers to use SwiftUI with confidence.

SkipUI Diagram {: .diagram-vector }

Dependencies

SkipUI depends on the skip transpiler plugin. The transpiler must transpile SkipUI's own source code, and SkipUI relies on the transpiler's transformation of SwiftUI code. See Implementation Strategy for details. SkipUI also depends on the SkipFoundation and SkipModel packages.

SkipUI is part of the core SkipStack and is not intended to be imported directly. The module is transparently adopted through the translation of import SwiftUI into import skip.ui.* by the Skip transpiler.

Android Libraries

Status

SkipUI - together with the Skip transpiler - has robust support for the building blocks of SwiftUI, including its state flow and declarative syntax. SkipUI also implements a large percentage of SwiftUI's components and modifiers. It is possible to write an Android app entirely in SwiftUI utilizing SkipUI's current component set.

SkipUI is a young library, however, and some of SwiftUI's vast surface area is not yet implemented. See Supported SwiftUI for a full list of supported API.

When you want to use a SwiftUI construct that has not been implemented, you have options. You can try to find a workaround using only supported components, embed Compose code directly, or add support to SkipUI. If you choose to enhance SkipUI itself, please consider contributing your code back for inclusion in the official release.

ComposeView

ComposeView is an Android-only SwiftUI view that you can use to embed Compose code directly into your SwiftUI view tree. In the following example, we use a SwiftUI Text to write "Hello from SwiftUI", followed by calling the androidx.compose.material3.Text() Compose function to write "Hello from Compose" below it:

VStack {
    Text("Hello from SwiftUI")
    #if SKIP
    ComposeView { _ in
        androidx.compose.material3.Text("Hello from Compose")
    }
    #endif
}

Skip also enhances all SwiftUI views with a Compose() method, allowing you to use SwiftUI views from within Compose. The following example again uses a SwiftUI Text to write "Hello from SwiftUI", but this time from within a ComposeView:

#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

Or:

#if SKIP
ComposeView { context in 
    VStack {
        Text("Hello from SwiftUI").Compose(context: context.content())
        androidx.compose.material3.Text("Hello from Compose")
    }.Compose(context: context)
}
#endif

With ComposeView and the Compose() function, you can move fluidly between SwiftUI and Compose code. These techniques work not only with standard SwiftUI and Compose components, but with your own custom SwiftUI views and Compose functions as well.

ComposeView and the Compose() function are only available in Android, so you must guard all uses with the #if SKIP or #if os(Android) compiler directives.

Additional Considerations

There are additional considerations when integrating SwiftUI into a Compose application that is not managed by Skip. SwiftUI relies on its own mechanisms to save and restore Activity UI state, such as @AppStorage and navigation path bindings. It is not compatible with Android's Activity UI state restoration. Use a pattern like the following to exclude SwiftUI from Activity state restoration when integrating SwiftUI views:

val stateHolder = rememberSaveableStateHolder()
stateHolder.SaveableStateProvider("myKey") {
    MySwiftUIRootView().Compose()
    SideEffect { stateHolder.removeState("myKey") }
}

This pattern allows SwiftUI to take advantage of Compose's UI state mechanisms internally while excluding it from Activity state restoration.

composeModifier

In addition to ComposeView above, Skip offers the composeModifier SwiftUI modifier. This modifier allows you to apply any Compose modifiers to the underlying Compose view. It takes a block that accepts a single androidx.compose.ui.Modifier parameter and returns a Modifier as well. For example:

#if SKIP
import androidx.compose.foundation.layout.imePadding
#endif

...

TextField("Enter username:", text: $username)
    .textFieldStyle(.plain)
    #if SKIP
    .composeModifier { $0.imePadding() }
    #endif

Like ComposeView, the composeModifier function is only available in Android, so you must guard all uses with the #if SKIP or #if os(Android) compiler directives.

Material

Under the hood, SkipUI uses Android's Material 3 colors and components. While we expect you to use SwiftUI's built-in color schemes (.preferredColorScheme) and modifiers (.background, .foregroundStyle, .tint, and so on) for most UI styling, there are some Android customizations that have no SwiftUI equivalent. Skip therefore adds additional, Android-only API for manipulating Material colors and components.

Material Colors

By default, Skip uses Material 3's dynamic colors on devices that support them, and falls back to Material 3's standard colors otherwise. You can customize these colors in Compose code using the following function:

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

The scheme argument takes a closure with two arguments: Skip's default androidx.compose.material3.ColorScheme, and whether dark mode is being requested. Your closure returns the androidx.compose.material3.ColorScheme to use for the supplied content.

For example, to customize the surface colors for your entire app, you could edit Main.kt as follows:

@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 also provides the SwiftUI .material3ColorScheme(_:) modifier to customize a SwiftUI view hierarchy. The modifier takes the same closure as the Material3ColorScheme Kotlin function. It is only available for Android, so you must use it within a #if SKIP block. For example:

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

Skip's built-in components use the following Material 3 colors, if you'd like to customize them:

Material Components

In addition to the .material3ColorScheme modifier detailed above, Skip includes many other .material3 modifiers for its underlying Material 3 components. This family of modifiers share a common API pattern:

You can find details on Material 3 component API in this Android API documentation. {: class="callout info"}

Here is an example of changing the selected indicator color on your Android tab bar, which is implemented by the Material 3 NavigationBar component:

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

SkipUI currently includes the following Material modifiers:

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 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 struct Material3TopAppBarOptions {
    public var title: @Composable () -> Void
    public var modifier: androidx.compose.ui.Modifier
    public var navigationIcon: @Composable () -> Void
    public var colors: androidx.compose.material3.TopAppBarColors
    public var scrollBehavior: androidx.compose.material3.TopAppBarScrollBehavior?
    public var preferCenterAlignedStyle: Bool
    public var preferLargeStyle: Bool
}

Note that .material3TopAppBar involves API that Compose deems experimental, so you must add the following to any View where you use it:

// SKIP INSERT: @OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
struct MyView: View {
    ...
}

Material Effects

Compose applies an automatic "ripple" effect to components on tap. You can customize the color and alpha of this effect with the material3Ripple modifier. To disable the effect altogether, return nil from your modifier closure.

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
}

Supported SwiftUI

The following table summarizes SkipUI's SwiftUI support on Android. Anything not listed here is likely not supported. Note that in your iOS-only code - i.e. code within #if !SKIP blocks - you can use any SwiftUI you want.

Support levels:

SupportAPI
🟒
@AppStorage (example)
  • Optional values are not supported
βœ… @Bindable
βœ… @Binding (example)
βœ…
@Environment (example)
βœ… @EnvironmentObject (example)
βœ… @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)
  • 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(_ 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(_ label: String, isExpanded: Binding<Bool>, @ViewBuilder content: @escaping () -> any View)
  • Does not animate when used as a List or Form item
  • Always animates when **not** used as a List or Form item
βœ… Divider (example)
🟒
DragGesture (example)
🟑
EllipticalGradient (example)
  • Fills as a circular gradient instead of elliptical unless the gradient is used as its own 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() -> Font
  • func weight(_ weight: Font.Weight) -> Font
  • func bold() -> Font
  • func monospaced() -> Font
🟒
ForEach
βœ… Form (example)
🟑
GeometryProxy
  • var size: CGSize
  • func frame(in coordinateSpace: some CoordinateSpaceProtocol) -> CGRect
  • Only .local and .global coordinate spaces are supported
🟑
GeometryReader
  • See 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)
  • See Images
🟒
Label (example)
  • init(@ViewBuilder title: () -> any View, @ViewBuilder icon: () -> any View)
  • init(_ title: String, systemImage: String)
  • init(_ title: String, image: String)
  • See Images
🟑
LazyHGrid
🟑
LazyHStack
  • Does not support pinned headers and footers
  • When placed in a ScrollView, it must be the only child of that view
🟑
LazyVGrid
🟑
LazyVStack
  • Does not support pinned headers and footers
  • When placed in a ScrollView, it must be the only child of that view
βœ… LinearGradient (example)
βœ… Link (example)
🟒
List (example)
🟒
LongPressGesture (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)
  • Does not support `codable` property
🟒
NavigationStack (example)
βœ… Oval (example)
βœ… Picker (example)
🟑
ProgressView (example)
  • init()
  • init(value: Double?, total: Double = 1.0)
βœ… RadialGradient (example)
βœ… Rectangle (example)
βœ… RoundedRectangle (example)
βœ… ScrollView (example)
🟑
ScrollViewProxy
  • Works only for List and lazy containers: LazyVStack, LazyHStack, LazyVGrid, LazyHGrid
  • UnitRect parameter is ignored
🟑
ScrollViewReader
  • See ScrollViewProxy
🟒
Section (example)
βœ… SecureField (example)
🟠
ShareLink (example)
  • Supports sharing String or URL data only
🟑
Slider (example)
  • init(value: Binding<Double>, in bounds: ClosedRange<Double> = 0.0...1.0, step: Double? = nil)
🟒
Spacer (example)
  • In Compose, when multiple elements want to expand they will share the available space equally
🟠
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)
  • All TableColumns must be directly nested in the parent Table content block
  • Multiple selection is not supported
🟠
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)
🟒
TabView (example)
🟒
TapGesture (example)
🟒
Text (example)
  • Text(...) + Text(...) is not supported
  • For formatters, only Text.DateStyle.date and Text.DateStyle.time are supported
🟒
TextEditor
  • .font, .lineSpacing, etc modifiers have no effect
🟒
TextField (example)
  • init(_ title: String, text: Binding<String>, prompt: Text? = nil)
🟑
Toggle (example)
  • init(isOn: Binding<Bool>, @ViewBuilder label: () -> any View)
βœ… ToolbarItem (example)
βœ… ToolbarItemGroup (example)
🟠
UIKit
βœ… UnevenRoundedRectangle (example)
βœ… Custom Views
βœ… Custom ViewModifiers
βœ…
VStack (example)
🟑
withAnimation
βœ… ZStack (example)
🟒
.accessibilityAddTraits
  • Only traits that map to Compose accessibility roles are used
βœ… .accessibilityHeading
βœ… .accessibilityHidden
βœ… .accessibilityIdentifier
βœ… .accessibilityLabel
βœ… .accessibilityValue
βœ… .alert
🟑
.animation
🟑
.aspectRatio
  • Only supported for images
βœ… .autocorrectionDisabled
🟒
.background (example)
.background (example)
βœ… .backgroundStyle
βœ… .bold
βœ… .border (example)
🟒
.buttonStyle
  • .automatic
  • .plain
  • .borderless
  • .bordered
  • .borderedProminent
  • Custom styles are not supported
🟑
.clipped
  • Most content in Compose clips automatically
βœ… .clipShape
βœ…
.colorScheme
βœ… .confirmationDialog (example)
βœ… .cornerRadius
🟠
.datePickerStyle
  • .automatic
  • .compact
  • Custom styles are not supported
βœ… .deleteDisabled
βœ… .disabled
βœ… .environment
βœ… .environmentObject
βœ… .fill
βœ… .font (example)
βœ… .foregroundColor
βœ… .foregroundStyle
🟒
.frame (example)
🟒
.fullScreenCover
  • func fullScreenCover(isPresented: Binding<Bool>, onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping () -> any View) -> some View
  • Note that covers are dismissible via swipe and the back button on Android.
🟒
.gesture (example)
βœ… .gradient (example)
βœ… .grayscale
βœ… .hidden
🟒
.ignoresSafeArea
βœ… .inset
βœ… .italic
βœ… .keyboardType (example)
βœ… .labelsHidden
🟑
.lineLimit
  • func lineLimit(_ number: Int?) -> some View
βœ… .listItemTint
βœ… .listRowBackground
βœ… .listRowSeparator
βœ… .listStyle
βœ… .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
βœ… .onReceive
βœ… .onSubmit
🟒
.onTapGesture
βœ… .opacity
βœ… .overlay (example)
βœ… .padding
🟑
.pickerStyle
  • .automatic
  • .navigationLink
  • .menu
  • .segmented
  • Custom styles are not supported
βœ… .position
βœ…
.preferredColorScheme
🟒
.progressViewStyle
  • .automatic
  • .linear
  • .circular
  • Custom styles are not supported
🟑
.redacted
  • Only RedactionReasons.placeholder is supported
βœ… .refreshable
🟠
.resizable
  • func resizable() -> Image
🟒
.rotation
  • func rotation(_ angle: Angle) -> any Shape
🟒
.rotationEffect
  • func rotationEffect(_ angle: Angle) -> some View
🟒
.scale
  • func scale(_ scale: CGFloat) -> any Shape
  • func scale(x: CGFloat = 1.0, y: CGFloat = 1.0) -> any Shape
🟑
.scaledToFill
  • Only supported for images
🟑
.scaledToFit
  • Only supported for images
🟑
.scaleEffect
  • func scaleEffect(_ scale: CGSize) -> some View
  • func scaleEffect(_ s: CGFloat) -> some View
  • func scaleEffect(x: CGFloat = 1.0, y: CGFloat = 1.0) -> some View
βœ… .scrollContentBackground
🟑
.searchable (example)
  • func searchable(text: Binding<String>, prompt: Text? = nil) -> some View
  • func searchable(text: Binding<String>, prompt: String) -> some View
🟒
.shadow (example)
  • Place this modifier before .background, .overlay modifiers
🟒
.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
βœ… .tabItem
βœ… .tag
βœ… .task
βœ… .textCase
βœ… .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
🟒
.transition
🟒
.underline
  • pattern and color are ignored
βœ… .zIndex (example)

Supported UIKit

SkipUI does not support UIKit views themselves, but it does support a subset of the UIKit framework, such as the pasteboard and haptic feedback classes, that act as interfaces to the underlying services on Android.

The following table summarizes SkipUI's UIKit support on Android. Anything not listed here is likely not supported. Note that in your iOS-only code - i.e. code within #if !SKIP blocks - you can use any UIKit you want.

Support levels:

SupportAPI
🟠
UIApplication
  • static var shared: UIApplication
  • var applicationState: UIApplication.State
  • var isIdleTimerDisabled: Bool
  • func open(_ url: URL, options: [UIApplication.OpenExternalURLOptionsKey : Any] = [:]) async -> Bool
🟠
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

Supported UserNotifications

Skip integrates its support for the UserNotifications framework into SkipUI.

The following table summarizes SkipUI's UserNotifications support on Android. Anything not listed here is likely not supported. Note that in your iOS-only code - i.e. code within #if !SKIP blocks - you can use any UserNotifications API you want.

Support levels:

SupportAPI
🟑
UNAuthorizationOptions
  • Ignored on Android
🟑
UNMutableNotificationContent
  • See `UNNotificationContent`
βœ… UNNotification
🟑
UNNotificationContent
  • Only `title`, `body`, `userInfo`, and `public.image`-type attachments are used
🟑
UNNotificationPresentationOptions
  • Only `.banner` and `.alert` are used
βœ… UNNotificationRequest
βœ… UNNotificationResponse
🟑
UNNotificationSound
  • Ignored on Android
🟑
UNNotificationTrigger
  • Ignored on Android
🟠
UNUserNotificationCenter
  • static func current() -> UNUserNotificationCenter
  • func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool
  • var delegate: (any UNUserNotificationCenterDelegate)?
  • func add(_ request: UNNotificationRequest) async throws
  • The `add` function ignores all scheduling and repeat options and simply delivers the notification immediately.
βœ… UNUserNotificationCenterDelegate

Topics

Animation

Skip supports SwiftUI's .animation and .transition modifiers as well as its withAnimation function on Android.

The following properties are currently animatable:

All of SwiftUI's built-in transitions are supported on Android. To use transitions or to animate views being added or removed in general, however, you must assign a unique .id value to every view in the parent HStack, VStack, or ZStack:

VStack {
    FirstView()
        .id(100)
    if condition {
        SecondView()
            .transition(.scale)
            .id(200)
    }
}
.animation(.default)

Skip converts the various SwiftUI animation types to their Compose equivalents. For many SwiftUI spring animations, though, Skip uses Compose's simple EaseInOutBack easing function rather than a true spring. Only constructing a spring with SwiftUI.Spring(mass:stiffness:damping:) creates a true Compose spring animation. Using an easing function rather than a true spring allows us to overcome Compose's limitations on springs:

Custom Animatables and Transitions are not supported. Finally, if you nest withAnimation blocks, Android will apply the innermost animation to all block actions.

ColorScheme

SkipUI fully supports the .preferredColorScheme modifier. If you created your app with the skip tool prior to v0.8.26, however, you will have to update the included Android/app/src/main/kotlin/.../Main.kt file in order for the modifier to work correctly. Using the latest Main.kt as your template, please do the following:

  1. Replace the all of the import statements with ones from latest Main.kt
  2. Replace the contents of the setContent { ... } block with the content from the latest Main.kt
  3. Replace the MaterialThemeRootView() function with the PresentationRootView(context:) function from the latest Main.kt

With these updates in place, you should be able to use .preferredColorScheme successfully.

Custom Fonts

Custom fonts can be embedded and referenced using Font.custom. Fonts are loaded differently depending on the platform. On iOS the custom font name is the full Postscript name of the font, and on Android the name is the font's file name without the extension.

Android requires that font file names contain only alphanumeric characters and underscores, so you should manually name your embedded font to the lowercased and underscore-separated form of the Postscript name of the font. SkipUI's Font.custom call will accommodate this by translating your custom font name like "Protest Guerrilla" into an Android-compatible name like "protest_guerrilla.ttf".

Text("Custom Font")
    .font(Font.custom("Protest Guerrilla", size: 30.0)) // protest_guerrilla.ttf

Custom fonts are embedded differently for each platform. On Android you should create a folder Android/app/src/main/res/font/ and add the font file, which will cause Android to automatically embed any fonts in that folder as resources.

For iOS, you must add the font by adding to the Xcode project's app target and ensure the font file is included in the file list in the app target's "Build Phases" tab's "Copy Bundle Resources" phase. In addition, iOS needs to have the font explicitly listed in the Xcode project target's "Info" tab under "Custom Application Target Properties" by adding a new key for the "Fonts provided by application" (whose raw name is "UIAppFonts") and adding each font's file name to the string array.

See the Skip Showcase app TextPlayground for a concrete example of using a custom font, and see that project's Xcode project file (screenshot) to see how the font is included on both the iOS and Android sides of the app.

Environment Keys

SwiftUI has many built-in environment keys. These keys are defined in EnvironmentValues and typically accessed with the @Environment property wrapper. In additional to supporting your custom environment keys, SkipUI exposes the following built-in environment keys:

ForEach

The SwiftUI ForEach view allows you to generate views for a range or collection of content. SkipUI support any Int range or any RandomAccessCollection. If the collection elements do not implement the Identifiable protocol, specify the key path to a property that can be used to uniquely identify each element. These id values must follow our Restrictions on Identifiers.

ForEach([person1, person2, person3], id: \.fullName) { person in
    HStack {
        Text(person.fullName)
        Spacer()
        Text(person.age)
    } 
}

Important: When the body of your ForEach contains multiple top-level views (e.g. a full row of a VGrid), or any single view that expands to additional views (like a Section or a nested ForEach), SkipUI must "unroll" the loop in order to supply all its views individually to Compose. This means that the ForEach will be entirely iterated up front, though the views it produces won't yet be rendered.

Gestures

SkipUI currently supports tap, long press, and drag gestures. You can use either the general .gesture modifier or the specialized modifiers like .onTapGesture to add gesture support to your views. The following limitations apply:

There is one exception to the last limitation: you can create a DragGesture(minimumDistance: 0) in order to detect touch down events immediately.

Shapes and Paths

SwiftUI automatically applies a mask to shapes and paths so that touches outside the shape do not trigger its gestures. SkipUI emulates this feature, but it is not supported on custom shapes and paths that have a .stroke applied. These shapes will register touches anywhere in their bounds. Consider using .strokeBorder instead of .stroke when a gesture mask is needed on a custom shape.

Grids

SkipUI renders SwiftUI grid views using native Compose grids. This provides maximum performance and a native feel on Android. The different capabilities of SwiftUI and Compose grids, however, imposes restrictions on SwiftUI grid support in Android:

Haptics

SkipUI supports UIKit's UIFeedbackGenerator API for generating haptic feedback on the device, typically as a result of user interaction. Some examples are as follows:

// impact haptic feedback
UIImpactFeedbackGenerator(style: .light).impactOccurred()
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()

UIImpactFeedbackGenerator().impactOccurred(intensity: 0.5)

// notification haptic feedback
UINotificationFeedbackGenerator().notificationOccurred(.success)
UINotificationFeedbackGenerator().notificationOccurred(.warning)
UINotificationFeedbackGenerator().notificationOccurred(.error)

// selection haptic feedback
UISelectionFeedbackGenerator().selectionChanged()

Android requires adding a permission in order to be able to utilize the device's haptic feedback service (android.content.Context.VIBRATOR_MANAGER_SERVICE) by adding to the Android/app/src/main/AndroidMetadata.xml file's manifest section: <uses-permission android:name="android.permission.VIBRATE"/> {: class="callout warning"}

Images

Network Images

SkipUI supports loading images from network URLs using SwiftUI's AsyncImage. Our implementation uses the Coil library to download images on Android. This includes support for a loading indicator, such as:

AsyncImage(url: URL(string: "https://picsum.photos/id/237/200/300")) { image in
    image.resizable()
} placeholder: {
    ProgressView()
}

Image Assets

Images can be bundled in asset catalogs provided in the Resources folder of your SwiftPM modules. Your Package.swift project should have the module's .target include the Resources folder for resource processing (which is the default for projects created with skip init):

.target(name: "MyModule", dependencies: ..., resources: [.process("Resources")], plugins: skipstone)

Once the asset catalog is added to your Resources folder, any bundled images can be loaded and displayed using the Image(name:bundle:) constructor. For example:

Image("Cat", bundle: .module, label: Text("Cat JPEG image"))

See the Skip Showcase app ImagePlayground for a concrete example of using a bundled image in an asset catalog, and see that project's Xcode project file (screenshot) to see the configuration of the .xcassets file for the app module.

Note that you must specify the bundle parameter for images explicitly, since a Skip project uses per-module resources, rather than the default Bundle.main bundle that would be assumed of the parameter were omitted. {: class="callout info"}

When an app project is first created with skip init, it will contain two separate asset catalogs: a project-level Assets.xcassets catalog that contains the app's icons, and an empty module-level Module.xcassets catalog. Only the module-level catalog will be transpiled, since the project-level catalog is not processed by the skip transpiler. {: class="callout warning"}

In addition to raster image formats like .png and .jpg, vector images in the .svg and .pdf formats are also supported in asset catalogs. This can be useful for providing images that can scale up or down with losing quality, and are commonly used for icons. Supported .svg sources are discussed in the System Symbols documentation below. PDF images must have the "Preserve Vector Data" flag set in the asset in Xcode (screenshot) in order to support tinting with the .foregroundStyle(color) modifier. Otherwise, the colors set in the PDF itself will always be used when displaying the image.

Image("baseball-icon", bundle: .module, label: Text("Baseball Icon"))
    .resizable()
    .aspectRatio(contentMode: .fit)
    .foregroundStyle(Color.cyan)
    .frame(width: 30, height: 30)

Skip currently supports Light and Dark variants of images in an asset catalog, and will display the appropriate image depending on the active color scheme. Other image asset variants like size classes are currently unsupported. {: class="callout warning"}

Bundled Images

In addition to using asset catalogs, images may be included in the Resources folder and referenced directly using AsyncImage to display local image resources. This works on both iOS and through Skip on Android. So if you have an image Sources/MyModule/Resources/sample.jpg then the following SwiftUI will display the image on both platforms:

AsyncImage(url: Bundle.module.url(forResource: "sample", withExtension: "jpg"))

System Symbols

The Image(systemName:) constructor is used to display a standard system symbol name that is provided on Darwin platforms. There is no built-in equivalent to these symbols on Android, but you can add same-named vector symbols manually, so that code like Image(systemName: "folder.fill") will use the built-in "folder.fill" symbol on iOS, but will use your included folder.fill.svg vector asset on Android.

  1. If it doesn't already exist, create a Module.xcassets asset catalog in your top-level app module's Resources folder.
  2. Download the desired symbol from the Google Material Icons catalog. Make sure to download in the iOS SVG format (see the documentation). You can also export symbols from the SF Symbols app.
  3. Give the downloaded symbol file the same name as the iOS symbol you want it to represent on Android. Keep the .svg file extension.
  4. Drag the file to your Module.xcassets asset catalog.

When exporting from the SF Symbols app, selecting "Export for: Xcode 12" may result in sharper rendering on Android.
{: class="callout info"}

See the Skip Showcase app ImagePlayground for a concrete example of using a system symbol with an exported symbol image, and see that project's Xcode project file (screenshot) to see how the symbol is included in the .xcassets file for the app module.

SkipUI currently supports using the view's foregroundStyle and fontWeight to customize the color and weight of the symbol, but other symbol modifiers such as symbolVariant and symbolRenderingMode are currently unsupported. {: class="callout warning"}

Downloaded symbols can be used directly, or they can be edited using an SVG editor to provide custom vector symbols for you app, as described at Creating custom symbol images for your app. You use Image(systemName:) to load a system symbol image and Image(_:bundle) to load your custom symbol, as the following code shows:

// Display a system symbol image
Image(systemName: "multiply.circle.fill")

// Display a custom symbol image that is included in the module's asset catalog
Image("custom.multiply.circle", bundle: .module)

This is discussed further in the documentation for Loading a symbol image.

Fallback Symbols

If a matching system symbol with the same name is not found in any of the asset catalog files for the top-level app module, SkipUI will fallback to a small subset of pre-defined symbol names that map to the equivalent Compose material symbols (as seen at https://developer.android.com/reference/kotlin/androidx/compose/material/icons/Icons). The fallback symbols will not match the iOS equivalents exactly, but will provide a rough approximation of the symbol's shape and meaning.

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 Icons.Outlined.Star
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

In Android-only code, you can also supply any androidx.compose.material.icons.Icons image name as the systemName. For example:

#if SKIP
Image(systemName: "Icons.Filled.Settings")
#endif

Layout

SkipUI fully supports SwiftUI's various layout mechanisms, including HStack, VStack, ZStack, and the .frame modifier. If you discover layout edge cases where the result on Android does not match the result on iOS, please file an Issue. The following is a list of known cases where results may not match:

Lists

SwiftUI Lists are powerful and flexible components. SkipUI currently supports the following patterns for specifying List content.

Static content. Embed a child view for each row directly within the List:

List {
    Text("Row 1")
    Text("Row 2")
    Text("Row 3")
}

Indexed content. Specify an Int range and a closure to create a row for each index:

List(1...100) { index in 
    Text("Row \(index)")
}

Collection content. Supply any RandomAccessCollection - typically an Array - and a closure to create a row for each element. If the elements do not implement the Identifiable protocol, specify the key path to a property that can be used to uniquely identify each element:

List([person1, person2, person3], id: \.fullName) { person in
    HStack {
        Text(person.fullName)
        Spacer()
        Text(person.age)
    } 
}

ForEach content. Use ForEach to specify indexed or collection content. This allows you to mix content types.

List {
    Text("People").bold()
    ForEach([person1, person2, person3], id: \.fullName) { person in
        HStack {
            Text(person.fullName)
            Spacer()
            Text(person.age)
        }
    }
}

When using collection content or a ForEach with collection content, you can enable swipe-to-delete and drag-to-reorder by supplying a binding to the collection and the appropriate set of edit actions.

List($people, id: \.fullName, editActions: .all) { $person in
    Text(person.fullName)
        .deleteDisabled(!person.isDeletable)
    }
}

You can also enable editing by using a ForEach with the .onDelete and .onMove modifiers. Make sure your ForEach also supplies an id for each item.

List Limitations

Navigation

SwiftUI has three primary forms of navigation: TabView, NavigationStack, and modal presentations. SkipUI has implemented all three, albeit with the restrictions explained below.

SkipUI's TabView does yet not support SwiftUI's overflow tab behavior. Adding too many tabs will just result in too many tabs rather than SwiftUI's automatic "More" tab. Otherwise, TabView acts as you would expect.

In SwiftUI, you push views onto a NavigationStack with NavigationLink. NavigationLink has two ways to specify its destination view: embedding the view directly, or specifying a value that is mapped to a view through the .navigationDestination modifier, as in the following code sample:

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

SkipUI supports both of these models. Using .navigationDestinations, however, requires some care. It is currently the case that if a pushed view defines a new .navigationDestination for key type T, it will overwrite any previous stack view's T destination mapping. Take care not to unintentionally re-map the same key type in the same navigation stack.

Compose imposes an additional restriction as well: we must be able to stringify .navigationDestination key types. See Restrictions on Identifiers below.

Restrictions on Identifiers

Compose requires all state values to be serializable. This restriction is typically transparent to your code, because when you use property wrappers like @State, SkipUI automatically tracks your state objects and gives Compose serializable identifiers in their place. Some SwiftUI values, however, must be stored directly in Compose, including navigationDestination values and List item identifiers. When this is the case, SkipUI creates a String from the value you supply using the following algorithm:

Please ensure that when using these API, the above algorithm will create unique, stable strings for unique values.

Safe Area

Like the iPhone, Android devices can render content behind system bars like the top status bar and bottom gesture area. SwiftUI code using the .ignoresSafeArea modifier to extend content behind system bars will work the same across SwiftUI and SkipUI, with two exceptions:

MyView()
    .background(.yellow, ignoresSafeAreaEdges: .all)

Remember that you can use #if SKIP blocks to confine your .ignoresSafeArea calls for iOS or Android only.

Enabling or Disabling Edge-to-Edge

Modern SkipUI versions enable Jetpack Compose's "edgeToEdge" mode by default. If you created your app with the skip tool prior to v0.8.32, however, you will have to update the included Android/app/src/main/kotlin/.../Main.kt file to render content behind system bars. Using the latest Main.kt as your template, please do the following:

  1. Add the following import: import androidx.activity.enableEdgeToEdge
  2. Add the following line to the MainActivity.onCreate(savedInstanceState:) function:
override fun onCreate(savedInstanceState: android.os.Bundle?) {
    super.onCreate(savedInstanceState)
    enableEdgeToEdge() // <--- Add this line
    ...

With these updates in place, your app should extend below the system bars. If you're running a modern SkipUI version and want to disable edge-to-edge mode, simply remove the enableEdgeToEdge() call.

Contributing

We welcome contributions to SkipUI. The Skip product documentation includes helpful instructions and tips on local Skip library development.

The most pressing need is to implement more core components and view modifiers. View modifiers in particular are a ripe source of low-hanging fruit. The Compose Modifier type often has built-in functions that replicate SwiftUI modifiers, making these SwiftUI modifiers easy to implement. To help fill in unimplemented API in SkipUI:

  1. Find unimplemented API. Unimplemented API will either be within #if !SKIP blocks, or will be marked with @available(*, unavailable). Note that most unimplemented View modifiers are in the View.swift source file.
  2. Write an appropriate Compose implementation. See Implementation Strategy below.
  3. Write tests and/or showcase code to exercise your component. See Tests.
  4. Submit a PR.

Other forms of contributions such as test cases, comments, and documentation are also welcome!

Tests

SkipUI utilizes a combination of unit tests, UI tests, and basic snapshot tests in which the snapshots are converted into ASCII art for easy processing.

Perhaps the most common way to test SkipUI's support for a SwiftUI component, however, is through the Skip Showcase app. Whenever you add or update support for a visible element of SwiftUI, make sure there is a showcase view that exercises the element. This not only gives us a mechanism to test appearance and behavior, but the showcase app becomes a demonstration of supported SwiftUI components on Android over time.

Implementation Strategy

Code Transformations

SkipUI does not work in isolation. It depends on transformations the skip transpiler plugin makes to SwiftUI code. And while Skip generally strives to write Kotlin that is similar to hand-crafted code, these SwiftUI transformations are not something you'd want to write yourself. Before discussing SkipUI's implementation, let's explore them.

Both SwiftUI and Compose are declarative UI frameworks. Both have mechanisms to track state and automatically re-render when state changes. SwiftUI models user interface elements with View objects, however, while Compose models them with @Composable functions. The Skip transpiler must therefore translate your code defining a View graph into @Composable function calls. This involves two primary transformations:

  1. The transpiler inserts code to sync View members that have special meanings in SwiftUI - @State, @EnvironmentObject, etc - with the corresponding Compose state mechanisms, which are not member-based. The syncing goes two ways, so that your View members are populated from Compose's state values, and changing your View members updates Compose's state values.
  2. The transpiler turns @ViewBuilders - including View.body - into @Composable function calls.

The second transformation in particular deserves some explanation, because it may help you to understand SkipUI's internal API. Consider the following simple example:

struct V: View {
    let isHello: Bool

    var body: some View {
        if isHello {
            Text("Hello!")
        } else {
            Text("Goodbye!")
        }
    }
}

The transpilation would look something like the following:

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

    ...
}

Notice the changes to the body content. Rather than returning an arbitrary view tree, the transpiled body always returns a single ComposeBuilder, a special SkipUI view type that invokes a @Composable block. The logic of the original body is now within that block, and any View that body would have returned instead invokes its own Compose(context:) function to render the corresponding Compose component. The Compose(context:) function is part of SkipUI's View API.

Thus the transpiler is able to turn any View.body - actually any @ViewBuilder - into a ComposeBuilder: a block of Compose code that it can invoke to render the desired content. A later section details how you can use SkipUI's ComposeView yourself to move fluidly between SwiftUI and Compose when writing your Android UI.

Implementation Phases

SkipUI contains stubs for the entire SwiftUI framework. API generally goes through three phases:

  1. Code that no one has begun to port to Skip starts in #if !SKIP blocks. This hides it from the Skip transpiler.
  2. The first implementation step is to move code out of #if !SKIP blocks so that it will be transpiled. This is helpful on its own, even if you just mark the API @available(*, unavailable) because you are not ready to implement it for Compose. An unavailable attribute will provide Skip users with a clear error message, rather than relying on the Kotlin compiler to complain about unfound API.
    • When moving code out of a #if !SKIP block, please strip Apple's extensive API comments. There is no reason for Skip to duplicate the official SwiftUI documentation, and it obscures any Skip-specific implementation comments we may add.
    • SwiftUI uses complex generics extensively, and the generics systems of Swift and Kotlin have significant differences. You may have to replace some generics or generic constraints with looser typing in order to transpile successfully. Typing will still be enforced in user code by the Swift compiler.
    • Reducing the number of Swift extensions and instead folding API into the primary declaration of a type can make Skip's internal symbol storage more efficient. You should, however, leave View modifiers that are specific to a given component - e.g. .navigationTitle is specific to NavigationStack - within the component's source file.
  3. Finally, we add a Compose implementation and remove any unavailable attribute.

Note that SkipUI should remain buildable throughout this process. Being able to successfully compile SkipUI in Xcode helps us validate that our ported components still mesh with the rest of the framework.

Components

Before implementing a component, familiarize yourself with SkipUI's View protocol in Sources/View/View.swift as well as the files in the Sources/Compose directory. It is also helpful to browse the source code for components and modifiers that have already been ported. See the table of Supported SwiftUI.

The Text view exemplifies a typical SwiftUI component implementation. Here is an abbreviated code sample:

public struct Text: View, Equatable, Sendable {
    let text: String

    public init(_ text: String) {
        self.text = text
    }

    ...

    #if SKIP
    @Composable public override func ComposeContent(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
}

As you can see, the Text type is defined just as it is in SwiftUI. We then use an #if SKIP block to implement the composable View.ComposeContent function for Android, while we stub the body var to satisfy the Swift compiler. ComposeContent makes the necessary Compose calls to render the component, applying the modifier from the given context as well as any applicable environment values. If Text had any child views, ComposeContent would call child.Compose(context: context.content()) to compose its child content. (Note that View.Compose(context:) delegates to View.ComposeContent(context:) after performing other bookkeeping operations, which is why we override ComposeContent rather than Compose.)

Modifiers

Modifiers, on the other hand, use the ComposeModifierView to perform actions, including changing the context passed to the modified view. Here is the .opacity modifier:

extension View {
    public func opacity(_ opacity: Double) -> some View {
        #if SKIP
        return ComposeModifierView(targetView: self) { context in
            context.modifier = context.modifier.alpha(Float(opacity))
            return ComposeResult.ok
        }
        #else
        return self
        #endif
    }
}

Some modifiers have their own composition logic. These modifiers use a different ComposeModifierView constructor whose block defines the composition. Here, for example, .frame composes the view within a Compose Box with the proper dimensions and alignment:

extension View {
    public func frame(width: CGFloat? = nil, height: CGFloat? = nil, alignment: Alignment = .center) -> some View {
        #if SKIP
        return ComposeModifierView(contentView: self) { view, 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()) {
                    view.Compose(context: contentContext)
                }
            }
        }
        #else
        return self
        #endif
    }
}

Like other SwiftUI components, modifiers use #if SKIP ... #else ... to stub the Swift implementation and keep SkipUI buildable in Xcode.