Chapters

Hide chapters

Kotlin Multiplatform by Tutorials

Third Edition · Android 15, iOS 18, Desktop · Kotlin 2.1.20 · Android Studio Meerkat

C. Appendix C: Sharing Your Compose UI Across Multiple Platforms
Written by Carlos Mota

Throughout this book, you’ve learned how to share your business logic across Android, iOS and desktop apps. What if you could go a step further and also share your Compose UI?

That’s right — along with Kotlin Multiplatform, you now have Compose Multiplatform, which allows you to share your Compose UI with Android, iOS and desktop apps.

Note: This appendix uses learn, the project you built in chapters 11 through 14.

Setting Up an iOS App to Use Compose Multiplatform

To follow along with the code examples throughout this appendix, download the project and open 17-appendix-c-sharing-your-compose-ui-across-multiple-platforms/projects/starter with Android Studio.

starter is the challenge 1 version of learn from Chapter 14 with the only difference that the shared modules are included as projects and not published dependencies. It contains the base of the project that you’ll build here, and final gives you something to compare your code with when you’re done.

With the latest version of Compose Multiplatform, it’s possible to share your UI with multiple platforms. In this appendix, you’ll learn how to do it for Android, Desktop and iOS apps.

Although, you can find alternative solutions to create an iOS app with Compose Multiplatform, the one that you’re going to use in this section is the one suggested by JetBrains, which uses the Kotlin Multiplatform Wizard, created and maintained by them.

Start by opening the wizard and define:

  • Project Name: learn
  • Project ID: com.kodeco.learn

Confirm that you have the Android, iOS (with the share UI setting on), and Desktop targets selected, then click DOWNLOAD.

Extract the content from the .zip file. Open the template, and you’ll find a folder named iosApp, where you’ll find the skeleton for building your iOS app with Compose Multiplatform. Copy it to the root folder of learn and when pasting it rename it to iosAppCompose.

Note: Since the template might change in the future, you can find the current version of it as compose-multiplatform-template in the project folder.

Your project structure should now be similar to this one:

Fig. B.1 — Project view hierarchy
Fig. B.1 — Project view hierarchy

Open the iosApp.xcodeproj file located on iosAppCompose with Xcode. Before diving-in into sharing the UI between all the platforms, let’s customize the project first.

Open ContentView.swift. Here is the entry point for the (Compose) screen to be loaded. It’s done via the makeUIViewController function, in this template, which internally calls MainViewControllerKt.MainViewController(). You’ll create this implementation later in the chapter. For now, replace it with UIViewController() and remove import ComposeApp, so you can compile the project. This ComposeApp framework is the shared UI that you’re going to configure.

Open iosApp (the root folder) and go to BuildPhases. Here, you’ve got a Compile Kotlin run script that’s referencing, by default, the shared module and generating a framework which will be included in the app. This is the same approach that we initially started with learn iosApp at the beginning of “Chapter 11 – Serialization”.

Since, you haven’t created the shared UI module, comment the embedAndSignAppleFrameworkForXcode task for now:

#./gradlew :composeApp:embedAndSignAppleFrameworkForXcode

Compile the project. You should see an empty screen similar to this one:

Fig. B.2 — iOS App
Fig. B.2 — iOS App

Depending on the current version of Java that you have set as your JAVA_HOME you might see an error similar to the following:

‘compileJava’ task (current target is 17) and ‘compileKotlin’ task (current target is 18) jvm target compatibility should be set to the same Java version.

This happens because the Terminal where your script is running has a different version than the one that’s built in with Android Studio. You can change your JAVA_HOME to reflect the same directory, or you can just add the following before any instruction in the Compile Kotlin run script:

export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home/"

Make sure the path in the command above matches your version of Android Studion. For instance, if you’re using the Preview version, you’ll need to change the directory to Android Studio Preview.app.

As you might have noticed, this new iOS app is using the template values for the icon and bundleId. Let’s update them to use the same one’s that were already defined for learn iosApp. Open the Config.xcconfig file located inside the Configuration folder and replace the existing content with:

TEAM_ID=
BUNDLE_ID=com.kodeco.learn
APP_NAME=learn

If you now go to iosApp and click on the General section and look for the Bundle Identifier setting, which is under Identity, you’ll see that both the app name and bundle ID were updated to learn and com.kodeco.learn respectively.

Finally, open Assets and remove the existing AppIcon. Open Finder and navigate to iosApp/iosApp/Assets.xcassets and copy the existing AppIcon.appiconset folder to iosAppCompose/iosApp/Assets.xcassets. Return to Xcode, and you should now see the Kodeco logo in AppIcon.

To confirm that your iOS app is ready, compile the project. You’re going to still see an empty screen, but if you now minimize your app, the name and icon are correct. :]

Fig. B.3 — iOS App icon
Fig. B.3 — iOS App icon

Updating Your Project Structure

To share your UI, you’ll need to create a new Kotlin Multiplatform module. This is required because different platforms have different specifications — which means you’ll need to write some platform-specific code. This is similar to what you’ve done throughout this book.

Start by creating a new KMP library. You can easily do this by clicking the Android Studio status bar File, followed by New and New Module.

Then, select Kotlin Multiplatform Shared Module and set:

  • Module Name: shared-ui
  • Package Name: com.kodeco.learn.ui

Click Finish and wait for the project to synchronize.

As you can see, there’s a new shared-ui module in learn. Open the settings.gradle.kts file to confirm that it was added to your project.

Android Studio only has direct support for mobile targets. So, when you try to add a new module, and you’re targeting other platforms — like desktop apps — you’ll need to manually add these targets.

Open the shared-ui build.gradle.kts and add the jvm target inside the kotlin section, right after androidLibrary:

jvm()

This is required — otherwise, you would only generate the shared-ui library for Android.

Update the existing androidLibrary target, to retrieve the compileSdk and minSdk from your libs versions file - libs.versions.toml:

compileSdk = libs.versions.android.sdk.compile.get().toInt()
minSdk = libs.versions.android.sdk.min.get().toInt()

