KSP library for generating ComposeUIViewController
and UIViewControllerRepresentable
implementations when using Compose Multiplatform for iOS.
When employing Compose Multiplatform for iOS, if the goal is to effectively manage the UI state within the iOS app, it's essential to adopt the approach detailed here:
Compose Multiplatform — Managing UI State on iOS.
As the project expands, the codebase required naturally grows, which can quickly become cumbersome and susceptible to errors. To mitigate this challenge, this library leverages Kotlin Symbol Processing to automatically generate the necessary code for you.
Kotlin Multiplatform and Compose Multiplatform are built upon the philosophy of incremental adoption and sharing only what you require. Consequently, the support for this specific use-case - in my opinion - is of paramount importance, especially in its capacity to entice iOS developers to embrace Compose Multiplatform.
Version | Kotlin | KSP | K2 | Compose Multiplatform | Xcode |
---|---|---|---|---|---|
1.9.20-RC2 | 1.0.13 | Yes | 1.5.10-rc02 | 14.3.1, 15.0 |
It's important to note that this addresses the current Compose Multiplatform API design. Depending on JetBrains' future implementations, this may potentially become deprecated.
Steps to follow:
First we need to import the ksp plugin:
plugins {
id("com.google.devtools.ksp") version "${Kotlin}-${KSP}"
}
Then configure iosMain target to import kmp-composeuiviewcontroller-annotations
:
kotlin {
val iosX64 = iosX64()
val iosArm64 = iosArm64()
val iosSimulatorArm64 = iosSimulatorArm64()
applyDefaultHierarchyTemplate()
sourceSets {
val iosMain by getting {
dependencies {
implementation("com.github.guilhe.kmp:kmp-composeuiviewcontroller-annotations:${LASTEST_VERSION}")
}
}
}
}
and also the kmp-composeuiviewcontroller-ksp
:
listOf(iosX64, iosArm64, iosSimulatorArm64).forEach { target ->
val targetName = target.name.replaceFirstChar { it.uppercaseChar() }
dependencies.add("ksp$targetName", "com.github.guilhe.kmp:kmp-composeuiviewcontroller-ksp:${LASTEST_VERSION}")
}
Finish it by adding this task
configuration in the end of the file:
- If using XCFramework:
tasks.matching { it.name == "embedAndSignAppleFrameworkForXcode" }.configureEach { finalizedBy(":addFilesToXcodeproj") }
- If using Cocoapods:
tasks.matching { it.name == "syncFramework" }.configureEach { finalizedBy(":addFilesToXcodeproj") }
You can find a full setup example here.
Now we can take advantage of two annotations:
@ComposeUIViewController
: it will mark the@Composable
as a desiredComposeUIViewController
to be used by the iosApp;@ComposeUIViewControllerState
: it will specify the composable state variable.
@ComposeUIViewController
will always require a unique@ComposeUIViewControllerState
;@ComposeUIViewController
has aframeworkName
parameter that must used to specify the shared library framework's base name;@ComposeUIViewControllerState
can only be applied once per@Composable
;- The state variable of your choosing must have default values in it's initialization;
- Only 1
@ComposeUIViewControllerState
and * function parameters (excluding@Composable
) are allowed in@ComposeUIViewController
functions.
For more information consult the ProcessorTest.kt file from kmp-composeuiviewcontroller-ksp
.
data class ViewState(val status: String = "default")
@ComposeUIViewController("SharedUI")
@Composable
fun ComposeView(@ComposeUIViewControllerState viewState: ViewState, callback: () -> Unit) { }
will produce a ComposeViewUIViewController
:
public object ComposeViewUIViewController {
private val viewState = mutableStateOf(ViewState())
public fun make(callback: () -> Unit): UIViewController {
return ComposeUIViewController {
ComposeView(viewState.value, callback)
}
}
public fun update(viewState: ViewState) {
this.viewState.value = uiState
}
}
and also a ComposeViewRepresentable
:
import SwiftUI
import SharedUI
public struct ComposeViewRepresentable: UIViewControllerRepresentable {
@Binding var viewState: ScreenState
let callback: () -> Void
func makeUIViewController(context: Context) -> UIViewController {
return ComposeViewUIViewController().make(callback: callback)
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
ComposeViewUIViewController().update(viewState: viewState)
}
}
Having all the files created by KSP, the next step is to make sure all the UIViewControllerRepresentable
files are referenced in xcodeproj
for the desire target
:
- Make sure you have Xcodeproj installed;
- Copy the exportToXcode.sh file to the project's root and run
chmod +x ./exportToXcode.sh
- Copy the following gradle task to the project's root
build.gradle.kts
:
tasks.register<Exec>("addFilesToXcodeproj") {
workingDir(layout.projectDirectory)
commandLine("bash", "-c", "./exportToXcode.sh")
}
note: if you change the default names of shared module, iosApp folder, iosApp.xcodeproj file and iosApp target, you'll have to adjust the exportToXcode.sh
accordingly (in # DEFAULT VALUES
section).
Now that the UIViewControllerRepresentable
files are included and referenced in the xcodeproj
, they are ready to be used:
struct SharedView: View {
@State private var state: ViewState = ViewState(status: "default")
var body: some View {
ComposeViewRepresentable(viewState: $state, callback: {})
}
}
Pretty simple right? 😊
For a working sample run iosApp by opening iosApp/iosApp.xcodeproj
in Xcode and run standard configuration or use KMM plugin for Android Studio and choose iosApp
in run configurations.
> Task :shared:kspKotlinIosSimulatorArm64
note: [ksp] loaded provider(s): [com.github.guilhe.kmp.composeuiviewcontroller.ksp.ProcessorProvider]
note: [ksp] GradientScreenUIViewController created!
note: [ksp] GradientScreenRepresentable created!
> Task :addFilesToXcodeproj
> Copying files to iosApp/SharedRepresentables/
> Checking for new references to be added to xcodeproj
> GradientScreenUIViewControllerRepresentable.swift added!
> Done
It's an example of a happy path 🙌🏼
You can also find another working sample in Expressus App:
Operation | Status |
---|---|
Android Studio Run | 🟢 |
Xcode Run | 🟢 |
Xcode Preview | 🟢 |
Occasionally, the Xcode may experience interruptions or the need of one or two consequent builds, but running the app through Android Studio has remained reliable.
If necessary, disable swift
files automatically export to Xcode and instead include them manually, all while keeping the advantages of code generation. Simply comment the following line:
//...configureEach { finalizedBy(":addFilesToXcodeproj") }
You will find the generated files under {shared-module}/build/generated/ksp/
.
Warning: avoid deleting iosApp/SharedRepresentables
whithout first using Xcode to Remove references
.
Copyright (c) 2023-present GuilhE
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.