We tried (successfully) to use Compose for both iOS and Android UI of the Droidcon app (read more here). The app is written in Kotlin Multiplatform, and the original iOS views (which can still be used by changing a toggle switch in the Settings) are in SwiftUI. The view models are written in Kotlin. The Compose for the iOS is definitely not production ready (at the time of writing this) but we didn’t encounter any major obstacles and to be honest, it was a lot of fun.
Why should anyone use Compose for iOS views? One answer would be to minimize the code that needs to be written. You can share the views or their parts with Android and save time and effort for programmers. It’s also written in Kotlin, so it minimizes the need to have a developer team specialized in Swift (even though some Swift code still needs to be written, but not much). Another point is better compatibility with view models written in Kotlin. You can directly observe changes in StateFlows without the need to write additional code (as you often need to in Multiplatform development when connecting view models to the SwiftUI views). Or if you for some reason want to use Material design for your iOS app. Or if your app is very much stylized for both platforms, with Compose, you need to create your own styled views only once and in one language. If you are still not convinced, a good reason to use Compose for iOS is to just try it out and have some fun, and maybe a use case will come along.
Jetpack Compose’s iOS entrypoint function
Application(title: String, content: @Composable () -> Unit) returns an instance of
UIViewController. Since we’re using SwiftUI in Droidcon, we need to wrap it in an implementation of a
UIViewControllerRepresentable. Unfortunately, the application was crashing when switching back from the background, or when switching from SwiftUI to Jetpack Compose from iOS settings. Those crashes were caused by the incorrect size of the view, so we wrapped the Compose controller in a container controller. This delays the need for view size until Compose can get the correct size from iOS.
We then switch between the SwiftUI implementation, or the Jetpack Compose one, depending on a setting passed down from a view model. If we didn’t use SwiftUI’s
App implementation (with the
@main attribute), we could’ve just instantiated a new
UIWindow with the Jetpack Compose view controller as its root. It would allow us to skip implementing the
UIViewControllerRepresentable and possibly even the workaround mentioned above.
One of the limitations of Jetpack Compose compiled with Kotlin/Native is the requirement for all
@Composable symbols to be
public composables will result in a compile error. For that reason, we hide the call to the
Application entrypoint inside a global function
getRootController and return the controller’s instance from it.
It’s possible to draw images from Xcode Assets by creating a
BitmapPainter from compose
ImageBitmap converted from Skia
Image that was converted from UIKit’s
UIImage. The conversion to Skia
Image consists of remapping all the values and properties, such as width and height, but also bytes per row, alpha type and colour type. The Jetbrains’s Skia Image has limited support for color spaces and at the time of writing doesn’t support grayscale images. We haven’t spent the time to implement color space conversion in runtime and instead made sure all images in the app have one of the supported color spaces. Our implementation also doesn’t support color order other than
RGBA_8888 as Skia’s and UIKit’s implementation are different.
The app currently uses a library called Hyperdrive Singularity for shared view models. To understand why we need to look into the history of the Droidcon app. In July 2021, a rewrite of the Droidcon app was made in Jetpack Compose on Android and SwiftUI on iOS. The rewrite was done by a company Tadeas founded long before joining Touchlab, and they used the Singularity library in the iOS app, to showcase it to Kevin.
When we began trying the Jetpack Compose for iOS, we were skeptical and started small. Initially, we didn’t think there would be any shared UI code between the iOS and Android Compose implementations. So the Composables on iOS were written from scratch and Singularity’s support for Jetpack Compose came in handy. Since Singularity is a multiplatform library and supports Android, we were able to make the Compose UI code shared and replace the previous implementation we had that was using Android ViewModels.
That said, there are other libraries that can be used to write shared view models (it can also be done with no libraries, manually). It was just convenient for us to keep view models already written with Singularity and save ourselves time.
Hyperdrive Singularity was added as an experiment and is not made by, nor endorsed by Touchlab. Use at your own risk.
Demo user’s perspective
After the initial set-up it’s fairly easy to use. You can create composable functions with your views as you normally would, use the same theming as on Android, use material icons and components like
Row, … Even the ripple effect works, though items stay a little lighter until they lose focus (user clicks on something else) on both simulator and on a device. Sadly there is no effect when the end of a scrollable view is reached yet.
Another problem is navigation. For the Droidcon app we used Compose for all of the iOS screens, calling the main compose controller from the main SwiftUI View. Navigation is handled by a custom component:
NavigationController holding and managing a back stack,
NavigationStack has a composable function – parent view – and a list of links where the user can navigate to from the said screen. When a child view should be shown is indicated by a view model value for this screen turning from
null to an instance (in parent view model). In
NavigationStack views with non-null values are shown by being placed in a
SubcomposeLayout. We added an animation by using
AnimatedContent to have nice transitions to children views and back to the parent. It’s possible to use just one or even all the views in compose while the rest is in SwiftUI by having a
UIViewController which can be called from SwiftUI code (for example in SwiftUI’s
NavigationLink to open the compose view as a new screen). It’s important to make sure the size of the compose view is correct, in some instances (compose view inside a view with bottom navigation) we had to set the height manually from a
GeometryReader because the view would cover the space behind the bottom navigation row as well.
Sharing compose views between Android and iOS
By having the compose views in a shared module, we can share them between both Android and iOS, so then each view is written only once and all the changes and fixes can be done in a single place. For cases when we want some view implemented differently for each platform we can use the
expect/actual from KMP. For example having a
DialogView composable expect function that takes composable content as it’s parameter and implementing it on Android with the native
Dialog and on iOS with a custom
Box with semi-transparent background. It’s also useful for images, since we need to get them from the assets/resources differently on each platform. This can also be used for example if we want to have different options for each platform in the settings screen. To be able to have the views common we need common view models and use the <
NavigationController for the navigation on Android as well.
While Compose for iOS is not production ready yet and may never fully replace native iOS UI, it’s definitely worth it to try it out and in the future I’m sure it will find it’s place in codebases of many Multiplatform applications. If you’re trying this out for yourself and need some help, you can reach out to Touchlab.