Chapters

Hide chapters

Kotlin Multiplatform by Tutorials

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

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

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

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

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

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

Setting Up an iOS App to Use Compose Multiplatform

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

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

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

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

Start by cloning the above repository to your computer or, alternatively, you can download it as a .zip file, and extract its content. Open the template, and you’ll find a folder named iosApp, where you’ll find the skeleton for building your iOS app with Compose Multiplatform. Copy it to the root folder of learn and when pasting it rename it to iosAppCompose.

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

Your project structure should now be similar to this one:

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

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

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

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

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

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

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

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

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

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

If you’re not using the stable version of Android Studio, you’ll need to change the directory to Android Studio Preview.app.

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

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

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

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

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

Updating Your Project Structure

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

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

getByName("commonTest") {
  dependencies {
    implementation(kotlin("test"))
  }
}
compileOptions {
  sourceCompatibility = JavaVersion.VERSION_17
  targetCompatibility = JavaVersion.VERSION_17
}
Fig. B.4 — Project view hierarchy
Jaf. W.4 — Jzujows peiy vaaxamczn

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.

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.

com.kodeco.learn.utils
package com.kodeco.learn.utils

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

expect fun converterIso8601ToReadableDate(date: String): String
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")
    "-"
  }
}
api(project(":shared-logger"))

implementation(libs.kotlinx.datetime)
@Suppress("ConstantLocale")
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())
}
import platform.Foundation.NSDate
import platform.Foundation.NSDateFormatter
import platform.Foundation.NSISO8601DateFormatter
implementation(project(":shared-ui"))

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.

Rocdobu Usegaqooz Niyvuqo Xokuhean 9 Vowqiho Beyuyaud Pitnini Cautruleig Xucpaha EI Yadjinu Haqxejoc Cabfupu Yomcigi Bokjaco EE Luoftoh (Unxkaoz)
Tul. L.6 — Soryeqr Dakyome didb-qaqib veehkoy

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.

id("org.jetbrains.compose") version "1.5.1"
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.jetbrains.compose)
alias(libs.plugins.androidLibrary)
api(compose.foundation)
api(compose.material)
api(compose.material3)
api(compose.runtime)
api(compose.ui)
# Compose Multiplatform
org.jetbrains.compose.experimental.uikit.enabled=true

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"))

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.

implementation(libs.image.loader)
val resource = painterResource(R.drawable.ic_brand)

val painter = rememberImagePainter(
  url = url,
  placeholderPainter = { resource },
  errorPainter = { resource }
)
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.

api(libs.precompose)
api(libs.precompose.viewmodel)
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import moe.tlaster.precompose.viewmodel.ViewModel
import moe.tlaster.precompose.viewmodel.viewModelScope
val items: MutableState<List<KodecoEntry>> = mutableStateOf(emptyList())
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import moe.tlaster.precompose.viewmodel.ViewModel
import moe.tlaster.precompose.viewmodel.viewModelScope
val profile: MutableState<GravatarEntry> = mutableStateOf(GravatarEntry())
profile.value = item
import androidx.lifecycle.MutableLiveData
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
private lateinit var bookmarkViewModel: BookmarkViewModel
private lateinit var feedViewModel: FeedViewModel
feedViewModel = viewModel {
  FeedViewModel()
}

bookmarkViewModel = viewModel {
  BookmarkViewModel()
}
import moe.tlaster.precompose.ui.viewModel
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.

class MainActivity : PreComposeActivity()
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import moe.tlaster.precompose.lifecycle.PreComposeActivity
import moe.tlaster.precompose.lifecycle.setContent
import androidx.navigation.NavHostController
NavHost(
  navigator = navController,
  initialRoute = DEFAULT_SCREEN.route
)
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
val navController = rememberNavigator()
navController.enableOnBackPressed(false)
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.

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"
moko-resources = { module = "dev.icerock.moko:resources", version.ref = "moko-resources" }
moko-resources-compose = { module = "dev.icerock.moko:resources-compose", version.ref = "moko-resources" }
moko-multiplatform-resources = { id = "dev.icerock.mobile.multiplatform-resources", version.ref = "moko-resources" }
alias(libs.plugins.moko.multiplatform.resources) apply false
alias(libs.plugins.moko.multiplatform.resources)
multiplatformResources {
  multiplatformResourcesPackage = "com.kodeco.learn.ui"
}
api(libs.moko.resources)
api(libs.moko.resources.compose)
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")
}
sourceSets["main"].java.srcDirs("build/generated/moko/androidMain/src")

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.

import dev.icerock.moko.resources.compose.painterResource
val resource = painterResource(MR.images.ic_more)
import com.kodeco.learn.ui.MR
import com.kodeco.learn.R
import androidx.compose.ui.res.painterResource
val resource = painterResource(MR.images.ic_brand)
import com.kodeco.learn.ui.MR
import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.ui.res.painterResource
import com.kodeco.learn.R
import com.kodeco.learn.ui.MR
import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.ui.res.painterResource
import com.kodeco.learn.R
painter = painterResource(MR.images.ic_home),
painter = painterResource(MR.images.ic_bookmarks),
painter = painterResource(MR.images.ic_latest),
painter = painterResource(MR.images.ic_search),
val resource = painterResource(MR.images.ic_search)
import com.kodeco.learn.ui.MR
import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.ui.res.painterResource
import com.kodeco.learn.R

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.

