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:
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:
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/"
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:
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
- 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:
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
@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.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:
- 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.
- 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
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/MainBottomBar: When defining the
BottomNavigationItem, 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 = 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
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. ChangestringResIdto:
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
BottomNavigationItemin theMainBottomBar. 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.
- 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:
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!
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!