SkipUI Documentation

repository·main·Indexed 43 days ago

https://github.com/skiptools/skip-ui

SkipUI provides SwiftUI support for Skip apps by reimplementing SwiftUI for Kotlin on Android using Jetpack Compose. It enables developers to write UI in SwiftUI and run it natively on Android, offering tools like ComposeView for embedding Compose code in SwiftUI, .composeModifier for applying Android modifiers, and bridging helpers for Color, Font, and Image types.

Tokens
33.8K
Snippets
106
Records
147
Agent score
50%

What's inside SkipUI

  1. SkipUI API Support Status

    main

    SkipUI provides varying levels of support for SwiftUI APIs. Use the following legend to understand the compatibility of components in your project:

    • ✅ Fully Supported: The API works as expected.
    • 🟢 Partially Supported: The API is available but has specific limitations or constraints.
    • 🟡 Limited/Conditional Support: The API has significant limitations, requires specific compilation (e.g., SkipFuseUI), or behaves differently than standard SwiftUI.

    Refer to the specific API records for detailed constraints on properties, initializers, and behaviors.

  2. How Skip Lite transforms SwiftUI into Compose

    main

    SkipUI works by leveraging transformations performed by the Skip transpiler. Because SwiftUI uses View objects and Compose uses @Composable functions, the transpiler performs two key transformations:

    1. State Syncing: It inserts code to synchronize SwiftUI state members (like @State or @EnvironmentObject) with Compose state mechanisms. This synchronization is bidirectional.
    2. ViewBuilder Transformation: It converts @ViewBuilder blocks (including View.body) into ComposeBuilder objects.

    Inside a ComposeBuilder, the original logic of the body is executed within a @Composable block. Any View that would have been returned in SwiftUI is instead rendered using the Compose(context:) function, which is part of SkipUI's View API. This allows the transpiler to bridge the declarative models of both frameworks.

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

    // Transpiled Kotlin equivalent:

    class V(val isHello: Boolean) : View() {
        override fun body(): View {
            return ComposeBuilder { composectx -> 
                if (isHello) {
                    Text("Hello!").Compose(context = composectx)
                } else {
                    Text("Goodbye!").Compose(context = composectx)
                }
                ComposeResult.ok
            }
        }
    }
  3. Handle AsyncImage phase changes and first-frame rendering

    main

    SkipUI's AsyncImage is built on Coil for Android. Unlike SwiftUI's AsyncImagePhase, Coil has four phases: Success, Failure, Loading, and Empty.

    In SkipUI, the empty case is modeled as empty(Image?). If the phase is empty and the image is nil, the image is currently Loading. You can handle this in three ways:

    1. Optimistic: Attempt to render the image immediately if it exists in the empty phase to avoid a placeholder, but beware of layout shifts if the image size is 0x0.
    2. ZStack: Render the placeholder underneath the image in a ZStack during the empty phase. If the image has no transparency, it will obscure the placeholder once loaded.
    3. Pessimistic: Always show the placeholder during the empty phase and only render the image once the phase is explicitly .success.
    AsyncImage(url: url) { phase in
        switch phase {
        case .empty:
            if let image = phase.image {
                // Option 1: Optimistic
                image.resizable()
            } else {
                // Option 3: Pessimistic
                ProgressView()
            }
        case .failure:
            Color.red
        case .success(let image):
            image.resizable()
        }
    }
  4. Work with GeometryReader and GeometryProxy

    main

    The GeometryReader and GeometryProxy APIs have limited support.

    When using GeometryProxy, you have access to:

    • var size: CGSize
    • func frame(in coordinateSpace: some CoordinateSpaceProtocol) -> CGRect
    • var safeAreaInsets: EdgeInsets

    Constraint: Only .local and .global coordinate spaces are supported.

  5. Understand identifier restrictions for Compose

    main

    Compose requires all state values to be serializable. For values that must be stored directly in Compose (such as navigationDestination keys or List item identifiers), SkipUI generates a String identifier using this algorithm:

    1. If the value is Identifiable, it uses String(describing: value.id).
    2. If the value is RawRepresentable, it uses String(describing: value.rawValue).
    3. Otherwise, it uses String(describing: value).

    Ensure your types produce unique, stable strings via this algorithm to avoid navigation or list errors.

  6. Implement NavigationStack and TabView

    main

    SkipUI implements TabView, NavigationStack, and modal presentations.

    • TabView: Does not support SwiftUI's automatic overflow/"More" tab behavior. Adding many tabs will simply render them all.
    • NavigationStack: Supports pushing views via NavigationLink. You can embed views directly or use .navigationDestination(for:destination:) to map values to views.
    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)
            }
        }
    }
  7. Use Lazy stacks and grids

    main

    SkipUI provides lazy layout components for efficient rendering of large collections. Note the following constraints for specific components:

    • LazyHStack and LazyVStack:
      • Do not support pinned headers and footers.
      • When placed inside a ScrollView, they must be the only child of that view.
    • LazyHGrid and LazyVGrid: Available for grid-based layouts.
    // Example usage of lazy stacks
    LazyVStack {
        ForEach(items) { item in
            Text(item.name)
        }
    }
  8. Bridge SwiftUI types to Compose

    main

    When passing data from compiled Swift to a #if SKIP block, simple types bridge automatically. For SwiftUI-specific types, SkipUI provides helper functions to convert them to their Compose equivalents:

    • Color: Use SkipUI.Color.asComposeColor() to get an androidx.compose.ui.graphics.Color.
    • Font: Use SkipUI.Font.asComposeTextStyle() to get an androidx.compose.ui.text.TextStyle.
    • Image: Call Image.Compose(context:) to render a native SwiftUI Image in Compose.
    • Text: Use SkipUI.Text.localizedTextString() to get the localized string value for use in Compose.
    #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
  9. Configure GridItem specs for Android

    main
    When defining a grid with an array of GridItem specs, the Android implementation is based on the first GridItem in the array. Compose does not support different specs for different rows or columns, so SkipUI applies the first spec to all rows and columns. Additionally, maximum GridItem sizes are ignored.
  10. How Material 3 component modifiers work

    main

    SkipUI provides a family of .material3 modifiers to customize underlying Android components. These modifiers follow a specific pattern:

    1. Closure-based API: The modifier takes a closure that receives a Material3<Component>Options struct.
    2. Immutability via copy: Every options struct implements a copy method (matching Kotlin's pattern), allowing you to modify specific properties easily.
    3. Environment-based propagation: Modifiers place the configuration into the SwiftUI Environment. To affect a group of subviews, apply the modifier to the parent/root view of that group, not the individual components themselves.
    4. Compose-aligned types: The properties in the options structs typically match the parameters of the corresponding androidx.compose.material3 component functions.
    // Example: Customizing a Navigation Bar
    TabView {
        ...
    }
    .material3NavigationBar { options in 
        let updatedColors = options.itemColors.copy(selectedIndicatorColor: Color.green.asComposeColor())
        return options.copy(itemColors: updatedColors) 
    }
  11. Use SwiftUI Property Wrappers in SkipUI

    main

    SkipUI supports most standard SwiftUI property wrappers for state management, with some specific caveats:

    Fully Supported

    • @Bindable
    • @Binding
    • @Environment
    • @EnvironmentObject
    • @ObservedObject
    • @State
    • @StateObject

    Partially Supported / Limitations

    • @AppStorage: Optional values are not supported.
    • @FocusState: FocusState.Binding is not supported. You must manually create a Binding using the following pattern:
      Binding(get: { myFocusState.wrappedValue }, set: { myFocusState.wrappedValue = $0 })
    • @GestureState: Only supported when using SkipFuseUI compiled Swift.