Compose Multiplatform - Shared UI
This tutorial demonstrates a Compose Multiplatform application that displays museum art from The Metropolitan Museum of Art Collection API. It uses Koin for dependency injection across Android and iOS platforms with shared UI. You need around 20 min to complete the tutorial.
update - 2024-11-12
Looking for the annotations version of this tutorial? Check out Compose Multiplatform & Annotations which uses Koin Annotations for compile-time verification and automatic module discovery.
Get the code
Application Overview
The application fetches museum art objects from a remote API and displays them in a list. Users can tap on an item to see detailed information:
MuseumAPI -> MuseumStorage -> MuseumRepository -> ViewModels -> Compose UI
Technologies used:
- Compose Multiplatform for shared UI (Android & iOS)
- Ktor for HTTP networking
- Koin for dependency injection
- Kotlin Coroutines & Flow for async operations
- Navigation Compose for routing
The Data Layer
All the common/shared code is located in
composeAppGradle project
MuseumObject Model
The museum art object data class:
@Serializable
data class MuseumObject(
val objectID: Int,
val title: String,
val artistDisplayName: String,
val medium: String,
val dimensions: String,
val objectURL: String,
val objectDate: String,
val primaryImage: String,
val primaryImageSmall: String,
val repository: String,
val department: String,
val creditLine: String,
)
MuseumApi - Network Layer
We create an API interface to fetch data from The Metropolitan Museum of Art API:
interface MuseumApi {
suspend fun getData(): List<MuseumObject>
}
class KtorMuseumApi(private val client: HttpClient) : MuseumApi {
private companion object {
const val API_URL = "https://raw.githubusercontent.com/Kotlin/KMP-App-Template/main/list.json"
}
override suspend fun getData(): List<MuseumObject> {
return try {
client.get(API_URL).body()
} catch (e: Exception) {
if (e is CancellationException) throw e
e.printStackTrace()
emptyList()
}
}
}
MuseumStorage - Local Caching
We create a storage interface to cache museum objects locally:
interface MuseumStorage {
suspend fun saveObjects(newObjects: List<MuseumObject>)
fun getObjectById(objectId: Int): Flow<MuseumObject?>
fun getObjects(): Flow<List<MuseumObject>>
}
class InMemoryMuseumStorage : MuseumStorage {
private val storedObjects = MutableStateFlow(emptyList<MuseumObject>())
override suspend fun saveObjects(newObjects: List<MuseumObject>) {
storedObjects.value = newObjects
}
override fun getObjectById(objectId: Int): Flow<MuseumObject?> {
return storedObjects.map { objects ->
objects.find { it.objectID == objectId }
}
}
override fun getObjects(): Flow<List<MuseumObject>> = storedObjects
}
MuseumRepository
The repository coordinates between the API and storage:
class MuseumRepository(
private val museumApi: MuseumApi,
private val museumStorage: MuseumStorage,
) {
private val scope = CoroutineScope(SupervisorJob())
init {
initialize()
}
fun initialize() {
scope.launch {
refresh()
}
}
suspend fun refresh() {
museumStorage.saveObjects(museumApi.getData())
}
fun getObjects(): Flow<List<MuseumObject>> = museumStorage.getObjects()
fun getObjectById(objectId: Int): Flow<MuseumObject?> = museumStorage.getObjectById(objectId)
}
The Shared Koin Modules
Use the module function to declare Koin modules. We organize our dependencies into separate modules for better structure:
Data Module
val dataModule = module {
// HttpClient for Ktor
single {
val json = Json { ignoreUnknownKeys = true }
HttpClient {
install(ContentNegotiation) {
json(json, contentType = ContentType.Any)
}
}
}
// API, Storage, and Repository
singleOf(::KtorMuseumApi) { bind<MuseumApi>() }
singleOf(::InMemoryMuseumStorage) { bind<MuseumStorage>() }
singleOf(::MuseumRepository) {
createdAtStart() // Repository initializes on app start
}
}
ViewModel Module
Let's create ViewModels for our two screens:
// List screen ViewModel
class ListViewModel(museumRepository: MuseumRepository) : ViewModel() {
val objects: StateFlow<List<MuseumObject>> =
museumRepository.getObjects()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}
// Detail screen ViewModel
class DetailViewModel(private val museumRepository: MuseumRepository) : ViewModel() {
fun getObject(objectId: Int): StateFlow<MuseumObject?> {
return museumRepository.getObjectById(objectId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
}
}
Declare them in the ViewModel module:
val viewModelModule = module {
viewModelOf(::ListViewModel)
viewModelOf(::DetailViewModel)
}
Platform-Specific Module
For platform-specific components (Android vs iOS):
val nativeComponentModule = module {
singleOf(::NativeComponent)
}
Main App Module
Combine all modules:
val appModule = module {
includes(dataModule, viewModelModule, nativeComponentModule)
}
The Koin modules are organized and can be initialized from both Android and iOS using the initKoin() function.
Native Component
For platform-specific information (Android vs iOS), we use an expect/actual pattern:
// commonMain
interface NativeComponent {
fun getInfo(): String
}
// androidMain
class NativeComponent {
fun getInfo(): String = "Android ${android.os.Build.VERSION.SDK_INT}"
}
// iosMain
class NativeComponent {
fun getInfo(): String = "iOS ${UIDevice.currentDevice.systemVersion}"
}
Injecting ViewModels in Compose
All the common Compose app is located in
commonMainfromcomposeAppGradle module
The ViewModels are injected using koinViewModel() in Compose:
@Composable
fun App() {
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
) {
Surface {
val navController: NavHostController = rememberNavController()
NavHost(navController = navController, startDestination = ListDestination) {
composable<ListDestination> {
val vm = koinViewModel<ListViewModel>()
ListScreen(viewModel = vm, navigateToDetails = { objectId ->
navController.navigate(DetailDestination(objectId))
})
}
composable<DetailDestination> { backStackEntry ->
val vm = koinViewModel<DetailViewModel>()
DetailScreen(
objectId = backStackEntry.toRoute<DetailDestination>().objectId,
viewModel = vm,
navigateBack = { navController.popBackStack() }
)
}
}
}
}
}
The koinViewModel() function retrieves ViewModel instances and binds them to the Compose lifecycle.
Starting Koin
Initialize Koin with the initKoin() function:
fun initKoin(configuration: KoinAppDeclaration? = null) {
startKoin {
includes(configuration)
modules(appModule)
}
val platformInfo = KoinPlatform.getKoin().get<NativeComponent>().getInfo()
println("Running on: $platformInfo")
}
Android Setup
In Android, Koin is initialized from the main Activity or Application class:
// Call from Android entry point
initKoin()
iOS Setup
All the iOS app is located in
iosAppfolder
In iOS, initialize Koin from the SwiftUI App entry point:
@main
struct iOSApp: App {
init() {
KoinKt.doInitKoin()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
The Compose UI is started with:
fun MainViewController() = ComposeUIViewController { App() }