Compose Swift BridgeExpect Swift View - Tutorial

Tutorial

1. Configure the project with the plugin

There are two types of setups:

  1. Single-module approach: your whole app is in the same module
  2. Multi-module approach: your screens and widgets are spread between multiple modules and, you have an Umbrella Module that exports the Framework to iOS

Single module approach

First the tool requires SKIE and KSP be configured in your project build.gradle.kts for example:

plugins {
    id("co.touchlab.skie") version "0.9.3"
    id("com.google.devtools.ksp") version "YOUR KSP VERSION HERE"
    ...
}

You can find KSP versions on their GitHub page.

Next we need to add the Compose Swift Bridge dependency to commonMain to allow us access the Annotation.

kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("co.touchlab.compose:compose-swift-bridge:0.1.0-SNAPSHOT")
        }
    }
}

Now we need to configure the Compose Swift Bridge KSP Plugin and SKIE SubPlugin. You should add the KSP dependency to all targets your project support, such as wasm, js, jvm, android, etc. The main reason is that the tool will generate expect interface’s that depending on the target, will automatically generate the actual implementations.

dependencies {
    val composeSwiftBridgeKsp = "co.touchlab.compose:compose-swift-bridge-ksp:0.1.0-SNAPSHOT"
    "kspCommonMainMetadata"(composeSwiftBridgeKsp) // Common Main generation required
   
    // iOS targets
    "kspIosSimulatorArm64"(composeSwiftBridgeKsp)
    "kspIosArm64"(composeSwiftBridgeKsp)
    "kspIosX64"(composeSwiftBridgeKsp)
      
    // All targets your module support, here, is Android only as a example
    "kspAndroid"(composeSwiftBridgeKsp)

    // add the SKIE SubPlugin that will generate the Swift code
    skieSubPlugin("co.touchlab.compose:compose-swift-bridge-skie:0.1.0-ALPHA")
}

We also need to Configure KSP to be able to identify the target that is running for the tool and support Common code generation.

// Adds the required targetName for the KSP plugin
tasks.withType<com.google.devtools.ksp.gradle.KspTaskNative>().configureEach {
    options.add(SubpluginOption("apoption", "compose-swift-bridge.targetName=$target"))
}

// support for generating ksp code in commonCode
// see https://github.com/google/ksp/issues/567
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
    if (name != "kspCommonMainKotlinMetadata") {
        dependsOn("kspCommonMainKotlinMetadata")
    }
}

kotlin.sourceSets.commonMain {
   kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
}

We are all set up!

Multi-Module setup

Multi Module setup works differently. Your project has multiple modules with Compose code and you want to be able to annotate this Composable with @ExpectSwiftView. For that we have to keep some considerations in mind.

Considerations:

  1. Each of your Modules should have a different Factory Name (The name that the generator use for creating the Interface that will be implemented in Swift) configured, so you have two options:
    1. Making sure that each expect Composable is being configured with a Custom Factory name by configuring in the annotation @ExpectSwiftView.
    2. Or less error prone, using KSP property ksp { arg("compose-swift-bridge.defaultFactoryName", "MyModuleNameView") }. (See sample below)
  2. The modules should be exported to iOS (aka framework { export(project("your_module")) })

Let’s dive in.

The modules containing Compose UI and the expect composable with @ExpectSwiftView should have KSP configured similar to the umbrella module setup.

plugins {
    id("com.google.devtools.ksp") version "YOUR KSP VERSION HERE"
    ...
}

kotlin {
    sourceSets {
        commonMain.dependencies {
            // adds the dependency for accessing annotations
            implementation("co.touchlab.compose:compose-swift-bridge:0.1.0-SNAPSHOT")
        }
    }
}

dependencies {
   val composeSwiftBridgeKsp = "co.touchlab.compose:compose-swift-bridge-ksp:0.1.0-SNAPSHOT"
   "kspCommonMainMetadata"(composeSwiftInteropKsp) // Common Main generation required

   // iOS targets
   "kspIosSimulatorArm64"(composeSwiftBridgeKsp)
   "kspIosArm64"(composeSwiftBridgeKsp)
   "kspIosX64"(composeSwiftBridgeKsp)

   // All targets your module support, here, is Android only as a example
   "kspAndroid"(composeSwiftBridgeKsp)

   // add the SKIE SubPlugin that will generate the Swift code
   skieSubPlugin("co.touchlab.compose:compose-swift-bridge-skie:0.1.0-ALPHA")
}

