Advanced Features for Compose Multiplatform Apps

Jetpack Compose has revolutionized Android UI development with its declarative syntax and composable functions. But its true potential shines when combined with Kotlin Multiplatform, allowing you to share code across multiple platforms such as Android, iOS, desktop, and web. This post delves into advanced features for building robust Compose Multiplatform applications.

What is Compose Multiplatform?

Compose Multiplatform is a UI framework that enables you to write UI code once in Kotlin and run it on various platforms using Jetpack Compose (Android) and Compose for Desktop/Web/iOS. It allows for significant code reuse, reduces development time, and ensures consistency across platforms.

Why Use Compose Multiplatform?

  • Code Reusability: Share UI and business logic across different platforms.
  • Consistent UI: Maintain a similar look and feel on all platforms.
  • Faster Development: Write once, deploy everywhere, saving time and resources.
  • Unified Tech Stack: Leverage the power of Kotlin across all platforms.

Advanced Features in Compose Multiplatform

1. Platform-Specific Implementations

While Compose Multiplatform promotes code sharing, you’ll often encounter scenarios where platform-specific implementations are necessary. This can be achieved using expect and actual declarations.

Example: Accessing Platform-Specific Resources

First, define an expect function in your common module:


// in commonMain
expect fun getPlatformName(): String

Then, provide the actual implementations in the platform-specific modules:


// in androidMain
actual fun getPlatformName(): String = "Android"

// in iosMain
actual fun getPlatformName(): String = "iOS"

// in desktopMain
actual fun getPlatformName(): String = System.getProperty("os.name") ?: "Desktop"

You can then use this function in your common UI:


import androidx.compose.material.Text
import androidx.compose.runtime.Composable

@Composable
fun Greeting() {
    Text(text = "Hello from ${getPlatformName()}")
}

2. Dependency Injection

Dependency injection helps manage dependencies and promotes modularity in your multiplatform app. Popular DI frameworks like Koin and Kodein can be used with Compose Multiplatform.

Example: Using Koin for Dependency Injection

Add Koin dependencies to your build.gradle.kts file:


dependencies {
    implementation("io.insert-koin:koin-core:3.4.0")
    implementation("io.insert-koin:koin-compose:3.4.0") // for Compose
}

Define a module in your common module:


// in commonMain
import org.koin.core.module.Module
import org.koin.dsl.module

val appModule: Module = module {
    single { GreetingRepository() }
    single { GreetingViewModel(get()) }
}

class GreetingRepository {
    fun getGreeting(): String = "Hello from Koin!"
}

class GreetingViewModel(private val repository: GreetingRepository) {
    fun getGreeting(): String = repository.getGreeting()
}

Start Koin in your platform-specific entry points:


// in androidMain
import android.app.Application
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin

class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidContext(this@MyApplication)
            modules(appModule)
        }
    }
}

Finally, inject and use the GreetingViewModel in your Compose UI:


import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import org.koin.compose.koinInject

@Composable
fun KoinGreeting() {
    val viewModel: GreetingViewModel = koinInject()
    Text(text = viewModel.getGreeting())
}

3. State Management

Effective state management is critical in any UI application. In Compose Multiplatform, you can leverage Compose’s built-in state management features like remember, mutableStateOf, and ViewModel along with libraries like StateFlow and MutableStateFlow for more complex scenarios.

Example: Using StateFlow for Asynchronous Data

Add Kotlin Coroutines Core and StateFlow dependencies in your build.gradle.kts file:


dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.7.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-javafx:1.7.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.0")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-debug:1.7.0")

    implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.1") // Make lifecycle-aware components compatible with Compose runtime.
}

Create a ViewModel with a StateFlow:


import androidx.compose.runtime.mutableStateOf
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

class CounterViewModel {
    private val _count = MutableStateFlow(0)
    val count: StateFlow = _count.asStateFlow()

    fun increment() {
        _count.value = _count.value + 1
    }
}

Use collectAsState to observe the StateFlow in your Compose UI:


import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.tooling.preview.Preview

@Composable
fun CounterScreen() {
    val viewModel = remember { CounterViewModel() }
    val countState = viewModel.count.collectAsState()

    Column {
        Text("Count: ${countState.value}")
        Button(onClick = { viewModel.increment() }) {
            Text("Increment")
        }
    }
}

4. Platform-Specific UI Libraries and Native Integration

In some scenarios, you may need to use platform-specific UI libraries or integrate with native APIs. This can be achieved using the same expect/actual mechanism.

Consider an iOS app needing native iOS functionality. You would use Swift/Objective-C with Kotlin interoperability, known as Swift/Obj-C interop. Alternatively, for native Android functionality, Java or Kotlin interoperability applies.

Example: Integrating with Native Android Features

Create an expect function:


// In commonMain
expect fun showToast(message: String)

And provide actual implementation in the Android module using android.widget.Toast:


// In androidMain
import android.widget.Toast

actual fun showToast(message: String) {
    // get an instance of android context via, e.g. the context() extension
    val appContext = MyApplication.context()
    Toast.makeText(appContext, message, Toast.LENGTH_SHORT).show()
}

5. Handling Configuration Changes

In Android development, configuration changes like screen rotations can cause activities to be recreated. Using rememberSaveable and ViewModel to survive configuration changes is vital.

Example: Persisting UI State

// Use SavedStateHandle with a ViewModel
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewmodel.compose.viewModel

class MyViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
    var name = mutableStateOf(savedStateHandle["name"] ?: "")
        private set

    fun saveName(newName: String) {
        name.value = newName
        savedStateHandle["name"] = newName
    }
}

// Compose function utilizing the ViewModel
import androidx.compose.material.TextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview

@Composable
fun NameSaver() {
    val viewModel = viewModel()

    TextField(
        value = viewModel.name.value,
        onValueChange = { newName -> viewModel.saveName(newName) },
        label = { Text("Enter Name") }
    )
}

6. Working with Local Databases

Use libraries like SQLDelight for multiplatform database access.


dependencies {
  implementation("app.cash.sqldelight:runtime:2.0.0")
  androidMainImplementation("app.cash.sqldelight:android-driver:2.0.0")
  iosMainImplementation("app.cash.sqldelight:native-driver:2.0.0")
  desktopMainImplementation("app.cash.sqldelight:sqlite-driver:2.0.0")
}

Setup SQLDelight with expect/actual classes. SQLDelight is able to generate Kotlin code representing database tables and classes.

7. Building Custom Compose Components

To build a consistent UI across all platforms, use and expand Compose to build highly customizable, multiplatform custom components.

Example: Customizable Buttons

// In commonMain

@Composable
fun CustomButton(
    text: String,
    onClick: () -> Unit
) {
   // Implement multi-platform look and feel.
   Button(
      onClick = onClick
   ) {
      Text(text)
   }
}

Conclusion

Compose Multiplatform offers a powerful and efficient way to build applications that span multiple platforms with shared code and consistent UI. By leveraging advanced features like platform-specific implementations, dependency injection, state management, native integration, and local database handling, developers can create truly robust and maintainable cross-platform applications with Jetpack Compose.