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:
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:
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. :]
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 New ▸ Directory and select jvmMain/kotlin.
Look at the project structure. It should be similar to the one below:
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 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 = 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
@Composableinto UI.
They can be structured into the following 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:
- The
NavigationSuiteScaffoldadapts 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. - 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 forBottomAppBarin MainBottomBar.kt. Since they are now defined here, you can safely delete that file. - The previous
Scaffoldis now thecontentofNavigationSuiteScaffold.
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 New ▸ Directory 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 Build ▸ Compile ‘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 Build ▸ Compile ‘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
Textdeclaration, set thefontFamilyargument to:
fontFamily = fontFamilyResource(MR.fonts.opensans_regular)
-
common/EntryContent: On the
AddEntryContentComposable, look for fourTextusages and add:
fontFamily = fontFamilyResource(MR.fonts.opensans_regular)
-
home/HomeContent: Scroll down to the end of this file, and on
Textadd:
fontFamily = fontFamilyResource(MR.fonts.opensans_regular)
-
home/HomeSheetContent: Search for the two
Textcalls and add:
fontFamily = fontFamilyResource(MR.fonts.opensans_regular)
-
latest/LatestContent: Set the
fontFamilyon theTextdeclarations onAddNewPageandAddNewPageEntry:
fontFamily = fontFamilyResource(MR.fonts.opensans_regular)
-
main/MainScreen: When defining the
navigationSuiteItems, onTextadd:
fontFamily = fontFamilyResource(MR.fonts.opensans_regular)
-
main/MainTopAppBar: Update the
Textto contain thefontFamilyargument:
fontFamily = fontFamilyResource(MR.fonts.opensans_regular)
-
search/SearchContent: Finally, when defining the
placeholderset thefontFamilyinText:
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
BookmarkContentComposable, update thestringResourcecall 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
Rclass, and, orderly, update them to use the equivalentMRreference. Starting withR.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
AddImagePreviewEmptyand update thedescriptionproperty 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
textshould 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
LatestContentComposable, 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:
@StringResis 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. Changetitleto:
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
bottomNavigationItemsin thenavigationSuiteItems. Replace thestringResourcefromandroidx.composeto:
import dev.icerock.moko.resources.compose.stringResource
And remove its import:
import androidx.compose.ui.res.stringResource
-
main/MainTopAppBar.kt: Replace the
stringResourcecall 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
AddSearchFieldand locate the two calls tostringResource. The first one is where you’re defining theplaceholderand 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:
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 iOSApp ▸ Build Phases ▸ Compile 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!
Want to see something amazing? It also supports 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!