JetBrains and outside open-source contributors have been diligently working on Compose Multiplatform for several years and recently released an alpha version for iOS. Naturally, we were curious to test its functionality, so we decided to experiment by trying to run our Dribbble replication music app on iOS using the framework and seeing what challenges might arise.
Compose Multiplatform for desktop and iOS taps into the capabilities of Skia, an open-source 2D graphics library that is widely used across different platforms. Widely adopted software like Google Chrome, ChromeOS, and Mozilla Firefox are powered by Skia, along with JetPack Compose and Flutter.
Compose Multiplatform Architecture
To understand the Compose Multiplatform approach, we first looked into the overview from JetBrains itself, that includes Kotlin Multiplatform Mobile (KMM) as well.
As you can see in the diagram, the general approach of Kotlin Multiplatform includes:
- Code for iOS-specific APIs like Bluetooth, CodeData etc.
- Shared code for business logic
- UI on the iOS side.
Compose Multiplatform introduced the ability of sharing not only the business logic code, but also the UI code. You have a choice to use native iOS UI frameworks (UIKit or SwiftUI), or embed iOS code directly into Compose. We wanted to see how our complex native Android UI would work on iOS, so we chose to limit native iOS UI code to the minimum. Currently you can only use Swift code for platform-specific APIs, and for platform-specific UI, all other code you can share with the Android app using Kotlin and Jetpack Compose.
The general approach of Kotlin Multiplatform, as shown in the diagram, can include:
- Writing code specifically for iOS APIs like Bluetooth and CodeData.
- Creating shared code for business logic written in Kotlin.
- Creating UI on the iOS side.
Compose Multiplatform has extended the capabilities for sharing code, so now you can share not only business logic but also the UI. You can still use SwiftUI for the UI or embed UIKit directly into Compose, which we will discuss below. With this new development, you can use Swift code only for platform-specific APIs, platform-specific UI and share all other code with the Android app using Kotlin and Jetpack Compose. Let's delve into the preparation needed for startup.
Prerequisites for running on iOS
The best place to find instructions for iOS set up is the official doc itself. To summary it here, this is what you need to start:
Additionally, there is a template available in the Jetbrains repository, which can help with the multiple Gradle setups present.
Project Structure
Once you've set up the base project, you will see three primary directories:
androidApp
iosApp
shared
androidApp
and shared
are modules because they are related to Android and built with build.gradle
. The iosApp
is the directory of the actual ios application, which you can open via Xcode.
The androidApp
module is just an entry point for the Android application. The code below is familiar to anyone who ever developed for Android.
iosApp
is the entry point for the iOS application with some boilerplate SwiftUI code:
As this is the entry point, you should implement the top-level changes here - for example we add the ignoresSafeArea
modifier to show the application in fullscreen:
The code above already lets you run your Android application on iOS. Here your ComposeUIViewController
is wrapped inside a UIKit UIViewController
and presented to the user. MainViewController()
is located in the Kotlin file main.ios.kt, and App()
contains the Composable application code.
Here’s another example from JetBrains.
In case you need some platform specific functionality, you can embed UIKit in your Compose code with UIKitView. Here is an example with a map view from Jetbrains. Using UIKit is very similar to using AndroidView inside Compose, in case you are already familiar with that concept.
The shared
module is the most important one of the three. Essentially, this Kotlin module contains shared logic for both Android and iOS implementations, facilitating the use of the same codebase across both platforms. Within the shared module, you'll find three directories, each serving its own purpose: commonMain
, androidMain
, and iosMain
. This a point of confusion - in fact, the code that is actually shared lies inside the commonMain
directory. The other two directories are for writing platform-specific Kotlin code that will either behave or look differently on Android or iOS. This is done by writing an expect fun
inside of the commonMain
code, and implementing it using actualFun
in the corresponding platform directory.
Migration
When starting the migration, we were sure we’d run into some issues that would require specific fixes. Even though the application we chose to migrate is very light on logic (it’s basically only UI, animations and transitions), we ran into quite a number of hurdles as expected. Here are some you might encounter yourself during the migration.
Resources
The first thing we had to deal with was the use of resources. There is no dynamically generated class R, which is specific to Android only. Instead you have to put a resource in the resources directory, and you need to specify the path as a string. Example for an image:
When implementing resources in this way, you might get a runtime crash, instead of a compile-time crash due to an incorrectly named resource.
Moreover, if you refer to Android resources in your xml files, you also need to get rid of links to the android platform:
Fonts
There is no way to use the standard font-loading techniques you would use on iOS and Android in Compose Multiplatform at the time of writing this article. As far as we could see, Jetbrains suggests loading fonts using byteArray
, as in the iOS code below:
However, we don’t like the asynchronous approach, or the fact that runBlocking
is used, which will block the main UI thread during execution. So on Android, we decided to use a more common approach with an integer identifier:
We can create a Fonts
object and use it when needed for convenience.
Replacing Java with Kotlin
It’s not possible to use Java in the code, because Compose Multiplatform uses a Kotlin compiler plugin. Therefore we need to rewrite the parts where Java code is used. For example, in our app a Time
formatter converts the music track time in seconds to a more convenient format in minutes. We had to give up using java.util.concurrent.TimeUnit
, but it turned out good, because it gave us a chance to refactor the code and write it more elegantly.
Native Canvas
Sometimes, we use the Android Native canvas to create drawings. However, in Compose Multiplatform, we don't have access to the Android native canvas in common code, and the code has to be adapted accordingly. For instance, we had an animated title text that was relying on the measureText(letter)
function from the native canvas to enable letter-by-letter animation. We had to find an alternative approach for this functionality, so we rewrote it with a Compose canvas, and used TextMeasurer
instead of Paint.measureText(letter)
The drawText
method also relied on native canvas and had to be rewritten:
Gestures
On Android, the BackHandler
is always available - it handles back gestures or back button presses depending on the navigation mode that is available for the device. But this approach won’t work with Compose Multiplatform, because BackHandler
is a part of the Android source set. Instead let’s use expect fun
:
There are many different approaches that can be proposed to achieve the desired result in iOS. For example, you can write your own back gesture in Compose, or if you have multiple screens in the app, you can wrap each in a separate UIViewController, and use native iOS navigation with UINavigationController that includes default gestures.
We chose an implementation that handles the backward gesture on the iOS side without wrapping separate screens in corresponding controllers (because the transitions between the views in our app are heavily customized). This is a good demonstration of how to link these two languages. First of all, we add a native iOS SwipeGestureViewController to detect gestures, and handlers for the gesture events. Full iOS implementation you can see here
After that we create a corresponding function in the main.ios.kt file:
We can call this function in Swift like this:
We implement a store that collects actions.
This store accumulates actions using store.events.collect
:
This helps bridge the difference in gesture handling on the two platforms, and make the iOS app feel native and intuitive for back navigation.
Minor bugs
In some cases, you may encounter minor issues such as these: on the iOS platform, an item scrolls up to become visible when tapped on. You can compare the expected behavior (Android) with the faulty iOS behavior below:
That happens becauseModifier.clickable
makes an item focused when it’s tapped, which in turn activates the "bringIntoView" scroll mechanism. Focus management is different on Android and iOS, which causes this different behavior. We fixed this by adding a .focusProperties { canFocus = false }
modifier to the item.
Finally
Compose Multiplatform is the next stage in the development of Multiplatform for the Kotlin language after KMM. This technology provides even more opportunities for code sharing - not only business logic, but UI components as well. Although there is a possibility to combine Compose and SwiftUI in your multiplatform application, at the moment it doesn't look very straightforward.
You should think about whether your application has business logic, UI or functional capabilities that could benefit from code sharing across multiple platforms. If your application requires a lot of platform-specific features, KMM and Compose Multiplatform may not be the best choice. The repo contains the full implementation. You also can check out the existing libraries to be more aware of current KMM capabilities.
As for us, we are impressed and think that Compose Multiplatform could be used in our actual projects, once a stable version is released. It's best suited to UI-heavy applications without a ton of hardware-specific features. It might be a viable alternative to both Flutter and native development, but time will tell. Meanwhile we'll continue focusing on native development - check out our iOS and Android articles!