Since you won’t be using the androidDeviceTest and androidHostTest targets, you can safely remove those folders, along with the declarations of withHostTestBuilder and withDeviceTestBuilder from androidLibrary, as well as the androidDeviceTest call in the sourceSets section.

To keep the framework name consistent with the other shared modules, update xcfName to:

val xcfName = "shared-ui"

Synchronize the project.

With the configuration set, click on the shared-ui folder and then NewDirectory and select jvmMain/kotlin.

Look at the project structure. It should be similar to the one below:

Fig. B.4 — Project view hierarchy
Fig. B.4 — Project view hierarchy

When generating a KMP library, Android Studio also adds Platform.*.kt inside all targets, and an AndroidManifest.xml inside androidMain. You can remove these four files as you won’t use them in this appendix.

Sharing Your UI Code

Although the code of both platforms is quite similar, the Android app uses platform-specific libraries. Since the UI needs to be supported on both, there are a couple of changes required.

Typically, the most common scenario is that you have an Android app built with Compose that you want to port to the desktop, or to iOS. So, you’ll start by moving the UI from androidApp to shared-ui. In the end, you’ll remove the classes that are no longer needed from desktopApp.

Before you start, there are a couple of things to consider:

  • Android libraries that use the native SDK are platform-specific, so it won’t be possible to use them on desktop apps.
  • shared-ui follows the same principles of the shared module that you created before: the code needs to be written entirely in Kotlin — even its third-party libraries.

With that, it’s time to start your journey. :]

Migrating Your Android UI Code to Multiplatform

Start by moving all the directories inside androidApp/ui into shared-ui/commonMain/ui. Don’t move the MainActivity.kt file, since activities are Android-specific.

Note: Depending on the current view that you have selected for the project structure window on the left, you might not be able to move files directly to the right folder. To change this, select the window mode Project Files.

When prompted about how the move should be done, select “Refactor”.

Android Studio will open another window enumerating a couple of issues that were found during this process. They’re related to resources and libraries that need to be added to shared-ui. For now, don’t worry about this. Click Refactor Anyway.

After this operation ends, move the components directory into shared-ui/commonMain. It should be at the same level as the ui folder. When prompted about possible problems that were detected, click once again in Refactor followed by Refactor Anyway.

You’ve got a Utils.kt file located inside utils folder that cannot directly be moved to commonMain because it’s using platform-specific code. In this case, it’s using Java libraries that won’t be available for iOS. You need to migrate this logic to Multiplatform.

Start by creating a utils folder in com.kodeco.learn for each one of the directories: androidMain, commonMain, and iosMain. For jvmMain, since you’ve manually added this target, you have to add the namespace first. You can easily do this by right-click on jvmMain/kotlin folder and select NewPackage and add:

com.kodeco.learn.utils

With the folder structure set, go to commonMain/utils, create a Utils.common.kt file and add:

package com.kodeco.learn.utils

public const val TIME_FORMAT: String = "yyyy/MM/dd"

expect fun converterIso8601ToReadableDate(date: String): String

Now that the expect function is declared, you need to create actual for each one of the targets. Starting with androidMain create the Utils.android.kt file and add the following code:

package com.kodeco.learn.utils

private const val TAG = "Utils"

@SuppressLint("ConstantLocale")
private val simpleDateFormat = SimpleDateFormat(TIME_FORMAT, Locale.getDefault())

actual fun converterIso8601ToReadableDate(date: String): String {
  return try {
    val instant = Instant.parse(date)
    val millis = Date(instant.toEpochMilliseconds())
    return simpleDateFormat.format(millis)
  } catch (e: Exception) {
    Logger.w(TAG, "Error while converting dates. Error: $e")
    "-"
  }
}

This code is similar to the one in Utils.kt from androidApp. You might have noticed that kotlinx.datetime and Logger imports aren’t resolved, that’s because both libraries haven’t been imported in this new module. Open build.gradle.kts file from shared-ui and in the dependencies section of commonMain add:

api(project(":shared-logger"))

implementation(libs.kotlinx.datetime)

Click on Sync Now to add these libraries to the project.

Go back to Utils.android.kt and add the needed imports.

You can now remove Utils.kt from androidApp.

Go over to jvmMain and create the Utils.desktop.kt file inside the utils directory. Copy-paste the code that you’ve previously added to Utils.android.kt. Since they’re both JVM targets, the only thing that you need to do here is to remove the @SuppressLint annotation along with its import and replace it with:

@Suppress("ConstantLocale")

Finally, go to iosMain and inside the utils folder create the Utils.ios.kt file and define its actual implementation:

package com.kodeco.learn.utils

actual fun converterIso8601ToReadableDate(date: String): String {
  val dateFormatter = NSDateFormatter()
  dateFormatter.dateFormat = TIME_FORMAT

  val nsdate = NSISO8601DateFormatter().dateFromString(date)
  return dateFormatter.stringFromDate(nsdate ?: NSDate())
}

Add the following imports:

import platform.Foundation.NSDate
import platform.Foundation.NSDateFormatter
import platform.Foundation.NSISO8601DateFormatter

Looking at the androidApp source folder, there are only two classes: MainActivity and KodecoApplication. All the other UI classes are now in shared-ui.

With your code moved to a different module, you need to import it to the androidApp. Otherwise, MainActivity won’t be able to resolve its imports.

Open build.gradle.kts from androidApp and in the dependencies section, below shared-action add:

implementation(project(":shared-ui"))

Synchronize and wait for this operation to finish.

You still have to migrate the resources files. However, to share the code through all the platforms, you’ll need to use a new library named moko-resources and make additional changes. The “Handling Resources” section in this chapter describes all the steps required.

Compose Multiplatform

Jetpack Compose was initially introduced for Android as the new UI toolkit where one could finally leave the XML declarations and the findViewById calls behind and shift towards a new paradigm – declarative UI.

Note: You can learn more about Jetpack Compose for Android in Chapter 3, Developing UI, and by reading the Jetpack Compose by Tutorials from Kodeco.

