C.
Appendix C: Sharing Your Compose UI Between Android & Desktop
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.
Updating your project structure
To follow along with the code examples throughout this appendix, download the Starter project and open 17-appendix-c-sharing-your-compose-ui-between-android-and-desktop/projects/starter with Android Studio.
Starter is the final version of learn from Chapter 14, without the iOS app and its platform-specific code. 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.
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 KMM 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.raywenderlich.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 KMM (Kotlin Multiplatform Mobile). 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 shared-ui and rename the iosMain folder to desktopMain.
Now, open the shared-ui build.gradle.kts and remove all the iOS references. Starting from the beginning of this file:
- Moving towards the
kotlinsection, remove all the iOS targets:
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach {
it.binaries.framework {
baseName = "shared-ui"
}
}
- Now on
sourceSetsdelete all theiOS*MainandiOS*Testfields:
val iosX64Main by getting
val iosArm64Main by getting
val iosSimulatorArm64Main by getting
val iosMain by creating {
dependsOn(commonMain)
iosX64Main.dependsOn(this)
iosArm64Main.dependsOn(this)
iosSimulatorArm64Main.dependsOn(this)
}
val iosX64Test by getting
val iosArm64Test by getting
val iosSimulatorArm64Test by getting
val iosTest by creating {
dependsOn(commonTest)
iosX64Test.dependsOn(this)
iosArm64Test.dependsOn(this)
iosSimulatorArm64Test.dependsOn(this)
}
Now that there are no more iOS references, return to the kotlin section and under the android() target add:
jvm("desktop")
This is required — otherwise, you would only generate the shared-ui library for Android.
Finally, on the sourceSets configuration, add the desktopMain property to the bottom:
val desktopMain by getting
Synchronize the project. After it finishes, look at the project structure. It should be similar to this one:
When generating a KMM library, Android Studio also adds a Platform.kt file inside all folders and a Greetings.kt inside commonMain. You can remove these four files. They’re just placeholders, and they won’t be used in this appendix.
Sharing your UI code
Although the code of both platforms is quite similar, the Android app uses libraries that are platform-specific. Since the UI needs to be supported on both, there are a couple of changes you’ll need to make.
Typically, the most common scenario is that you have an Android app built with Compose that you want to port to desktop. 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.
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 a new 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 how the move should be done, before pressing refactor, confirm that you have the following settings selected:
- Search in comments and strings.
- Search for text occurrences.
Click Continue.
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.
Looking at the androidApp source folder, there are only two classes: MainActivity.kt and RWApplication. All the other UI classes are now in shared-ui.
It’s time to move the resources files. Start by creating a res folder inside shared-ui/androidMain:
- Right-click androidMain.
- Select New Directory.
- When prompted, select res from the list of Gradle Source Sets.
Similar to the androidApp/res, this folder will contain all the app resources that are platform-specific. Although you can move all the files inside res folder to this new location, there are a couple of ones that don’t necessarily need to be shared, since they are Android-specific:
- ic_launcher_background.xml, the app icon background.
- colors.xml, colors used for XML attributes.
- themes.xml, defines the window background.
Move all the folders except values inside androidApp/res to shared-ui.
With your code and its resources 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 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 need to use an external library to support them when targeting Multiplatform. You can read more about this in the “Use LiveData and ViewModel” section of this chapter.
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. It’s a more concise and modern approach — decoupled from API versions, it empowers you to create apps faster.
Note: You can learn more about Jetpack Compose for Android in Chapter 3, Developing UI, and by reading the Jetpack Compose by Tutorials from raywenderlich.com.
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 the Compose Multiplatform, JetBrains provided this exact support. It allows using Compose both for desktop and web. In this book, you’ve seen how to develop an app for the first platform and run it on macOS, Windows and Linux.
Different versions of Compose
The desktop app is already using Compose for desktop through the JetBrains Compose plugin. To make Android and desktop share the same UI, the shared-ui module needs to use the same one, instead of the Android version you’re using in the androidApp.
This is required because there’s currently a limitation between both Android and Multiplatform Compose frameworks. Google and JetBrains launch libraries updates at different times. This means that one of the Compose UI Toolkits might be using a version that may not be compatible with the other one. Since you’re sharing UIs between different applications, you need to guarantee that everything works. You can track the progress of this issue on Google’s IssueTracker.
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 and import JetBrain’s Compose. In the plugins section, before com.android.library, add:
id("org.jetbrains.compose") version "1.1.0"
Now add the Compose libraries the project is using. Scroll down to sourceSets, and inside commonMain section add:
dependencies {
api(compose.foundation)
api(compose.material)
api(compose.runtime)
api(compose.ui)
}
It will look like this when you’re done:
val commonMain by getting {
dependencies {
api(compose.foundation)
api(compose.material)
api(compose.runtime)
api(compose.ui)
}
}
Synchronize the project and navigate back to BookmarkContent.kt file.
Fewer imports, colored red, mean the project could 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"))
api("org.jetbrains.kotlinx:kotlinx-datetime:0.3.2")
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 support Multiplatform, so you’ll migrate this logic to a new one: Kamel.
Kamel 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 Kamel:
api(project(":kamel-image"))
Here, you’re using a local version of Kamel, since the current one doesn’t support the latest version of 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.
The AddImagePreview Composable first checks if the url is empty. If it isn’t, it will create a request to download the image.
Kamel doesn’t deal with the request directly in lazyPainterResource, so you can remove this logic. Update the elseclause to:
else {
Box {
//1
when (val resource = lazyPainterResource(url)) {
//2
is Resource.Loading -> {
Logger.d(TAG, "Loading image from uri=$url")
AddImagePreviewEmpty(modifier)
}
//3
is Resource.Success -> {
Logger.d(TAG, "Loading successful image from uri=$url")
KamelImage(
resource = resource,
contentScale = ContentScale.Crop,
contentDescription = "Image preview",
modifier = modifier,
crossfade = true
)
}
//4
is Resource.Failure -> {
Logger.d(TAG, "Loading failed image from uri=$url. Reason=${resource.exception}")
AddImagePreviewEmpty(modifier)
}
}
}
}
Here’s a step-by-step breakdown of this logic:
-
lazyPainterResourceis part of the Kamel library, and it’s similar to therequestthat you had before. It returns the current state of the request viaResource.*which can either beLoading,Success, orFailure. - Handling the state of a request, in case it’s
loading, this means that the operation is ongoing. Visually, it will show an image placeholder that contains the app’s logo. - If the image is available, the result is a
success. AnImageComposable is added with the received file. - On the contrary, if the result is a
failure, you’ll display an empty preview.
With the image-fetching API migrated to Kamel, don’t forget to remove all the Coil imports along with the OptIn annotation at the beginning of the file. Scroll to the top of ImagePreview.kt and remove:
import androidx.compose.ui.platform.LocalContext
import coil.annotation.ExperimentalCoilApi
import coil.compose.ImagePainter
import coil.compose.rememberImagePainter
import coil.request.ImageRequest
@OptIn(ExperimentalCoilApi::class)
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, it won’t be possible to 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 and desktop and creates libraries that you can use on both platforms. One of these libraries is PreCompose. It was written by Tlaster and supports the Android Jetpack Lifecycle, ViewModel, LiveData and Navigation components in Multiplatform.
Since at the time of this writing, the version on GitHub is still using an older version of Compose Multiplatform instead of importing the published library, learn contains the source code of the project with a couple of changes — all the plugins/libraries are now using the latest versions.
Now that you’re familiar with precompose, open the build.gradle.kts file from the shared-ui module, and after the api(project()) includes, add:
api(project(":precompose"))
Synchronize your project. Once this operation ends, you’ll need to update your app ViewModels. Open the BookmarkViewModel.kt file on the shared-ui module, and remove the imports that you no longer need:
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.raywenderlich.learn.ui.utils.SingleLiveEvent
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 LiveData class from this library is slightly different from the Android one. Remove the _items variable, and update the items declaration to:
val items: MutableState<List<RWEntry>> = mutableStateOf(emptyList())
This also requires that you change its usages. Update onNewBookmarksList to set the items value:
items.value = bookmarks
Now, open FeedViewModel.kt. You’ll need 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 models fetch calls to be bellow its initialization.
Finally, remove the call to observeAsState(), which is no longer necessary with the LiveData objects that precompose uses.
Don’t forget to delete the now-unnecessary imports:
import androidx.activity.viewModels
import androidx.compose.runtime.livedata.observeAsState
After both changes, remove the SingleLiveEvent class, which is inside the utils directory. It’s Android-specific and no longer needed.
Handling navigation
The precompose library also handles Android and desktop 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 its 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 need to remove the androidx.* imports:
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
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, you need to 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.
Scroll down to where BottomNavigationItem is defined and look for onClick. Update this function with the new navigation from Navigator. Replace the current implementation with:
if (!isSelected) {
selectedIndex.value = index
navController.navigate(screen.route)
}
You don’t need to set additional configurations.
Once that’s done, don’t forget to remove the imports:
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
Now that you’ve updated MainBottomBar, you’ll need to make similar changes on MainContent.kt. Open this file, and once again replace the NavHostController type on the different functions with Navigator.
On MainScreenNavigationConfigurations, you also need to import the NavHost from precompose, replace startDestination with initialRoute and the composable call 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 on 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
Migrating JVM-only libraries To Android
The accompanist libraries that the Android app uses are only being generated for this platform. Fortunately, the community once again comes to the rescue with a version that supports desktop. It was ported by the user Syer10, and you can find the repository on his GitHub.
The latest release now supports both Android and JVM, so you can easily add it to learn and share it across both platforms. Open the shared-ui build.gradle.kts and add to commonMain/dependencies:
api("ca.gosyer:accompanist-pager:0.20.1")
api("ca.gosyer:accompanist-pager-indicators:0.20.1")
Synchronize the project.
Although, you could use Google’s version in Android and Syer10 in desktop, since you’re sharing the UI between both platforms, you need to use the same one on both.
To give this support, both libraries build.gradle.kts files were updated with the Android target:
plugins {
//1
kotlin("multiplatform")
id("org.jetbrains.compose") version "1.1.0"
//2
id("com.android.library")
}
kotlin {
//3
android {
publishLibraryVariants("release", "debug")
}
//4
jvm("desktop") {
testRuns["test"].executionTask.configure {
useJUnitPlatform()
}
}
//5
sourceSets {
val commonMain by getting {
dependencies {
api(compose.material)
api(compose.ui)
implementation("androidx.annotation:annotation:1.3.0")
implementation("io.github.aakira:napier:2.1.0")
}
}
val commonTest by getting
val androidMain by getting
val androidTest by getting
val desktopMain by getting
val desktopTest by getting
}
}
//6
android {
compileSdk = 31
sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
//7
sourceSets["main"].res.srcDirs("src/androidMain/res", "src/commonMain/resources")
defaultConfig {
minSdk = 21
targetSdk = 31
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
Here’s a step-by-step breakdown of this logic:
- Since you’re going to generate a library for more than one platform, you need to include the
multiplatformplugin. - Additionally, since one of these platforms is Android, you also need to import
com.android.libraryso you can define the configurations set on 6. - Previously, this accompanist version was just generating the JVM version. Since you want it to create an Android and desktop version, you need to add both targets under the
kotlinsection. Here, you’re defining that it should generate adebugand areleasebuilds. - To easily identify the desktop version, you’re setting its name inside the
jvmtarget. - Since you’re building for more than one platform, the libraries that the project is using need to be added to the
commonMaindependencies. Although one of the libraries is from Android, since it’s not platform-specific, you don’t need to define any other libraries on the other properties. - The configuration that’s going to be used to generate the Android build.
- The resources’ directory on commonMain is going to have the app fonts and strings, so you need to add its location to the
sourceSetsclass path.
Handling resources
Both 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.
Loading local images
To load local images, you’ll need to write this logic in Kotlin Multiplatform. This is necessary since Android uses the R class to reference images, and desktop uses the image path on the resources folder.
It’s also worth mentioning that both platforms use different formats for images. Android uses vector drawables, while desktop uses PNGs. With this in mind, you will not share these resources directly. They need to be in their own platform-specific folders.
From the Android side, you’ve already copied all the resources needed from androidApp/res. However, for the desktop, they’re still on desktopApp.
Start by creating a resources’ directory inside shared-ui/desktopMain. You can easily create it by right-clicking on this folder and selecting New ▸ Directory ▸ resources. Then, move the images folder from desktopApp/resources to this newly created folder.
With all the images in their correct folders, you need to create a class to reference them. In commonMain/theme, create Icons.kt and add:
@Composable
public expect fun icBrand(): Painter
@Composable
public expect fun icLauncher(): Painter
@Composable
public expect fun icMore(): Painter
@Composable
public expect fun icHome(): Painter
@Composable
public expect fun icBookmark(): Painter
@Composable
public expect fun icLatest(): Painter
@Composable
public expect fun icSearch(): Painter
These functions represent all images the apps are currently using. With the expect declarations done, you now need to write the actual implementations in androidMain and desktopMain. Starting with the first one, create an Icons.kt following the same path as the one you created on desktopMain (you’ll need to create the theme package): androidMain/kotlin/com/raywenderlich/shared/ui/theme/Icons.kt
And add:
@Composable
public actual fun icBrand(): Painter = painterResource(R.drawable.ic_brand)
@Composable
public actual fun icLauncher(): Painter = painterResource(R.mipmap.ic_launcher)
@Composable
public actual fun icMore(): Painter = painterResource(R.drawable.ic_more)
@Composable
public actual fun icHome(): Painter = painterResource(R.drawable.ic_home)
@Composable
public actual fun icBookmark(): Painter = painterResource(R.drawable.ic_bookmarks)
@Composable
public actual fun icLatest(): Painter = painterResource(R.drawable.ic_latest)
@Composable
public actual fun icSearch(): Painter = painterResource(R.drawable.ic_search)
Each one of these functions will access the generated R class and access the corresponding drawable or mipmap reference.
Now, you’ll need to do the same thing for desktopMain. Create the same Icons.kt file, but this time in desktopMain/kotlin/com/raywenderlich/shared/ui/theme/ (you’ll need to again create the theme package).
Then, add:
@Composable
public actual fun icBrand(): Painter = painterResource("images/razerware.png")
@Composable
public actual fun icLauncher(): Painter = painterResource("images/ic_launcher.png")
@Composable
public actual fun icMore(): Painter = painterResource("images/ic_more.png")
@Composable
public actual fun icHome(): Painter = painterResource("images/ic_home.png")
@Composable
public actual fun icBookmark(): Painter = painterResource("images/ic_bookmarks.png")
@Composable
public actual fun icLatest(): Painter = painterResource("images/ic_latest.png")
@Composable
public actual fun icSearch(): Painter = painterResource("images/ic_search.png")
Although you’re using the painterResource on both platforms, they’re different functions. One is from PainterResources.android.kt, and the other PainterResources.desktop.kt.
With all the resources properly identified with their corresponding functions, you’ll need to make quite a few updates to replace the current calls to the R class with this new implementation.
Starting alphabetically, you’ll need to make the following changes in the commonMain files:
common/EntryContent
In the AddEntryContent Composable, replace the call to R.mipmap.ic_launcher with:
val icon = icLauncher()
And afterward, when you’re accessing R.drawable.ic_more, with:
val icon = icMore()
And remove the import:
import androidx.compose.ui.res.painterResource
components/ImagePreview
In the AddImagePreviewEmpty Composable, replace the call to R.drawable.ic_brand with:
val icon = icBrand()
And then remove the import:
import androidx.compose.ui.res.painterResource
main/BottomNavigationScreens
You’ll need to make a few changes on this class. It can no longer receive a @DrawableRes, but instead it needs to be set as @Composable. This is required since all the functions that reference images as painterResource are Composables, and those can only be called from another Composable function.
Replace the drawResId parameter with:
val icon: @Composable () -> Unit
Since it now receives a @Composable, you need to replace the objects declared in this file:
object Home : BottomNavigationScreens(
route = "Home",
stringResId = R.string.navigation_home,
icon = {
Icon(
painter = icHome(),
contentDescription = R.string.navigation_home
)
}
)
object Bookmark : BottomNavigationScreens(
route = "Bookmark",
stringResId = R.string.navigation_bookmark,
icon = {
Icon(
painter = icBookmark(),
contentDescription = R.string.navigation_bookmark
)
}
)
object Latest : BottomNavigationScreens(
route = "Latest",
stringResId = R.string.navigation_latest,
icon = {
Icon(
painter = icLatest(),
contentDescription = R.string.navigation_latest
)
}
)
object Search : BottomNavigationScreens(
route = "Search",
stringResId = R.string.navigation_search,
icon = {
Icon(
painter = icSearch(),
contentDescription = R.string.navigation_search
)
}
)
And you can also remove this import:
import androidx.annotation.DrawableRes
And add:
import androidx.compose.material.Icon
import com.raywenderlich.learn.ui.theme.icBookmark
import com.raywenderlich.learn.ui.theme.icHome
import com.raywenderlich.learn.ui.theme.icLatest
import com.raywenderlich.learn.ui.theme.icSearch
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.
main/MainBottomBar
In the AppBottomNavigation Composable, when defining the icon inside the BottomNavigationItem, replace the Icon call with:
screen.icon()
And remove the imports:
import androidx.compose.material.Icon
import androidx.compose.ui.res.painterResource
search/SearchContent
In the AddSearchField Composable, replace the call in leadingIcon to painterResource with:
val icon = icSearch()
And remove the import:
import androidx.compose.ui.res.painterResource
All done! A couple more sections to go, and you’ll have your app’s UI completely shared.
Using custom fonts
Both apps need to use the Bitter font. Previously, you moved all the folders from androidApp/res folder to commonMain/resources. If you open it, you’ll see there’s a set of bitter_*.ttf that represent the fonts your text can use.
Similar to what you’ve done in the previous section, you’ll need to create a Multiplatform function to load them.
Start by creating a Font.kt file inside commonMain/theme on the shared-ui module. This class will contain the function declaration:
@Composable
expect fun Font(name: String, res: String, weight: FontWeight, style: FontStyle): Font
A font can either be referenced through the R class on Android or with its path on desktop. The res argument represents that. weight and style correspond to its properties, and, of course, name to its name.
Open the androidMain/theme, and create the corresponding Font.kt file with the following actual implementation:
@Composable
actual fun Font(name: String, res: String, weight: FontWeight, style: FontStyle): Font {
val context = LocalContext.current
val id = context.resources.getIdentifier(res, "font", context.packageName)
return Font(id, weight, style)
}
To make this function generic with the desktop app, res needs to be a string.
Now, go over desktopMain/theme and add its implementation of Font.kt. Create the file and add:
@Composable
actual fun Font(name: String, res: String, weight: FontWeight, style: FontStyle): Font =
androidx.compose.ui.text.platform.Font("font/$res.ttf", weight, style)
Finally, create the a file named Fonts.kt inside commonMain/theme and add the object that will contain the BitterFontFamily you can use:
object Fonts {
@Composable
fun BitterFontFamily() = FontFamily(
Font(
"BitterFontFamily",
"bitter_bold",
FontWeight.Bold,
FontStyle.Normal
),
Font(
"BitterFontFamily",
"bitter_extrabold",
FontWeight.ExtraBold,
FontStyle.Normal
),
Font(
"BitterFontFamily",
"bitter_light",
FontWeight.Light,
FontStyle.Normal
),
Font(
"BitterFontFamily",
"bitter_regular",
FontWeight.Normal,
FontStyle.Normal
),
Font(
"BitterFontFamily",
"bitter_semibold",
FontWeight.SemiBold,
FontStyle.Normal
)
)
}
Once again, the Font classes that you created represent Composable functions, and since you cannot reference them outside a Composable, you can use these fonts directly from the Typography property that’s on Type.kt file.
Before updating all the Text Composable’s with these new typography, 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.raywenderlich.learn.android.R
private val BitterFontFamily = FontFamily(
Font(R.font.bitter_bold, FontWeight.Bold),
Font(R.font.bitter_extrabold, FontWeight.ExtraBold),
Font(R.font.bitter_light, FontWeight.Light),
Font(R.font.bitter_regular),
Font(R.font.bitter_semibold, FontWeight.SemiBold),
)
Now that there’s no more BitterFontFamily, you need to 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.
Starting alphabetically on commonMain/ui, navigate to:
-
common/EmptyContent: On the
Textdeclaration, set thefontFamilyargument to:
fontFamily = Fonts.BitterFontFamily(),
-
common/EntryContent: On the
AddEntryContentComposable, look for fourTextusages and add:
fontFamily = Fonts.BitterFontFamily(),
-
home/HomeContent: Scroll down to the end of this file, and on
Textadd:
fontFamily = Fonts.BitterFontFamily(),
-
home/HomeSheetContent: Search for the two
Textcalls and add:
fontFamily = Fonts.BitterFontFamily(),
-
latest/LatestContent: Set the
fontFamilyon theTextdeclarations onAddNewPageandAddNewPageEntry:
fontFamily = Fonts.BitterFontFamily(),
-
main/MainBottomBar: When defining the
BottomNavigationItem, onTextadd:
fontFamily = Fonts.BitterFontFamily(),
-
main/MainTopAppBar: Update the
Textto contain thefontFamilyargument:
fontFamily = Fonts.BitterFontFamily(),
-
search/SearchContent: Finally, when defining the
placeholderset thefontFamilyinText:
fontFamily = Fonts.BitterFontFamily(),
Sharing strings
There’s currently no direct support for this feature. It’s true that you could follow a similar approach to the one you’ve made for sharing local images. However, this will be time-consuming and costly to maintain. On every new string, you need to create three different functions (one common and two at the platform level).
Fortunately, the IceRock Development team created the moko-resources library that supports exactly this and more!
Start by opening the build.gradle.kts file located in the root directory. In the buildscript section, inside dependencies, at the end of the list add:
classpath("dev.icerock.moko:resources-generator:0.18.0")
This will add the resources-generator to the project. Now, open the build.gradle.kts file, but this time the one from shared-ui and add its plugin:
id("dev.icerock.mobile.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.raywenderlich.learn"
}
Now you need to add the library on the commonMain/dependencies section:
api("dev.icerock.moko:resources:0.18.0")
Click synchronize and wait for the project to load this new library.
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 it should load.
You’ll reuse the Android strings.xml file as the shared strings across both platforms. Create a resources folder inside shared-ui/commonMain by right-clicking commonMain and selecting New ▸ Directory ▸ resources when prompted.
In order for moko-resources to work, the string files need to be in a specific path: commonMain/resources/MR/base. Create these two directories 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 and common) that contain the strings your app will use. You can find them at:
- shared-ui/build/generated/moko/
Before using any of these strings, there’s one step missing: you still need to implement logic to access them.
Start by creating the Resources.kt file inside the commonMain/utils folder, on the shared-ui module:
expect fun getString(resId: StringResource): String
Now, you need to declare the actual implementations both for Android and desktop. Starting with the first one, go over androidMain/ui/utils and create the corresponding Resources.kt file with:
actual fun getString(resId: StringResource): String {
return StringDesc.Resource(resId).toString(appContext)
}
The appContext corresponds to the application Context, which is already declared inside the PlatformDatabase.kt file and set in RWApplication.kt.
On desktopMain/ui/utils, create the remaining Resources.kt file and add:
actual fun getString(resId: StringResource): String {
return StringDesc.Resource(resId).localized()
}
With the implementation defined, you’ll need to go through all the classes and update the references to R class. Instead of accessing R.string.* they will call the getString function that you’ve just created.
Starting alphabetically on commonMain/ui, navigate to:
-
bookmark/BookmarkContent: On
BookmarkContentComposable, update thestringResourcecall to:
text = getString(MR.strings.empty_screen_bookmarks)
And remove the imports:
import androidx.compose.ui.res.stringResource
import com.raywenderlich.learn.android.R
-
common/EntryContent.kt: Locate all the calls to
stringResource, and, orderly, update them to the equivalentgetString(). From the top, update thedescriptionvalue to:
val description = getString(MR.strings.description_feed_icon)
Next, replace the reference to app_ray_wenderlich with:
text = getString(MR.strings.app_ray_wenderlich),
Finally, change the access to description_more to:
val description = getString(MR.strings.description_more)
Once again, remove the imports:
import androidx.compose.ui.res.stringResource
import com.raywenderlich.learn.android.R
-
components/ImagePreview .kt: You only need to make one change. Scroll down to
AddImagePreviewEmptyand update thedescriptionproperty that accesses the R class to:
val description = getString(MR.strings.description_preview_error)
And remove the now-unused imports:
import androidx.compose.ui.res.stringResource
import com.raywenderlich.learn.android.R
-
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) {
getString(MR.strings.action_remove_bookmarks)
} else {
getString(MR.strings.action_add_bookmarks)
}
After this update, the type of the text property is String, so you can remove the call to stringResource from the Text Composable below:
text = text
At the end of the file, there’s another reference to R. Replace this call with:
text = getString(MR.strings.action_share_link),
And remove the imports of:
import androidx.compose.ui.res.stringResource
import com.raywenderlich.learn.android.R
-
latest/LatestContent.kt: On
LatestContentComposable, update the strings call to:
AddEmptyScreen(getString(MR.strings.empty_screen_loading))
As always, remove the unnecessary imports:
import androidx.compose.ui.res.stringResource
import com.raywenderlich.learn.android.R
-
main/BottomNavigationScreens.kt:
@StringResis a string reference specific to the Android platform. Since you’re sharing this class with a desktop app, you need to update this parameter to a common type — String. ChangestringResIdto:
val title: String,
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 = getString(MR.strings.navigation_home),
contentDescription = getString(MR.strings.navigation_home)
The same applies to the bookmark object:
title = getString(MR.strings.navigation_bookmark),
contentDescription = getString(MR.strings.navigation_bookmark)
And to latest:
title = getString(MR.strings.navigation_latest),
contentDescription = getString(MR.strings.navigation_latest)
Finally, for search:
title = getString(MR.strings.navigation_search),
contentDescription = getString(MR.strings.navigation_search)
Remove the now-unnecessary imports:
import androidx.annotation.StringRes
import com.raywenderlich.learn.android.R
-
main/MainBottomBar.kt: With the previous change, you need to update the
BottomNavigationItemin theMainBottomBar. Replace thestringResourcecall with:
text = screen.title,
And remove its import:
import androidx.compose.ui.res.stringResource
-
main/MainTopAppBar.kt: Replace the
stringResourcecall with:
text = getString(MR.strings.app_name),
And remove the imports:
import androidx.compose.ui.res.stringResource
import com.raywenderlich.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 = getString(MR.strings.search_hint),
The second one is for leadingIcon, and you need to change the description to:
val description = getString(MR.strings.description_search)
And, as always, remove the imports:
import androidx.compose.ui.res.stringResource
import com.raywenderlich.learn.android.R
What’s missing?
With all of these changes done, you’re almost finishing. Open the desktopApp project and:
- Remove the ui and components except the Toast.kt file.
- Move the fonts folder from resources to commonMain/desktopMain/resources.
- Open the Utils.kt class and remove the
SupressLintannotation which is Android specific.
You should only keep Main.kt, which is the entry point of your app.
Here update the MainScreen call to:
MainScreen(
feeds = items,
bookmarks = bookmarks,
onUpdateBookmark = { updateBookmark(it) },
onShareAsLink = {},
onOpenEntry = { openLink(it) }
)
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"))
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:
dependencies {
implementation(project(":shared"))
implementation(project(":shared-ui"))
implementation(project(":shared-action"))
implementation("com.google.android.material:material:1.5.0")
}
Synchronize your project and — finally — compile and run your desktop and Android apps. You’ll see screens like these:
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!