Chapters

Hide chapters

Kotlin Multiplatform by Tutorials

Second Edition · Android 14, iOS 17, Desktop · Kotlin 1.9.10 · Android Studio Hedgehog

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 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 16-appendix-b-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 compose-multiplatform-template, created and maintained by them.

Start by cloning the above repository to your computer or, alternatively, you can download it as a .zip file, and extract its content. 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 Main_iosKt.MainViewController(). You’ll create this implementation later in the chapter. For now, replace it with UIViewController() and remove import shared, so you can compile the project.

Open iosApp 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”.

Now, go to BuildSettings and search for Linking - General. Here you’ve got a setting named Other Linker Flags. Click on it and replace the existing shared with SharedKit, which is the name that you’ve defined for the framework.

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:

If you’re not using the stable version of Android Studio, 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:

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
  • iOS framework distribution: Regular framework

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 the android one:

jvm("desktop")

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

Replace the existing android target, and it’s configuration, with the new one:

androidTarget()

Scroll down to the sourceSets section and replace the existing implementation with:

getByName("commonMain") {
  dependencies {
    //put your multiplatform dependencies here
  }
}

getByName("commonTest") {
  dependencies {
    implementation(kotlin("test"))
  }
}

This change is to avoid compilation warnings. Previously, you were creating two variables: commonMain and commonTest that would never be used. Using getByName instead solves this.

Finally, scroll down to the android section on the bottom of the file and add Java 17 compatibility options:

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

Synchronize the project.

With the configuration set, click on the shared-ui folder and then New ▸ Directory and select desktopMain/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 a Greetings.kt inside commonMain. 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 “Move 8 packages to another package” and then before pressing refactor, confirm that you have the following settings selected:

  • Search in comments and strings.
  • Search for text occurrences.

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 Continue.

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 Continue.

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 desktopMain, since you’ve manually added this target, you have to add the namespace first. You can easily do this by right-click on desktopMain/kotlin folder and select New ▸ Package 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 = date.toInstant()
    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 desktopMain 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())
}

When prompted, 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.

Once done, open MainActivity.kt. All the imports should now be resolved. Nevertheless, the view models still need to be addressed. Because they’re Android-specific, you must use an external library to support them when targeting Multiplatform. You can read more about this in the “Using LiveData and ViewModels” section of this chapter.

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.androidLibrary, add:

id("org.jetbrains.compose") version "1.5.1"

Since the project is using version catalogs, you can migrate the plugins added by the template, along with Compose Multiplatform:

alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.jetbrains.compose)
alias(libs.plugins.androidLibrary)

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)

Compose Multiplatform for iOS it’s still experimental, therefore to use it, you need to enable it. Open gradle.properties file and add:

# Compose Multiplatform
org.jetbrains.compose.experimental.uikit.enabled=true

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.

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

In the Android app, you were using Coil to fetch images. Unfortunately, it currently doesn’t fully support Multiplatform, so you’ll migrate this logic to a new one: Compose ImageLoader.

Compose ImageLoader uses Ktor (you can read more about this library in Chapter 12, “Networking”) to fetch media. This API is similar to Coil, so you won’t need to make many changes.

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

implementation(libs.image.loader)

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.

The AddImagePreview Composable first checks if the url is empty. If it isn’t, it will create a request to download the image via rememberAsyncImagePainter.

Compose ImageLoader API is similar to Coil. To fetch an image, you just need to make a couple of changes:

  1. Update the above call to use rememberImagePainter:
val resource = painterResource(R.drawable.ic_brand)

val painter = rememberImagePainter(
  url = url,
  placeholderPainter = { resource },
  errorPainter = { resource }
)

The placeholderPainter and errorPainter correspond to the image that should be shown during the process of fetching an image and when this operation fails, respectively. For now, you won’t be able to resolve both painterResource and the R class. You’re going to see in the “Handling Resources” section how to address this.

  1. Replace the existing Coil imports with:
