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.