Skip to content

Getting the native iOS look & feel in your Compose Multiplatform app

You can reply to this article at https://medium.com/@jacobras/getting-the-native-ios-look-feel-in-your-compose-multiplatform-app-33371e6ad362.

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.

iOS simulator on top of Android emulator, showing native look & feel on each platform

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 from cupertino and some widgets from cupertino-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.

In this article, we’re going to see how easy it is to use the Adaptive flavour to improve our apps’ system bars (top bar + navigation/tab bar) and main components (buttons + loading indicators + dialogs). There’s a lot more in the library, so take this post as an introduction to it and not a complete guide. We’re also going to see how we can test the iOS look on Android!

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

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:

Material component
(Android look)
Cupertino component
(iOS look)
Adaptive component
(Android/iOS look)
Scaffold()CupertinoScaffoldAdaptiveScaffold
TopAppBar()CupertinoTopAppBarAdaptiveTopAppBar
NavigationBar()
NavigationBarItem()
CupertinoNavigationBar
CupertinoNavigationBarItem
AdaptiveNavigationBar
AdaptiveNavigationBarItem
Button()CupertinoButtonAdaptiveButton
CircularProgressIndicator()CupertinoCircularProgressIndicatorAdaptiveCircularProgressIndicator
AlertDialog()CupertinoAlertDialogAdaptiveAlertDialog

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):

iOS simulators next to each other, showing before and after of using Compose Cupertino

That looks amazing! Dark theme also works on both platforms:

Android and iOS emulators next to each other, showing native look & feel on each platform. This time in dark theme.

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.