// Adds the required targetName for the KSP plugin
tasks.withType<com.google.devtools.ksp.gradle.KspTaskNative>().configureEach {
   options.add(SubpluginOption("apoption", "compose-swift-bridge.targetName=$target"))
}

ksp {
    // Configure the module with a custom default factory name, the default is called "NativeView"
    arg("compose-swift-bridge.defaultFactoryName", "MyModuleNameView")
}

// support for generating ksp code in commonCode
// see https://github.com/google/ksp/issues/567
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
   if (name != "kspCommonMainKotlinMetadata") {
      dependsOn("kspCommonMainKotlinMetadata")
   }
}

kotlin.sourceSets.commonMain {
   kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
}

2. Using the tool

So you have set up the project properly, the next step is to start using the tool. For the sake of the tutorial let’s imagine two scenarios.

  1. You have a Compose Component, for example a Map View on Android. You have Google Maps but for iOS the UIKit API is not good enough to be using in iosMain. So you have to implement this in Swift.
  2. You have a Screen that for performance reasons, or native feeling, your Managers want to be implemented in the Native view system of iOS. In this case you will write in SwiftUI.

2.1: Applying the annotation

For the first scenario, here an example:

You have a MapView component that receives a Title and a Coordinate of where the Map Pin will be located. On Android you use Google Maps as you would. On iOS you don’t need to implement this actual function, you just need to annotate with @ExpectSwiftView. The tool will take care of creating the actual implementation using KSP.

data class MapCoordinates(
    val lat: Double,
    val lng: Double,
)

@ExpectSwiftView
@Composable
expect fun MapView(
    modifier: Modifier = Modifier,
    title: String,
    coordinate: MapCoordinates,
)

The Modifier parameter above is directly applied to the Compose interop view, this is where you will, for example, apply the size of your Native Component.

2.2: Providing the Factory Interface that will come from iOS

Now you have to build the project for iOS (for example: gradlew :linkDebugFrameworkIosArm64). This will make both KSP and SKIE Sub Plugins and generate all required files to you to continue the first setup.

After successfully running the build, the plugin will generated:

  • Local{FactoryName}Factory - a LocalComposition
  • {FactoryName}Factory - the interface for factoring the IOS Views (SwiftUI, UIKit).

The FactoryName placeholder is based on the configuration that you can declare at the @ExpectSwiftView or if you have configured a using KSP property compose-swift-interop.defaultFactoryName. By default, if you have not customized it, it will be named NativeView, so you should expect to be generated with the following names: LocalNativeViewFactory and NativeViewFactory.

Now we need to update your Compose IOS EntryPoint (The function that returns a UIViewController and initializes Compose with ComposeUIViewController) with the {FactoryName}Factory parameter, and provide what we will receive from the iOS Project at the LocalComposition Local{FactoryName}Factory.

// For the sake of the example, we are using the Default Factory Name: NativeView
fun MainViewController(
    nativeViewFactory: NativeViewFactory
): UIViewController = ComposeUIViewController {
    CompositionLocalProvider(
        LocalNativeViewFactory provides nativeViewFactory,
    ) {
        AppScreen() // Your UI Content
    }
}

2.3: The SwiftUI Native Component

In order to start writing Swift we want XCode to find the new files. For that you should build KMP framework. if you are using Build Phase setup at XCode project(The KMP build is linkage directly into XCode build), you can just try run the project to have an updated framework at the XCode. It will fail because of the modification we did so far, but you will now have auto-complete to the new generate files.

Let’s start with the sample Map View using SwiftUI. Similar to what the Composable that we defined previously, we need a title and coordinate. Here is an example Map View implementation:

import SwiftUI
import ComposeApp
import MapKit

struct NativeMapView : View {
    let title: String
    let coordinate: MapCoordinates
    
