Getting the native iOS look & feel in your Compose Multiplatform app
Jacob Ras on 2024-02-17
Getting the native iOS look & feel in your Compose Multiplatform app
Compose’ default look and feel is that of Material Design. In Compose Multiplatform, certain elements have been tweaked on iOS to feel more native. For example, since version 1.5 the scroll effect on iOS has been made to imitate that of the platform. However, most of the UI elements still look Material. Let’s take a look at an easy way to obtain more of the iOS native look & feel in your app.
We’re going to use a library called Compose Cupertino. It’s available in several flavours:
cupertino
: iOS-like widgets, built using Compose;cupertino-native
: wrappers around native UIKit components;cupertino-adaptive
: adaptive themes/wrappers that use Material Design on Android and the iOS-like widgets fromcupertino
and some widgets fromcupertino-native
on iOS (main focus of this article);cupertino-icons-extended
: more than 800 of the most used Apple SF Symbols (note: these are copyrighted and require adhering to a license agreement);cupertino-decompose
: native feel of screen transitions & swipe gestures.
⚠️ Warning: Composer Cupertino is in the experimental phase. All APIs can change in an incompatible way or even be dropped at any point in time. This tutorial was written for version
0.1.0-alpha03
.
How does it work?
The native looking widgets in Cupertino are completely rebuilt iOS components using Compose. What this means is that they’re not actual native components, but rather drawn to look like them. Should we be worried about that? I wouldn’t, because that’s similar to how Compose itself rebuilds Android components. Those are also being drawn on canvas instead of relying on legacy android.view
components.
Cupertino Adaptive is built not only on top of the Material components, but also with their API in mind. This means that many Material components you’re using now can be switched out for their Adaptive counterparts in seconds. They’ll still be calling the exact same underlying code on Android, but on iOS they’ll be drawn to look like native components. The exception to these are the adaptive widgets ending in *Native
, like AdaptiveAlertDialogNative
. That one calls the wrappers from Cupertino Native, which invoke the actual UIKit component for a dialog.
Let’s see it in action! All code is available at https://github.com/jacobras/ComposeCupertinoSample.
Tutorial: Material to Cupertino Adaptive
The sample project we’ll be using has been created with the Kotlin Multiplatform Wizard. I added a scaffold with a toolbar, two tabs, a loading indicator and a dialog, all Material3 components. You can view the starting point codebase here: ComposeCupertinoSample/tree/starting-point.
1: Adding the dependency
We add the dependency to our version catalog and implement it in the app:
// in gradle/libs.versions.toml: cupertino = { module = "io.github.alexzhirkevich:cupertino-adaptive", version = "0.1.0-alpha03" } // in composeApp/build.gradle.kts, inside common.dependencies: implementation(libs.cupertino)
Full commit: ComposeCupertinoSample/pull/2/commits/d7b05ad809bc03cf87c3c58a6f7765f5c6442b92
2: Updating the theme
The AppTheme currently uses MaterialTheme. We need to change that to use the adaptive theme. It has two important parameters: material
, which takes our current MaterialTheme, and cupertino
, which takes a CupertinoTheme
. That one allows customising our iOS look by passing custom colours to darkColorScheme()
or lightColorScheme()
.
// Before @Composable fun AppTheme( useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit ) { MaterialTheme( colorScheme = if (useDarkTheme) { darkColorScheme() } else { lightColorScheme() }, content = content ) } // After @OptIn(ExperimentalAdaptiveApi::class) @Composable fun AppTheme( useDarkTheme: Boolean = isSystemInDarkTheme(), theme: Theme = determineTheme(), content: @Composable () -> Unit ) { AdaptiveTheme( material = { MaterialTheme( colorScheme = if (useDarkTheme) { androidx.compose.material3.darkColorScheme() } else { androidx.compose.material3.lightColorScheme() }, content = it ) }, cupertino = { CupertinoTheme( colorScheme = if (useDarkTheme) { darkColorScheme() } else { lightColorScheme() }, content = it ) }, target = theme, content = content ) }
The method determineTheme()
is an expect/actual function that returns Theme.Material
from [androidMain] and Theme.Cupertino
from [iosMain]. See the full commit for details: ComposeCupertinoSample/pull/2/commits/592b3e2a1d35ff8a9961dbc6739e0e25bf581b95
From the next version of Cupertino the
Theme
will be determined automatically, making the expect/actual function above redundant.
If we run the app now, nothing changes yet. Everything looks exactly like before, because we haven’t used any adaptive components yet. This demonstrates that on Android, everything will remain the same.
3: Using adaptive components
Now comes the fun part! This is also the easiest change. We locate all material components and replace them with the adaptive wrappers. An example:
// Before Button(onClick = { showContent = !showContent }) { Text("Click me!") } // After AdaptiveButton(onClick = { showContent = !showContent }) { Text("Click me!") }
We’re going to change these other components as well:
The pattern should be clear: Cupertino
[ComponentName]
for the iOS style components and Adaptive
[ComponentName]
for the ones that switch based on the platform. For this tutorial, we’ll use all the adaptive ones.
Most of these just require changing the name without changing the parameters. The AlertDialog
is an exception, which requires changing text
to title
and confirmButton
to buttons
.
Full commit: ComposeCupertinoSample/pull/2/commits/a8da43dd7db1187df15c0fbbca9af3ef705c64bd
If we now run the app again on iOS, we see the following (left simulator shows before, right simulator shows after the changes):
That looks amazing! Dark theme also works on both platforms:
Testing on Android
To test the Cupertino look on Android, all that’s required is changing the determineTheme()
method in the Android source set:
actual fun determineTheme(): Theme = Theme.Material3
Further steps & reading
I hope it’s clear how easy this was and how big the impact is. There’s more we can do: using adaptive icons (so we get the iOS ones on iPhone/iPad) or use more native-looking components, but that’s up to you. This has been just a short introduction to getting started with the library.