import com.seiko.imageloader.rememberImagePainter

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, there’s a strong community around Kotlin Multiplatform and Compose that tries to reduce the gap between Android, desktop (and now iOS), and creates libraries that you can use on both platforms. One of these libraries is PreCompose and it supports the Android Jetpack Lifecycle, ViewModel, LiveData and Navigation components in Multiplatform.

Now that you’re familiar with precompose, open the build.gradle.kts file from the shared-ui module, and on commonMain/dependencies, add:

api(libs.precompose)
api(libs.precompose.viewmodel)

Synchronize your project. Once this operation ends, you’ll need to update your app ViewModels. Open the BookmarkViewModel.kt file from the shared-ui module, and remove the imports that you no longer need:

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

To import ViewModel() and the viewModelScope, you’ll need to add the precompose version of both classes:

import moe.tlaster.precompose.viewmodel.ViewModel
import moe.tlaster.precompose.viewmodel.viewModelScope

The MutableLiveData class from this library is slightly different from the one in Android. Remove the _items variable, and update the items declaration to:

val items: MutableState<List<KodecoEntry>> = mutableStateOf(emptyList())

Add the following imports for MutableState:

import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf

Now, open FeedViewModel.kt. You’ll have to make similar changes.

Remove the imports to Android libraries:

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

And add the ones from precompose for ViewModel() and viewModelScope:

import moe.tlaster.precompose.viewmodel.ViewModel
import moe.tlaster.precompose.viewmodel.viewModelScope

Finally, remove the _profile declaration and replace profile with:

val profile: MutableState<GravatarEntry> = mutableStateOf(GravatarEntry())

And update its usage on onMyGravatarData to:

profile.value = item

And replace the import:

import androidx.lifecycle.MutableLiveData

With:

import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf

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

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

There’s no support to call by viewModel() on precompose. Instead, you need to initialize them inside a Composable function. This is why they’re set as lateinit. Inside setContent, add:

feedViewModel = viewModel {
  FeedViewModel()
}

bookmarkViewModel = viewModel {
  BookmarkViewModel()
}

When prompted, add:

import moe.tlaster.precompose.ui.viewModel

And move the view model’s fetch calls to be after its initialization.

Finally, remove the call to observeAsState() since it’s is no longer necessary.

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

import androidx.activity.viewModels
import androidx.compose.runtime.livedata.observeAsState

Handling Navigation

The precompose library also handles navigation between different screens. In case of learn, the user can change between the tabs on the bottom navigation bar.

The desktop app already uses precompose, so there’s nothing that you need to do there. However, Android was using different libraries, so you’ll need to make a few changes here.

Open the MainActivity.kt file inside androidApp, and replace the class the activity extends with:

class MainActivity : PreComposeActivity()

You’ll also have to remove the androidx.* imports:

import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent

And, add the ones from precompose:

import moe.tlaster.precompose.lifecycle.PreComposeActivity
import moe.tlaster.precompose.lifecycle.setContent

That’s it on the app side. Now, navigate back to the shared-ui module and make a few more updates.

The bottom navigation bar on Android uses the NavHost, which isn’t available for Multiplatform. Fortunately, precompose has a similar feature called Navigator. You’ll need to replace the current implementation that uses NavHostController with this one.

Open the main/MainBottomBar.kt file and replace the type of the NavHostController to Navigator. You need to make this change on MainBottomBar and AppBottomNavigation functions.

Once that’s done, don’t forget to remove the imports:

import androidx.navigation.NavHostController

Now that you’ve updated MainBottomBar, you’ll have to make similar changes on MainContent.kt. Open this file, and once again replace the NavHostController type on the different functions with Navigator.

In MainScreenNavigationConfigurations, you also have to import the NavHost from precompose and set it as navigator, and replace startDestination with initialRoute:

NavHost(
  navigator = navController,
  initialRoute = DEFAULT_SCREEN.route
)