    @State private var position: MapCameraPosition

    init(title: String, coordinate: MapCoordinates) {
        self.title = title
        self.coordinate = coordinate
        _position = State(initialValue: .region(
            MKCoordinateRegion(
                center: CLLocationCoordinate2D(latitude: coordinate.lat, longitude: coordinate.lng),
                span: MKCoordinateSpan(latitudeDelta: 0.008, longitudeDelta: 0.008)
            )
        ))
    }
    
    var body: some View {
        MapReader { reader in
            Map(position: $position) {
                Annotation(
                    title,
                    coordinate: CLLocationCoordinate2D(
                        latitude: coordinate.lat,
                        longitude: coordinate.lng
                    )
                ) {
                    VStack {
                      Group {
                        Image(systemName: "mappin.circle.fill")
                          .resizable()
                          .frame(width: 30.0, height: 30.0)
                        Circle()
                          .frame(width: 8.0, height: 8.0)
                      }
                      .foregroundColor(.red)
                    }
                }
            }
            .disabled(true)
        }
        .onChange(of: coordinate) { newCoordinate in
            position = .region(
                MKCoordinateRegion(
                    center: CLLocationCoordinate2D(latitude: newCoordinate.lat, longitude: newCoordinate.lng),
                    span: MKCoordinateSpan(latitudeDelta: 0.008, longitudeDelta: 0.008)
                )
            )
        }
    }
}

Now we can make things interesting. Compose expect Swift also generates an ObservableObject for all parameters at the Composable function. This way the SwiftUI View can always stay updated with the composable states. The Observable name generated follows the name of the Composable: {ComposableName}Observable. In the case of the tutorial this will be MapViewObservable.

With this ObservableObject we can directly update the View component to receive it or we can create a binding/observable component that receives the ObservableObject as a parameter and renders the Native View as shown below.

struct NativeMapViewBinding : View {
    @ObservedObject var observable: MapViewObservable
    
    var body: some View {
        NativeMapView(title: observable.title, coordinate: observable.coordinate)
    }
}

2.4: Implementing the Factory protocol

As we have seen previously, the code generator will generate a {FactoryName}Factory interface/protocol that we need to implement on iOS and provide our SwiftUI Views for each Composable function annotated with @ExpectSwiftView. In the case of the tutorial, the protocol we need to implement on iOS is NativeViewFactory because we are using the default factory name as mentioned previously.

class SwiftUINativeViewFactory : NativeViewFactory {
    func createMapView(observable: ComposeApp.MapViewObservable) -> AnyView {
        return AnyView(NativeMapViewBinding(observable: observable))
    }
}

2.5: Update iOS to pass the factory to Compose

The last part is to actually provide this implementation that we have built on iOS to your Compose Entrypoint function on the iOS side. The example below the app is a SwiftUI app, so we have a UIViewControllerRepresentable, but may be different on your setup. The main change here that we want to do is update the MainViewController call to provide the SwiftUINativeViewFactory that we have created.

struct MainView : UIViewControllerRepresentable {
    func makeUIViewController(context: Context) -> some UIViewController {
        MainViewController(nativeViewFactory: SwiftUINativeViewFactory())
    }
    
    func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {}
}

Troubleshooting

In the example above, you can see that we are calling directly MainViewController, this work by using the SKIE Feature Global Functions, the Compose Swift Bridge tool also generates a new Swift function with the correct Factory Protocol from Swift. Suppose you are not using the SKIE Global Function, in that case, you may encounter this similar compilation issue Argument type 'SwiftUINativeViewFactory' does not conform to expected type 'ComposeNativeViewFactory', and you may be using something like this: MainViewControllerKt.MainViewController(nativeViewFactory: SwiftUINativeViewFactory()). To fix this, use the Global Function from SKIE, which is the pretty solution, or wrap your {FactoryName}Factory inside iOS{FactoryName}Factory like: MainViewControllerKt.MainViewController(nativeViewFactory: iOSNativeViewFactory(SwiftUINativeViewFactory()))

Now, make sure you have added the MapView Component to your Screen in Compose and just run the iOS app and see in practice. 👏👏