<fontFamily>-<fontStyle>
OpenSans-Bold.ttf
OpenSans-ExtraBold.ttf
OpenSans-Light.ttf
OpenSans-Regular.ttf
OpenSans-SemiBold.ttf
fontFamilyResource(MR.fonts.OpenSans.regular)
MR.fonts.OpenSans.regular.asFont()
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),
)
import com.kodeco.learn.ui.MR
import dev.icerock.moko.resources.compose.fontFamilyResource
fontFamily = fontFamilyResource(MR.fonts.OpenSans.regular)
fontFamily = fontFamilyResource(MR.fonts.OpenSans.regular)
fontFamily = fontFamilyResource(MR.fonts.OpenSans.regular)
fontFamily = fontFamilyResource(MR.fonts.OpenSans.regular)
fontFamily = fontFamilyResource(MR.fonts.OpenSans.regular)
fontFamily = fontFamilyResource(MR.fonts.OpenSans.regular)
fontFamily = fontFamilyResource(MR.fonts.OpenSans.regular)
fontFamily = Fonts.BitterFontFamily(),

Sharing Strings

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

text = stringResource(MR.strings.empty_screen_bookmarks)
import com.kodeco.learn.ui.MR
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.res.stringResource
import com.kodeco.learn.android.R
text = stringResource(MR.strings.app_kodeco),
import com.kodeco.learn.ui.MR
import dev.icerock.moko.resources.compose.stringResource
val description = stringResource(MR.strings.description_more)
import androidx.compose.ui.res.stringResource
val description = stringResource(MR.strings.description_preview_error)
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.res.stringResource
val text = if (item.value.bookmarked) {
  stringResource(MR.strings.action_remove_bookmarks)
} else {
  stringResource(MR.strings.action_add_bookmarks)
}
import com.kodeco.learn.ui.MR
import dev.icerock.moko.resources.compose.stringResource
text = stringResource(MR.strings.action_share_link),
import androidx.compose.ui.res.stringResource
import com.kodeco.learn.android.R
AddEmptyScreen(stringResource(MR.strings.empty_screen_loading))
import com.kodeco.learn.ui.MR
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.res.stringResource
import com.kodeco.learn.android.R
val title: StringResource,
title = MR.strings.navigation_home,
contentDescription = stringResource(MR.strings.navigation_home)
import com.kodeco.learn.ui.MR
import dev.icerock.moko.resources.StringResource
import dev.icerock.moko.resources.compose.stringResource
title = MR.strings.navigation_bookmark,
contentDescription = stringResource(MR.strings.navigation_bookmark)
title = MR.strings.navigation_latest,
contentDescription = stringResource(MR.strings.navigation_latest)
title = MR.strings.navigation_search,
contentDescription = stringResource(MR.strings.navigation_search)
import androidx.annotation.StringRes
import androidx.compose.ui.res.stringResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.res.stringResource
text = stringResource(MR.strings.app_name),
import com.kodeco.learn.ui.MR
import dev.icerock.moko.resources.compose.stringResource
contentDescription = stringResource(MR.strings.description_profile)
import androidx.compose.ui.res.stringResource
import com.kodeco.learn.android.R
text = stringResource(MR.strings.search_hint),
val description = stringResource(MR.strings.description_search)
import com.kodeco.learn.ui.MR
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.res.stringResource

What’s Missing?

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

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

implementation(compose.desktop.currentOs)
implementation(project(":shared-ui"))
implementation(project(":shared-action"))

implementation(libs.android.material)
import com.kodeco.learn.R
import com.kodeco.learn.ui.R
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
  }
}
import android.app.Activity
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
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
    }
  }
Fig. B.6 — Feed in Android App
Nej. M.3 — Saak eg Owlxeet Atr

Fig. B.7 — Feed in Desktop App
Yow. S.2 — Zaed on Qoryhon Ons

it.binaries.framework {
  baseName = "SharedUIKit"
  linkerOpts.add("-lsqlite3")
}
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)
}
cd "$SRCROOT/.."
./gradlew :shared-ui:embedAndSignAppleFrameworkForXcode
import SharedUIKit
func makeUIViewController(context: Context) -> UIViewController {
  Main_iosKt.MainViewController()
}
.ignoresSafeArea(.all, edges: .all)
Fig. B.8 — Feed in iOS App (dark mode)
Sib. Y.8 — Deod ed eOZ Ozt (nacf vomu)

Fig. B.9 — Feed in iOS App (light mode)
Deg. B.9 — Ceop eq eUL Uvk (wapsd hipe)

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.

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

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now