Afterward, replace the multiple composable calls with scene.

Finally, remove the androidx.* imports:

import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable

The last change required is in MainScreen.kt when navController is defined:

val navController = rememberNavigator()

And, remove:

navController.enableOnBackPressed(false)

Which is currently not supported.

Finally, remove the import:

import androidx.navigation.compose.rememberNavController

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 another platform.

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 at the latest moko-resources version:

moko-resources = "0.23.0"

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 {
  multiplatformResourcesPackage = "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)

There’s currently an issue copying resources from a module to the app project, so you need to manually set the resources source directory for each one of the platforms that you’re targeting. After getByName("commonTest") add:

getByName("desktopMain") {
  resources.srcDirs("build/generated/moko/desktopMain/src")
}

getByName("iosX64Main") {
  resources.srcDirs("build/generated/moko/iosX64Main/src")
}

getByName("iosArm64Main") {
  resources.srcDirs("build/generated/moko/iosArm64Main/src")
}

getByName("iosSimulatorArm64Main") {
  resources.srcDirs("build/generated/moko/iosSimulatorArm64Main/src")
}

For Android, you have to scroll down to the end of the file, and inside the android section add:

sourceSets["main"].java.srcDirs("build/generated/moko/androidMain/src")

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.

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 New ▸ Directory ▸ resources. Repeat the process, but this time click on resources and enter MR/images.

This MR folder is required by moko. 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.

With all the resources 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 Build ▸ Make Project 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/resources/MR 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 Build ▸ Make Project. Once this operation ends, you can go to shared-ui/build/generated/moko/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/MainBottomBar: When defining the BottomNavigationItem, 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 = Fonts.BitterFontFamily(),

Sharing Strings

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

The strings on the desktop app are currently hardcoded. This is enough for a simple app, but if you keep adding new features that use strings, having them located in a single file is easier to maintain. Moreover, if you want to add support for internationalization, you’ll need to have multiple strings files, so the OS can know which one to load.

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/resources/MR/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/

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 stringResId 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/MainBottomBar.kt: With the previous change, you have to update the BottomNavigationItem in the MainBottomBar. 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.
  • From resources, delete the font and images directory. You should only have here your app icons.

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)

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
    window.statusBarColor = colorScheme.surface.toArgb()
    WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
  }
}

And then also remove its imports:

import android.app.Activity
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat

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

val darkTheme = isSystemInDarkTheme()
KodecoTheme(
  darkTheme = darkTheme
) {

  val view = LocalView.current
  val colorScheme = MaterialTheme.colorScheme
  if (!view.isInEditMode) {
    SideEffect {
      val window = (view.context as Activity).window
      window.statusBarColor = colorScheme.surface.toArgb()
      WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
    }
  }

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

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:

it.binaries.framework {
  baseName = "SharedUIKit"
  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.ui.Modifier
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 moe.tlaster.precompose.PreComposeApplication
import moe.tlaster.precompose.viewmodel.viewModel
import platform.Foundation.NSLog
import platform.Foundation.NSURL
import platform.UIKit.UIApplication

private lateinit var bookmarkViewModel: BookmarkViewModel
private lateinit var feedViewModel: FeedViewMod

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

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

    bookmarkViewModel = viewModel(BookmarkViewModel::class) {
      BookmarkViewModel()
    }

    feedViewModel = viewModel(FeedViewModel::class) {
      FeedViewModel()
    }

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

    val items = feedViewModel.items
    val profile = feedViewModel.profile
    val bookmarks = bookmarkViewModel.items

    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.

Now open Xcode, and go to iOSApp ▸ Build Phases ▸ Compile Kotlin 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 go to the Build Settings section and scroll down to Linking - General and look for Other Linker Flags setting. Or, you can just search for it. Now double-click on its value, and update the current SharedKit to SharedUIKit.

Once done, 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 shared 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.