If you look at the official documentation for Jetpack Compose, you can see that, at the time of writing, it’s composed of seven libraries:

  • compose.animation: Animations that you can easily use.
  • compose.material: The material design system to use on components.
  • compose.material3: The newest version of material design.
  • compose.foundation: Contains the basic building Composables — Column, Text, Image, and so on.
  • compose.ui: Handles input management, drawing, and layouts.
  • compose.runtime: It’s platform-agnostic, which means that it doesn’t know what Android or UI are. It can be seen as a tree-management solution.
  • compose.compiler: Transforms the @Composable into UI.

They can be structured into the following high-level diagram:

Compose Animation Compose Material 3 Compose Material Compose Foundation Compose UI Compose Compiler Compose Runtime Compose UI Toolkit (Android)
Fig. B.5 — Jetpack Compose high-level diagram

In this image, you can see that Jetpack Compose can be spliced into the:

  • Compose UI Toolkit, which is platform-specific.
  • Compose Plugins, which contains the Compose runtime and compiler.

By changing the Compose UI Toolkit, you can use Compose on other platforms.

With Compose Multiplatform, JetBrains provides this exact support. It allows using Compose for desktop, iOS, and the web. The desktop app that you’ve been building throughout the book was built with this framework. In this chapter, you’re going to share the same UI code across all the platforms, so the code from Android that you’ve moved to shared-ui needs to be migrated to Compose Multiplatform.

It’s worth mentioning that to keep everything stable, the org.jetbrains.compose plugin replaces the androidx.compose.* artifacts with the ones from JetBrains. This is a temporary solution to deal with these different versions.

Migrating to Compose Multiplatform

Open the BookmarkContent.kt file from shared-ui. Here you’ll see that the imports to androidx.compose* are not being resolved.

You need to add the Compose Multiplatform plugin and its libraries to solve this. Open the build.gradle.kts file from shared-ui. In the plugins section, before libs.plugins.android.kotlin.multiplatform.library, add:

alias(libs.plugins.jetbrains.compose)
alias(libs.plugins.jetbrains.compose.compiler)

Now add the Compose libraries the project is using. Scroll down to sourceSets, and inside commonMain dependencies section add:

api(compose.foundation)
api(compose.material)
api(compose.material3)
api(compose.runtime)
api(compose.ui)

Synchronize the project and navigate back to BookmarkContent.kt file.

With none of the Compose imports marked red, it means the project can now resolve its Compose dependencies.

Updating Your Shared UI Dependencies

Now that shared-ui contains your app UI, it’s time to add the missing libraries. Open the build.gradle.kts file from this module and look for commonMain/dependencies. Update it to include:

api(project(":shared"))

When prompted, click to synchronize the project, so it connects to both libraries.

Migrating Your UI Components

Not all classes in an Android Compose project are available in Compose Multiplatform. Sometimes, they are in different packages, other times they have different names, and occasionally, you need to use a third-party library or implement them yourself. In HorizontalPagerIndicator.kt file, you’ll encounter all three scenarios.

Look at the imports section, you’ll notice that some imports are unresolved:

import androidx.compose.material.ContentAlpha
import androidx.compose.material.LocalContentAlpha
import androidx.compose.material.LocalContentColor

Remove them. Now, import the correct package for LocalContentColor:

import androidx.compose.material3.LocalContentColor

Update the default parameters from both HorizontalPagerIndicator functions to:

activeColor: Color = LocalContentColor.current.copy(alpha = LocalContentColor.current.alpha),
inactiveColor: Color = activeColor.copy(DisabledAlpha),

Finally, add the DisabledAlpha constant before the composables declaration:

private const val DisabledAlpha = 0.38f

Using Third-Party Libraries

Although Compose Multiplatform is taking its first steps, the community is following closely, releasing libraries that help make the bridge between Android and desktop apps.

Fetching Images

There are currently two libraries commonly used to fetch images in Compose Multiplatform:

  • Coil: which you’re using in the Android app.
  • Compose ImageLoader: one of the first libraries to fully support multiple targets.

In previous editions of the book you were using Compose ImageLoader in this chapter. At the time it was one of the few libraries that fully supported Compose Multiplatform. Moreover, it was stable, had the feature set needed to develop your apps, and was backed by a great community. However, over the past year Coil has gained the same support. Since it is one of the most widely used libraries for Android, and to minimize changes in the project, you will now use it for Desktop and iOS as well.

The project is currently using Coil 2.x. To have full Compose Multiplatform support you need to update to Coil 3.x.

Open the libs.versions.toml file located inside the gradle folder, and look for the [versions] section. Here, update Coil to:

coil = "3.1.0"

For version 3, you the migration process includes changing the package name. Replace image-coil = { module = "io.coil-kt:coil-compose", version.ref = "coil" } with:

