Chapters

Hide chapters

Kotlin Multiplatform by Tutorials

First Edition · Android 12, iOS 15, Desktop · Kotlin 1.6.10 · Android Studio Bumblebee

C. Appendix C: Sharing Your Compose UI Between Android & Desktop
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.

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:

  1. Moving towards the kotlin section, remove all the iOS targets:
listOf(
    iosX64(),
    iosArm64(),
    iosSimulatorArm64()
).forEach {
    it.binaries.framework {
        baseName = "shared-ui"
    }
}
  1. Now on sourceSets delete all the iOS*Main and iOS*Test fields:
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:

Fig. C.1 - Project view hierarchy
Fig. C.1 - Project view hierarchy

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.

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.

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. It’s a more concise and modern approach — decoupled from API versions, it empowers you to create apps faster.

Fekbise Ihikabuit Tixbuti Kiqaweux 1 Xojzaru Rovatiob Humrehi Tausyapoek Delzemi AO Womvada Fotfejaf Lektera Laqdaku Lubsoli EE Jeiykof (Urljuel)
Zan. R.9 Kojxifd Dipsaro tafp-tolat naizjiz

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.

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.1.0"
dependencies {
  api(compose.foundation)
  api(compose.material)
  api(compose.runtime)
  api(compose.ui)
}
val commonMain by getting { 
  dependencies {
    api(compose.foundation)
    api(compose.material)
    api(compose.runtime)
    api(compose.ui)
  }
}

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

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.

api(project(":kamel-image"))
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)
      }
    }
  }
}
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.

api(project(":precompose"))
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.raywenderlich.learn.ui.utils.SingleLiveEvent
import moe.tlaster.precompose.viewmodel.ViewModel
import moe.tlaster.precompose.viewmodel.viewModelScope 
val items: MutableState<List<RWEntry>> = mutableStateOf(emptyList())
items.value = bookmarks
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 Android and desktop 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.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import moe.tlaster.precompose.lifecycle.PreComposeActivity
import moe.tlaster.precompose.lifecycle.setContent
if (!isSelected) {
  selectedIndex.value = index
  navController.navigate(screen.route)
}
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController
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

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.

api("ca.gosyer:accompanist-pager:0.20.1")
api("ca.gosyer:accompanist-pager-indicators:0.20.1")
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
  }
}

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.

@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
@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)
@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")
val icon = icLauncher()
val icon = icMore()
import androidx.compose.ui.res.painterResource
val icon = icBrand()
import androidx.compose.ui.res.painterResource
val icon: @Composable () -> Unit
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
    )
  }
)
import androidx.annotation.DrawableRes
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
screen.icon()
import androidx.compose.material.Icon
import androidx.compose.ui.res.painterResource
val icon = icSearch()
import androidx.compose.ui.res.painterResource

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.

@Composable
expect fun Font(name: String, res: String, weight: FontWeight, style: FontStyle): Font
@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)
}
@Composable
actual fun Font(name: String, res: String, weight: FontWeight, style: FontStyle): Font =
    androidx.compose.ui.text.platform.Font("font/$res.ttf", weight, style)
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
    )
  )
}
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),
)
fontFamily = Fonts.BitterFontFamily(),
fontFamily = Fonts.BitterFontFamily(),
fontFamily = Fonts.BitterFontFamily(),
fontFamily = Fonts.BitterFontFamily(),
fontFamily = Fonts.BitterFontFamily(),
fontFamily = Fonts.BitterFontFamily(),
fontFamily = Fonts.BitterFontFamily(),
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).

classpath("dev.icerock.moko:resources-generator:0.18.0")
id("dev.icerock.mobile.multiplatform-resources")
multiplatformResources {
  multiplatformResourcesPackage = "com.raywenderlich.learn"
}
api("dev.icerock.moko:resources:0.18.0")
expect fun getString(resId: StringResource): String
actual fun getString(resId: StringResource): String {
  return StringDesc.Resource(resId).toString(appContext)
}
actual fun getString(resId: StringResource): String {
  return StringDesc.Resource(resId).localized()
}
text = getString(MR.strings.empty_screen_bookmarks)
import androidx.compose.ui.res.stringResource
import com.raywenderlich.learn.android.R
val description = getString(MR.strings.description_feed_icon)
text = getString(MR.strings.app_ray_wenderlich),
val description = getString(MR.strings.description_more)
import androidx.compose.ui.res.stringResource
import com.raywenderlich.learn.android.R
val description = getString(MR.strings.description_preview_error)
import androidx.compose.ui.res.stringResource
import com.raywenderlich.learn.android.R
val text = if (item.value.bookmarked) {
  getString(MR.strings.action_remove_bookmarks)
} else {
  getString(MR.strings.action_add_bookmarks)
}
text = text
text = getString(MR.strings.action_share_link),
import androidx.compose.ui.res.stringResource
import com.raywenderlich.learn.android.R
AddEmptyScreen(getString(MR.strings.empty_screen_loading))
import androidx.compose.ui.res.stringResource
import com.raywenderlich.learn.android.R
val title: String,
title = getString(MR.strings.navigation_home),
contentDescription = getString(MR.strings.navigation_home)
title = getString(MR.strings.navigation_bookmark),
contentDescription = getString(MR.strings.navigation_bookmark)
title = getString(MR.strings.navigation_latest),
contentDescription = getString(MR.strings.navigation_latest)
title = getString(MR.strings.navigation_search),
contentDescription = getString(MR.strings.navigation_search)
import androidx.annotation.StringRes
import com.raywenderlich.learn.android.R
text = screen.title,
import androidx.compose.ui.res.stringResource
text = getString(MR.strings.app_name),
import androidx.compose.ui.res.stringResource
import com.raywenderlich.learn.android.R
text = getString(MR.strings.search_hint),
val description = getString(MR.strings.description_search)
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:

MainScreen(
  feeds = items,
  bookmarks = bookmarks,
  onUpdateBookmark = { updateBookmark(it) },
  onShareAsLink = {},
  onOpenEntry = { openLink(it) }
)
implementation(project(":shared"))
implementation(project(":shared-ui"))
implementation(project(":shared-action"))
implementation(compose.desktop.currentOs)
dependencies {
  implementation(project(":shared"))
  implementation(project(":shared-ui"))
  implementation(project(":shared-action"))

  implementation("com.google.android.material:material:1.5.0")
}
Fig. C.3 Feed in Android App
Fiq. Z.6 Niej os Ojqpeuc Ijy

Fig. C.4 Feed in Desktop App
Sek. R.1 Geux ik Wuwthut Onf

Where to go from here?

Congratulations! You just finished Kotlin Multiplatform by Tutorials. What a ride! Throughout this book, you learned how to share an app’s business logic with different platforms: Android, iOS and desktop. Now that you’ve mastered KMP, perhaps you’re interested in learning more about Jetpack Compose and SwiftUI. These books are the perfect starting point!

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 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