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:

⚠️ 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.