image-coil = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }`

If you need to make network requests, Coil3 requires an additional network library for Compose Multiplatform. In the libraries section, after image-coil, add:

image-coil-network = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }

Now, go to the shared-ui folder and open the build.gradle.kts file. In the commonMain/dependencies section, add:

api(libs.image.coil)
api(libs.image.coil.network)

Similar to what you’ve done in “Chapter 12 - Network”, you’ll need to add the Ktor client to each target:

androidMain {
  dependencies {
    implementation(libs.ktor.client.android)
  }
}

jvmMain {
  dependencies {
    implementation(libs.ktor.client.jvm)
  }
}

iosMain {
  dependencies {
    implementation(libs.ktor.client.ios)
  }
}

To configure the Ktor client for JVM, return to libs.versions.toml and add the following entry in the libraries section:

ktor-client-jvm = { module = "io.ktor:ktor-client-java", version.ref = "ktor" }

Synchronize the project.

In the shared-ui/components directory, open ImagePreview.kt. This file contains the logic required to fetch an image from the network and handles the request state: success, loading, and error.

Replace, the current coil import with the one from version 3.x:

import coil3.compose.rememberAsyncImagePainter

To confirm that the library was successfully imported, the import for should be resolved.

For now, you won’t be able to resolve both painterResource, stringResource and the R class. You’re going to see in the “Handling Resources” section how to address this.

Using LiveData and ViewModels

learn was built using LiveData and ViewModels that are available in Android through the runtime-livedata library. Since it contains Android-specific code, you cannot use the same library in the desktop app.

Fortunately, Google has started migrating several Android libraries to Compose Multiplatform, including ViewModels.

You’re already using it in the desktopApp, so you only need to add the library to your new module.

Open the build.gradle.kts file from the shared-ui module, and in commonMain/dependencies, add:

api(libs.jetbrains.compose.lifecycle)

Which corresponds to org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose library.

Synchronize your project. Once the operation ends, open the BookmarkViewModel.kt file from the shared-ui module. You’ll see that all viewModel* imports are resolved.

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope

Compose Multiplatform does not have built-in support for LiveData or MutableLiveData. Instead, you will need to use StateFlow or MutableStateFlow from the Kotlin Coroutines library.

To migrate, update the _items and items declarations to:

private val _items = MutableStateFlow<List<KodecoEntry>>(emptyList())
val items: StateFlow<List<KodecoEntry>> = _items.asStateFlow()

Add the following imports:

import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

And remove the now unnecessary::

import androidx.lifecycle.MutableLiveData

Open FeedViewModel.kt. You’ll have to make similar changes.

Update the _profile and profile declarations with:

private val _profile = MutableStateFlow<GravatarEntry>(GravatarEntry())
val profile: StateFlow<GravatarEntry> = _profile.asStateFlow()

Replace the import:

import androidx.lifecycle.MutableLiveData

With:

import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

With both view models updated, navigate to the androidApp and open the MainActivity.kt file. Here, look for their calls and update them to:

val profile = feedViewModel.profile.collectAsState()
val bookmarks = bookmarkViewModel.items.collectAsState()

Instead of using observeAsState since you’re now using flows you’ll need to call collectAsState.

Don’t forget to delete the now-unnecessary imports:

import androidx.compose.runtime.livedata.observeAsState

You’ll need to make the same update in the Main.kt file from desktopApp.

These changes on desktop require an additional step. Otherwise, when you run the app you’ll see an error similar to:

java.lang.IllegalStateException: Module with the Main dispatcher is missing. Add dependency providing the Main dispatcher, e.g. 'kotlinx-coroutines-android' and ensure it has the same version as 'kotlinx-coroutines-core'

To resolve this, follow the suggestion in the error message and add the kotlinx-coroutines-swing library to the jvmTarget.

First, open the libs.version.toml file and in the versions section, add:

kotlinx-coroutines = "1.10.1"

Scroll down to libraries section and add:

kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }

Finally, open the shared-ui/build.gradle.kts file and add the jvmMain target in the sourceSets, including the newly configured library:

implementation(libs.kotlinx.coroutines.swing)

Synchronize your project.

Handling Navigation

There are multiple libraries that handle navigation between different screens. In previous editions of this book, you used precompose.

With NavigationSuite from Material3 library in Compose Multiplatform, you can now use this composable directly.

Open the shared-ui/build.gradle.kts file, and in commonMain/dependencies add:

api(compose.material3AdaptiveNavigationSuite)

Synchronize the project. You’ll need to make a few changes here.

Open the MainScreen.kt file inside androidApp, and replace the existing Scaffold with the new one from this library:

//1
NavigationSuiteScaffold(
  //2
  navigationSuiteItems = {
    bottomNavigationItems.forEach { screen ->
      item(
        icon = {
          screen.icon()
        },
        label = {
          Text(
            text = stringResource(screen.title)
          )
        },
        selected = it == currentDestination.value,
        onClick = { currentDestination.value = screen }
      )
    }
  },
  content = {
    //3
    Column(
      modifier = Modifier.padding(it)
    ) {
      MainTopAppBar(
        profile = profile
      )

      MainContent(
        destination = currentDestination.value,
        coroutineScope = coroutineScope,
        bottomSheetScaffoldState = bottomSheetScaffoldState,
        selected = selected,
        feeds = feeds,
        bookmarks = bookmarks,
        onOpenEntry = onOpenEntry
      )
    }
  }
)

Here’s what’s happening:

  1. The NavigationSuiteScaffold adapts your app’s navigation based on screen size. If the device’s width or height is compact, it displays a navigation bar; otherwise, it shows a navigation rail.
  2. These items represent the screens you want to show. This code block defines the tab icon, text, and whether it’s selected. The logic is similar to NavigationBarItem, which you used for BottomAppBar in MainBottomBar.kt. Since they are now defined here, you can safely delete that file.
  3. The previous Scaffold is now the content of NavigationSuiteScaffold.

Previously, you used rememberNavController to save the current selected screen. However, since it’s Android-only, you need to migrate this logic to a common one. Replace these two instrunctions with:

val currentDestination = remember {
  mutableStateOf<BottomNavigationScreens>(BottomNavigationScreens.Home)
}

Remove the unused imports:

import androidx.compose.material3.Scaffold
import androidx.navigation.compose.rememberNavController

Now you need to update MainContent to receive the currentDestination instead. Open MainContent.kt and replace the navController parameter with:

destination: BottomNavigationScreens,

Do the same for MainScreeNavigationConfigurations and update its call in MainContent:

Column {
  MainScreenNavigationConfigurations(
    destination = destination,
    coroutineScope = coroutineScope,
    bottomSheetScaffoldState = bottomSheetScaffoldState,
    selected = selected,
    feeds = feeds,
    bookmarks = bookmarks,
    onOpenEntry = onOpenEntry
  )
}

Scroll down to MainScreenNavigationConfigurations again. Since NavHost isn’t supported in Compose Multiplatform, use the destination parameter you added as the condition to determine which screen to display. Replace the function’s content with:

when(destination) {
  BottomNavigationScreens.Home -> {
    HomeContent(
      selected = selected,
      items = feeds,
      coroutineScope = coroutineScope,
      bottomSheetScaffoldState = bottomSheetScaffoldState,
      onOpenEntry = onOpenEntry
    )
  }
  BottomNavigationScreens.Bookmark -> {
    BookmarkContent(
      selected = selected,
      items = bookmarks,
      coroutineScope = coroutineScope,
      bottomSheetScaffoldState = bottomSheetScaffoldState,
      onOpenEntry = onOpenEntry
    )
  }
  BottomNavigationScreens.Latest -> {
    LatestContent(
      items = feeds,
      onOpenEntry = onOpenEntry
    )
  }
  BottomNavigationScreens.Search -> {
    SearchContent(
      selected = selected,
      items = feeds,
      coroutineScope = coroutineScope,
      bottomSheetScaffoldState = bottomSheetScaffoldState,
      onOpenEntry = onOpenEntry
    )
  }
}

Finally, remove the androidx.navigation.* imports and the DEFAULT_SCREEN constant, as they are no longer used.

Handling Resources

All platforms handle resources quite differently. Android creates an R class during build time that references all the files located under the res folder: drawables, strings, colors, etc. Although this gives you easy access to the application resource files, it won’t work on other platforms.

There are currently two libraries you can use to handle resources:

  • resources: developed by JetBrains, it’s currently in an experimental state and, for now, it doesn’t support string sharing.
  • moko-resources: developed by IceRock Development, supports sharing strings, images, and fonts across multiple platforms: JVM, Native, and JS.

In this section you’re going to use moko-resources since sharing strings is one of the features that you will need to share your UI across all the targets. It’s also worth mentioning, that this library has been available for some time now and being used in several projects, which indirectly makes it more stable than resources at this time.

Note: By default, you can’t use both libraries at the same time. resources currently doesn’t run if it detects that your project has the moko plugin added.

Configuring moko-resources

Start by opening libs.versions.toml file, located inside the gradle folder. Inside the [versions] section add the latest moko-resources version:

moko-resources = "0.24.5"

Scroll down to [libraries] group and add both libraries:

moko-resources = { module = "dev.icerock.moko:resources", version.ref = "moko-resources" }
moko-resources-compose = { module = "dev.icerock.moko:resources-compose", version.ref = "moko-resources" }

The second library is to use moko with Compose.

Finally, go to the [plugins] set and add:

moko-multiplatform-resources = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "moko-resources" }

Now that both libraries and plugins are defined, it’s time to include them in the project. Open the build.gradle.kts file located in the root directory. In the plugins section, at the end of the list, add:

alias(libs.plugins.moko.multiplatform.resources) apply false

This will add the multiplatform-resources to the project. Now, open the build.gradle.kts file, but this time the one from shared-ui and add its plugin:

alias(libs.plugins.moko.multiplatform.resources)

With this, you need to set the app package name for moko-resources to use. After the plugins declaration, add:

multiplatformResources {
  resourcesPackage.set("com.kodeco.learn.ui")
}

Now you need to add the libraries to the commonMain/dependencies section:

api(libs.moko.resources)
api(libs.moko.resources.compose)

Click Sync Now and wait for the project to load these new libraries.

Loading Local Images

You’ll write the logic to load local images in Kotlin Multiplatform. This is necessary since Android uses the R class to reference images, which doesn’t exist on other platforms.

It’s also worth mentioning that all platforms can use different formats for images. Although Android and desktop supports vector drawables, it’s currently not available for iOS using moko-resources.

Nevertheless, you can use PNGs, JPGs, or SVGs on all platforms. With this in mind, and that SVGs are vector-based images, which means that they can be resized without losing quality, you’re going to use this format for sharing images.

Note: On Desktop, you use the Compose resources library for this. However, since this library doesn’t support SVG images on Android, you’ll use moko instead.

Open shared-ui/commonMain and start by creating a new resources folder. You can easily create it by right-clicking on this folder and selecting NewDirectory and name it moko-resources. Repeat the process, but this time click on resources and enter images.

All the resources that you’re going to share across multiple platforms need to be located in it.

The SVG files that you will use are located in the assets folder in this chapter’s materials. Copy-paste the six files into MR/images, and remove the correspondent .xml files from androidApp/res/drawable, which won’t be needed anymore.

moko-resources doesn’t recognize androidLibrary as a valid Android target, so you need to replace it with androidTarget. Open the build.gradle.kts file in shared-ui and make this change:

androidTarget {
  compilations.all {
    compileTaskProvider.configure {
      compilerOptions {
        jvmTarget.set(JvmTarget.JVM_17)
      }
    }
  }
}

Now, in the plugins section replace alias(libs.plugins.android.kotlin.multiplatform.library) with:

alias(libs.plugins.android.library)

Finally, add the target configuration at the end of the file:

android {
  namespace = "com.kodeco.learn.ui"

  compileSdk = libs.versions.android.sdk.compile.get().toInt()

  defaultConfig {
    minSdk = libs.versions.android.sdk.min.get().toInt()
  }

  compileOptions {
    sourceCompatibility = JavaVersion.VERSION_17
    targetCompatibility = JavaVersion.VERSION_17
  }
}

With all the resources and configuration set, you’ll need to make quite a few updates to replace the current calls to the R class with this new implementation.

Since moko-resources is going to generate an MR class, available for all platforms, similar to R with the reference to all the resources on shared-ui, before making any update, you need to first build the project. For that, go to BuildCompile ‘learn.shared-ui.commonMain’ and wait for this operation to end.

Starting alphabetically, you’ll need to make the following changes in the commonMain files:

common/EntryContent

In the AddEntryContent Composable, start by changing the import of painterResource. Instead of using androidx.compose you have to use the function from moko.resources.compose:

import dev.icerock.moko.resources.compose.painterResource

Now, remember the R class is Android-specific, so you’ll use the MR generated by moko instead:

val resource = painterResource(MR.images.ic_more)

And import:

import com.kodeco.learn.ui.MR

You can now remove the other imports:

import com.kodeco.learn.R
import androidx.compose.ui.res.painterResource

components/ImagePreview

Both in the AddImagePreview and AddImagePreviewEmpty Composables, replace the call to R.drawable.ic_brand with:

val resource = painterResource(MR.images.ic_brand)

Add the import to painterResource from moko.compose and the MR class:

import com.kodeco.learn.ui.MR
import dev.icerock.moko.resources.compose.painterResource

And remove the imports:

import androidx.compose.ui.res.painterResource
import com.kodeco.learn.R

main/BottomNavigationScreens

Similar as before, start by importing the painterResource function and the MR class:

import com.kodeco.learn.ui.MR
import dev.icerock.moko.resources.compose.painterResource

And remove the R class and painterResource from androidx.compose:

import androidx.compose.ui.res.painterResource
import com.kodeco.learn.R

Now, look for the data objects that are created in this class and replace the R.drawable.* with the equivalent reference from MR.images.*:

  • Home :
painter = painterResource(MR.images.ic_home),
  • Bookmark:
painter = painterResource(MR.images.ic_bookmarks),
  • Latest:
painter = painterResource(MR.images.ic_latest),
  • Search:
painter = painterResource(MR.images.ic_search),

There are still a couple of errors here that are related to the app strings. You’ll see how to update this logic in detail in the “Sharing Strings” section of this appendix.

search/SearchContent

In the AddSearchField Composable, replace the painterResource call in leadingIcon with:

val resource = painterResource(MR.images.ic_search)

Import the corresponding classes:

import com.kodeco.learn.ui.MR
import dev.icerock.moko.resources.compose.painterResource

And remove the unnecessary imports:

import androidx.compose.ui.res.painterResource
import com.kodeco.learn.R

All done! A couple more sections to go, and you’ll have your app’s UI completely shared.

Using Custom Fonts

The font that the three apps use is OpenSans. Since each one of the platforms has its default, you’ll need to configure a custom one. You’ll use, once again, moko-resources to load the new font.

Start by creating the fonts folder inside shared-ui/commonMain/moko-resources and move the files from androidApp/resources/font there. To use a font with moko it needs to follow a specific naming:

<fontFamily>-<fontStyle>

So you’ll have to rename all the OpenSans fonts to obey this rule:

OpenSans-Bold.ttf
OpenSans-ExtraBold.ttf
OpenSans-Light.ttf
OpenSans-Regular.ttf
OpenSans-SemiBold.ttf

To update the generated MR file, go to BuildCompile ‘learn.shared-ui.commonMain’. Once this operation ends, you can go to shared-ui/build/generated/moko-resources/commonMain/../MR and search for fonts. Here, you’ve got the five different types that you’ve just added to the project.

You can access any of these fonts via:

fontFamilyResource(MR.fonts.opensans_regular)

Or:

MR.fonts.opensans_regular.asFont()

But implementations need to be called from Composable functions. Therefore, you’ll have to use these fonts directly from the Typography property that’s on Type.kt file.

Before updating all the Text Composable’s with these new typographies, you’ll need to remove the references to the R class from Type.kt. Open this file and remove:

import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import com.kodeco.learn.android.R

private val OpenSansFontFamily = FontFamily(
  Font(R.font.opensans_bold, FontWeight.Bold),
  Font(R.font.opensans_extrabold, FontWeight.ExtraBold),
  Font(R.font.opensans_light, FontWeight.Light),
  Font(R.font.opensans_regular, FontWeight.Normal),
  Font(R.font.opensans_semibold, FontWeight.SemiBold),
)

Now that there’s no more OpenSansFontFamily, you must remove this call from all the fontFamily properties. Afterward, you need to manually update all the Text styles, since it’s not possible to reference the Fonts that you’ve created above from Typography.

When prompted, import:

import com.kodeco.learn.ui.MR
import dev.icerock.moko.resources.compose.fontFamilyResource

Starting alphabetically on commonMain/ui, navigate to:

  • common/EmptyContent: On the Text declaration, set the fontFamily argument to:
fontFamily = fontFamilyResource(MR.fonts.opensans_regular)
  • common/EntryContent: On the AddEntryContent Composable, look for four Text usages and add:
fontFamily = fontFamilyResource(MR.fonts.opensans_regular)
  • home/HomeContent: Scroll down to the end of this file, and on Text add:
fontFamily = fontFamilyResource(MR.fonts.opensans_regular)
  • home/HomeSheetContent: Search for the two Text calls and add:
fontFamily = fontFamilyResource(MR.fonts.opensans_regular)
  • latest/LatestContent: Set the fontFamily on the Text declarations on AddNewPage and AddNewPageEntry:
fontFamily = fontFamilyResource(MR.fonts.opensans_regular)
  • main/MainScreen: When defining the navigationSuiteItems, on Text add:
fontFamily = fontFamilyResource(MR.fonts.opensans_regular)
  • main/MainTopAppBar: Update the Text to contain the fontFamily argument:
fontFamily = fontFamilyResource(MR.fonts.opensans_regular)
  • search/SearchContent: Finally, when defining the placeholder set the fontFamily in Text:
fontFamily = fontFamilyResource(MR.fonts.opensans_regular)

Sharing Strings

Once again, you’re going to use the moko-resouces library to share strings across all platforms.

The desktop app is currently using Compose Resources for this. However, since moko-resources offers the same support and you’re already using it for images and fonts, you’ll also use it for strings.

You’ll reuse the Android strings.xml file as the shared strings across both platforms.

In order for moko-resources to work, the string files need to be in a specific path: commonMain/moko-resources/base. Create the base directory and move strings.xml from androidApp/res to this new location.

Note: If your app supports internationalization, you should create a folder inside MR with the language country code, then move the corresponding strings.xml file to that location.

Build the project. moko-resources will generate a couple of Multiplatform files (Android, desktop, iOS, and common) that contain the strings your app will use. You can find them at shared-ui/build/generated/moko-resources/

The changes needed for strings is similar to the one that you’ve done previously for images. You need to go through all the classes and update the references from R to MR class, and use the stringResource function from moko.

Starting alphabetically on commonMain/ui, navigate to:

  • bookmark/BookmarkContent: On BookmarkContent Composable, update the stringResource call to:
text = stringResource(MR.strings.empty_screen_bookmarks)

Add the imports to:

import com.kodeco.learn.ui.MR
import dev.icerock.moko.resources.compose.stringResource

And remove the previous ones:

import androidx.compose.ui.res.stringResource
import com.kodeco.learn.android.R
  • common/EntryContent.kt: Locate all the calls to R class, and, orderly, update them to use the equivalent MR reference. Starting with R.string.app_kodeco. Update to:
text = stringResource(MR.strings.app_kodeco),

And import:

import com.kodeco.learn.ui.MR
import dev.icerock.moko.resources.compose.stringResource

Finally, change the access to description_more to:

val description = stringResource(MR.strings.description_more)

And remove the import:

import androidx.compose.ui.res.stringResource
  • components/ImagePreview.kt: You only need to make one change. Scroll down to AddImagePreviewEmpty and update the description property that accesses the R class to:
val description = stringResource(MR.strings.description_preview_error)

Import stringResource from moko:

import dev.icerock.moko.resources.compose.stringResource

And remove the now-unused import:

import androidx.compose.ui.res.stringResource
  • home/HomeSheetContent.kt: Look for the accesses to the R class. The first one is the result of an if condition used to decide which text should be displayed. Replace this code block with:
val text = if (item.value.bookmarked) {
  stringResource(MR.strings.action_remove_bookmarks)
} else {
  stringResource(MR.strings.action_add_bookmarks)
}

And, as usual, import:

import com.kodeco.learn.ui.MR
import dev.icerock.moko.resources.compose.stringResource

At the end of the file, there’s another reference to R. Replace this call with:

text = stringResource(MR.strings.action_share_link),

And remove the imports of:

import androidx.compose.ui.res.stringResource
import com.kodeco.learn.android.R
  • latest/LatestContent.kt: On LatestContent Composable, update the strings call to:
AddEmptyScreen(stringResource(MR.strings.empty_screen_loading))

Add the imports:

import com.kodeco.learn.ui.MR
import dev.icerock.moko.resources.compose.stringResource

And, as always, remove the unnecessary ones:

import androidx.compose.ui.res.stringResource
import com.kodeco.learn.android.R
  • main/BottomNavigationScreens.kt: @StringRes is a string reference specific to the Android platform. Since you’re sharing this class with a desktop, and an iOS app, you need to update this parameter to a common type — which will be StringResource. Change title to:
val title: StringResource,

With that, you need to update all the objects declared in this class.

For the home object, update the stringResId and the contentDescription, respectively, to:

title = MR.strings.navigation_home,
contentDescription = stringResource(MR.strings.navigation_home)

And add the corresponding imports:

import com.kodeco.learn.ui.MR
import dev.icerock.moko.resources.StringResource
import dev.icerock.moko.resources.compose.stringResource

The same applies to the bookmark object:

title = MR.strings.navigation_bookmark,
contentDescription = stringResource(MR.strings.navigation_bookmark)

And to latest:

title = MR.strings.navigation_latest,
contentDescription = stringResource(MR.strings.navigation_latest)

Finally, for search:

title = MR.strings.navigation_search,
contentDescription = stringResource(MR.strings.navigation_search)

Remove the now-unnecessary imports:

import androidx.annotation.StringRes
import androidx.compose.ui.res.stringResource
  • main/MainScreen.kt: With the previous change, you have to update the bottomNavigationItems in the navigationSuiteItems. Replace the stringResource from androidx.compose to:
import dev.icerock.moko.resources.compose.stringResource

And remove its import:

import androidx.compose.ui.res.stringResource
  • main/MainTopAppBar.kt: Replace the stringResource call with:
text = stringResource(MR.strings.app_name),

And import:

import com.kodeco.learn.ui.MR
import dev.icerock.moko.resources.compose.stringResource

Now scroll down to where the Icon contentDescription is set, and update it to:

contentDescription = stringResource(MR.strings.description_profile)

Finally, remove the imports:

import androidx.compose.ui.res.stringResource
import com.kodeco.learn.android.R
  • search/SearchContent.kt: This is the last file that needs to be updated! Scroll down to AddSearchField and locate the two calls to stringResource. The first one is where you’re defining the placeholder and needs to be updated to:
text = stringResource(MR.strings.search_hint),

The second one is for leadingIcon, and you have to change the description to:

val description = stringResource(MR.strings.description_search)

Don’t forget to add the imports:

import com.kodeco.learn.ui.MR
import dev.icerock.moko.resources.compose.stringResource

And, as always, remove the ones you’re no longer using:

import androidx.compose.ui.res.stringResource

What’s Missing?

With all of these changes done, you’re almost done. Open the desktopApp project and:

  • Remove the ui, components, and utils folders.
  • Remove the composeResources folder.

The entry point of your desktop is the Main.kt file.

Now, open its build.gradle.kts and include the shared-ui dependency you’ve created throughout this appendix. To avoid having unnecessary implementations, you can replace all the libraries in this section with:

implementation(project(":shared-ui"))
implementation(project(":shared-action"))

implementation(compose.desktop.currentOs)

Do the same for androidApp. Open its build.gradle.kts and replace the dependencies section with:

implementation(project(":shared-ui"))
implementation(project(":shared-action"))

implementation(libs.android.material)
implementation(libs.androidx.navigation.compose)

The strings’ namespace changed to com.kodeco.learn.ui, you’ll need to make this update in MainActivity.kt. Open this file and replace, the existing import:

import com.kodeco.learn.R

With the new one:

import com.kodeco.learn.ui.R

There’s one more change that you need to do. Open Theme.kt in commonMain/../ui/theme. If you look at the KodecoTheme, you can see that there’s a set of operations that are going to update the status and navigation bars which are Android-specific.

Remove the following code block:

val view = LocalView.current
if (!view.isInEditMode) {
  SideEffect {
    val window = (view.context as Activity).window
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
      window.decorView.setOnApplyWindowInsetsListener { view, insets ->
        view.setBackgroundColor(colorScheme.surface.toArgb())
        insets
      }
    } else {
      window.statusBarColor = colorScheme.surface.toArgb()
    }
    WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
  }
}

And then also remove its imports:

import android.app.Activity
import android.os.Build
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat

Now return to MainActivity.kt in androidApp and update the KodecoTheme call to:

KodecoTheme {

  val view = LocalView.current
  if (!view.isInEditMode) {
    val color = colorScheme.surface.toArgb()
    val darkTheme = isSystemInDarkTheme()

    SideEffect {
      val window = (view.context as Activity).window
      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
        window.decorView.setOnApplyWindowInsetsListener { view, insets ->
          view.setBackgroundColor(color)
          insets
        }
      } else {
        window.statusBarColor = color
      }
      WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
  }
}

Add the needed imports. You can refer to the imports you removed in the previous step.

The strings’ namespace also changed on the desktop app. You’ll need to make a similar update in Main.kt. Open this file and replace, the existing import:

import com.kodeco.learn.resources.Res
import com.kodeco.learn.resources.app_name
import org.jetbrains.compose.resources.stringResource

With the new one:

import com.kodeco.learn.ui.MR
import dev.icerock.moko.resources.compose.stringResource

And update on Window to get the string from moko-resources:

title = stringResource(MR.strings.app_name)

Synchronize your project and — finally — compile and run your desktop and Android apps.

You’ll see screens like these:

Fig. B.6 — Feed in Android App
Fig. B.6 — Feed in Android App

Fig. B.7 — Feed in Desktop App
Fig. B.7 — Feed in Desktop App

Now, for iOS, you’ll need to make additional changes. Start by opening build.gradle.kts file from shared-ui and in the kotlin section, update the framework declaration to:

val xcfName = "SharedUIKit"

iosX64 {
  binaries.framework {
    baseName = xcfName
    linkerOpts.add("-lsqlite3")
  }
}

iosArm64 {
  binaries.framework {
    baseName = xcfName
    linkerOpts.add("-lsqlite3")
  }
}

iosSimulatorArm64 {
  binaries.framework {
    baseName = xcfName
    linkerOpts.add("-lsqlite3")
  }
}

This way the framework name follows Apple guidelines, and additionally you need to set this flag, which is required by SQLDelight.

With the configuration done, go to shared-ui/iosMain/../ui and create a Main.ios.kt file, and add the following code:

package com.kodeco.learn.ui

import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.Surface
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.window.ComposeUIViewController
import androidx.lifecycle.viewmodel.compose.viewModel
import com.kodeco.learn.data.model.KodecoEntry
import com.kodeco.learn.ui.bookmark.BookmarkViewModel
import com.kodeco.learn.ui.home.FeedViewModel
import com.kodeco.learn.ui.main.MainScreen
import com.kodeco.learn.ui.theme.KodecoTheme
import platform.Foundation.NSLog
import platform.Foundation.NSURL
import platform.UIKit.UIApplication

private lateinit var bookmarkViewModel: BookmarkViewModel
private lateinit var feedViewModel: FeedViewModel

fun MainViewController() = ComposeUIViewController {
  Surface(modifier = Modifier.fillMaxSize()) {

    bookmarkViewModel = viewModel {
      BookmarkViewModel()
    }

    feedViewModel = viewModel {
      FeedViewModel()
    }

    feedViewModel.fetchAllFeeds()
    feedViewModel.fetchMyGravatar()
    bookmarkViewModel.getBookmarks()

    val items = feedViewModel.items
    val profile = feedViewModel.profile.collectAsState()
    val bookmarks = bookmarkViewModel.items.collectAsState()

    KodecoTheme {
      MainScreen(
        profile = profile.value,
        feeds = items,
        bookmarks = bookmarks,
        onUpdateBookmark = { updateBookmark(it) },
        onShareAsLink = {},
        onOpenEntry = { openLink(it) }
      )
    }
  }
}

private fun updateBookmark(item: KodecoEntry) {
  if (item.bookmarked) {
    removedFromBookmarks(item)
  } else {
    addToBookmarks(item)
  }
}

private fun addToBookmarks(item: KodecoEntry) {
  bookmarkViewModel.addAsBookmark(item)
  bookmarkViewModel.getBookmarks()
}

private fun removedFromBookmarks(item: KodecoEntry) {
  bookmarkViewModel.removeFromBookmark(item)
  bookmarkViewModel.getBookmarks()
}

private fun openLink(url: String) {
  val application = UIApplication.sharedApplication
  val nsurl = NSURL(string = url)
  if (!application.canOpenURL(nsurl)) {
    NSLog("Unable to open url: $url")
    return
  }

  application.openURL(nsurl)
}

If you look at MainActivity.kt or Main.kt from the desktopApp, you can see that the code is identical.

Note: Although this is outside of the current scope, you can take it a step further by sharing the ViewModel initialization across all targets.

Now open Xcode, and go to iOSAppBuild PhasesCompile Kotlin Framework and update the existing script to compile a framework from shared-ui instead:

cd "$SRCROOT/.."
./gradlew :shared-ui:embedAndSignAppleFrameworkForXcode

You also need to update the path location where Xcode is going to look for the framework for the project. Now search for Framework Search Paths, which is inside the Search Paths section, and once again, double-click on its value: <Multiple values>. Scroll horizontally on the path to the composeApp framework, and update it to be shared-ui.

Finally, open ContentView.swift and add the SharedUIKit import to the list:

import SharedUIKit

And makeUIViewController instead of loading an empty Controller should now import the one that you’ve created in Main.ios.kt:

func makeUIViewController(context: Context) -> UIViewController {
  Main_iosKt.MainViewController()
}

One last change, if you scroll down to the end of this file, you see there’s an .ignoresSafeArea invocation. Update it to:

.ignoresSafeArea(.all, edges: .all)

Otherwise, the status and navigation bars won’t have the same color as the background.

Now compile and run your iOS app!

Fig. B.8 — Feed in iOS App (dark mode)
Fig. B.8 — Feed in iOS App (dark mode)

Want to see something amazing? It also supports light mode. :]

Fig. B.9 — Feed in iOS App (light mode)
Fig. B.9 — Feed in iOS App (light mode)

Where to Go From Here?

Congratulations! You just finished Kotlin Multiplatform by Tutorials. What a ride! Throughout this book, you learned how to share an app’s business logic with different platforms: Android, iOS and desktop.

Now that you’ve mastered KMP, perhaps you’re interested in learning more about Jetpack Compose and SwiftUI. These books are the perfect starting point!

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.