Tutorial
1. Configure the project with the plugin
There are two types of setups:
- Single-module approach: your whole app is in the same module
- Multi-module approach: your screens and widgets are spread between multiple modules and, you have an Umbrella Module that exports the Framework to iOS
Samples
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.1")
}
}
}
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.1"
"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.1")
}
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.KotlinCompilationTask<*>>().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:
- 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:
- Making sure that each expect Composable is being configured with a Custom Factory name by configuring in the annotation
@ExpectSwiftView
. - Or less error prone, using KSP property
ksp { arg("compose-swift-bridge.defaultFactoryName", "MyModuleNameView") }
. (See sample below)
- Making sure that each expect Composable is being configured with a Custom Factory name by configuring in the annotation
- 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.1")
}
}
}
dependencies {
val composeSwiftBridgeKsp = "co.touchlab.compose:compose-swift-bridge-ksp:0.1.1"
"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.1")
}
// 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.KotlinCompilationTask<*>>().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.
- 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. - 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. 👏👏
- 1. Configure the project with the plugin
- Samples
- Single module approach
- Multi-Module setup
- 2. Using the tool
- 2.1: Applying the annotation
- 2.2: Providing the Factory Interface that will come from iOS
- 2.3: The SwiftUI Native Component
- 2.4: Implementing the Factory protocol
- 2.5: Update iOS to pass the factory to Compose
- Troubleshooting