Logika bisnis sudah berbagi melalui commonMain. Pertanyaan berikutnya: bagaimana dengan UI? Tiga pendekatan umum dipakai di proyek KMP. Pertama, UI sepenuhnya native—SwiftUI di iOS, Jetpack Compose di Android, JavaFX di JVM. Kedua, ViewModel bersama dengan UI native—logika state di commonMain, rendering tetap per platform. Ketiga, Compose Multiplatform—UI Compose ditulis sekali untuk Android, iOS, Desktop, dan Web. Pada chapter ini kita akan membandingkan ketiga pendekatan, melihat demo ViewModel bersama yang di-render oleh empat toolkit berbeda, dan memahami trade-off masing-masing.

Tiga Strategi UI #

Setiap strategi memiliki trade-off antara berbagi kode dan nuansa native. Native memberikan nuansa 100% asli per platform tetapi tidak ada kode UI yang dibagi. Shared ViewModel berbagi logika state tetapi tetap menulis UI per platform. Compose Multiplatform berbagi UI penuh tetapi dengan nuansa yang konsisten lintas platform—tidak 100% asli.

StrategiKode yang dibagiLook & FeelTim yang cocok
NativeTidak ada100% native per platformTim kecil, prioritas nuansa asli
Shared ViewModelLogika state100% native per platformTim dengan UI designer per platform
Compose MultiplatformUI + ViewModelKonsisten lintas platformTim yang ingin satu codebase UI

Pilihan strategi tidak harus seragam—beberapa layar dapat pakai native, layar lain pakai Compose Multiplatform. Yang penting konsisten dalam satu layar agar maintenance mudah.

Shared ViewModel: Pola Paling Umum #

Strategi paling umum di proyek KMP adalah berbagi ViewModel dan state, sementara UI ditulis per platform. ViewModel di commonMain memakai kotlinx.coroutines.flow untuk emit state; UI di Android (Compose) dan iOS (SwiftUI) berlangganan ke flow tersebut.

kotlin commonMain: shared ViewModel
class TodoViewModel {
    private val _state = MutableStateFlow(TodoState())
    val state: StateFlow<TodoState> = _state

    fun tambahTugas(judul: String) {
        _state.update { it.copy(daftar = it.daftar + judul) }
    }
}
kotlin androidMain: binding Compose
@Composable
fun TodoScreen(vm: TodoViewModel) {
    val state by vm.state.collectAsState()
    Column {
        Text(state.judul)
        state.daftar.forEach { tugas -> Text(tugas) }
    }
}
kotlin iosMain: binding SwiftUI (interop)
class TodoObserver: ObservableObject {
    let vm = TodoViewModel()
    @Published var state: TodoState = TodoState()

    init() {
        vm.state.collect { snapshot in
            DispatchQueue.main.async {
                self.state = snapshot
            }
        }
    }
}

ViewModel tidak peduli toolkit UI—ia hanya mengelola state. UI platform berlangganan dan merender.

Demo Empat Binding UI #

Demo berikut mensimulasikan satu ViewModel yang sama di-render oleh empat binding berbeda—Compose, SwiftUI, JavaFX, dan Compose Multiplatform. Pola yang ditampilkan—state berubah, listener di-notify, binding merender—adalah pola yang sama dipakai di ViewModel + StateFlow di KMP nyata.

kotlin
// multiplatform_ui_overview.kt
// Opsi UI di KMP: native (SwiftUI/Compose), shared ViewModel, Compose Multiplatform.
// Demo: simulasi pola UI dengan ViewModel bersama + 3 binding platform.

// --- commonMain: model dan ViewModel bersama ---
data class StatusLayar(val judul: String, val daftar: List<String>, val isLoading: Boolean)

class TodoViewModel {
    private val _state = mutableListOf<String>()
    private var listener: ((StatusLayar) -> Unit)? = null

    fun setObserver(cb: (StatusLayar) -> Unit) {
        listener = cb
        emitState()
    }

    fun tambahTugas(judul: String) {
        if (judul.isBlank()) return
        _state.add(judul)
        emitState()
    }

    fun hapusTugas(index: Int) {
        if (index < 0 || index >= _state.size) return
        _state.removeAt(index)
        emitState()
    }

    fun toggleLoading() {
        emitState(loading = true)
        // Simulasi delay singkat - di kode nyata pakai coroutine
        emitState(loading = false)
    }

    private fun emitState(loading: Boolean = false) {
        listener?.invoke(StatusLayar(
            judul = "Daftar Tugas (${_state.size})",
            daftar = _state.toList(),
            isLoading = loading
        ))
    }
}

// --- Platform: binding UI ---
// Pada Android (Compose): ViewModel dipanggil dari Composable
// Pada iOS (SwiftUI): ViewModel dipanggil dari ObservableObject
// Pada JVM (Swing/JavaFX): ViewModel dipanggil dari controller

interface UiBinding {
    val nama: String
    fun render(state: StatusLayar)
}

class ComposeBinding : UiBinding {
    override val nama = "Jetpack Compose (Android)"
    override fun render(state: StatusLayar) {
        println("  [Compose] @Composable")
        println("    Text(\"${state.judul}\")")
        if (state.isLoading) println("    CircularProgressIndicator()")
        else state.daftar.forEachIndexed { i, t ->
            println("    Row { Text(\"  $i. $t\") IconButton { hapus($i) } }")
        }
    }
}

class SwiftUiBinding : UiBinding {
    override val nama = "SwiftUI (iOS)"
    override fun render(state: StatusLayar) {
        println("  [SwiftUI] struct TodoView: View")
        println("    Text(\"\\(state.judul)\")")
        if (state.isLoading) println("    ProgressView()")
        else state.daftar.forEachIndexed { i, t ->
            println("    HStack { Text(\"  $i. $t\"); Button(\"hapus\") { hapus($i) } }")
        }
    }
}

class JavaFxBinding : UiBinding {
    override val nama = "JavaFX (JVM Desktop)"
    override fun render(state: StatusLayar) {
        println("  [JavaFX] VBox")
        println("    Label(\"${state.judul}\")")
        if (state.isLoading) println("    ProgressIndicator()")
        else state.daftar.forEachIndexed { i, t ->
            println("    HBox: Label(\"  $i. $t\"), Button(\"hapus\").setOnAction { hapus($i) }")
        }
    }
}

class ComposeMultiplatformBinding : UiBinding {
    override val nama = "Compose Multiplatform (Android + iOS + Desktop)"
    override fun render(state: StatusLayar) {
        println("  [Compose Multiplatform] @Composable fun TodoScreen(vm: TodoViewModel)")
        println("    Text(\"${state.judul}\")")
        if (state.isLoading) println("    CircularProgressIndicator()")
        else state.daftar.forEachIndexed { i, t ->
            println("    Row { Text(\"  $i. $t\"); IconButton { vm.hapusTugas($i) } }")
        }
    }
}

// --- Demo ---

fun main() {
    println("=== Multiplatform UI Overview ===\n")
    println("Pendekatan UI yang berbeda dengan ViewModel yang sama:\n")

    val bindings = listOf(
        ComposeBinding(),
        SwiftUiBinding(),
        JavaFxBinding(),
        ComposeMultiplatformBinding()
    )

    for (binding in bindings) {
        println("--- Binding: ${binding.nama} ---")
        val vm = TodoViewModel()
        vm.setObserver { state -> binding.render(state) }
        vm.tambahTugas("Belajar KMP")
        vm.tambahTugas("Setup Gradle")
        vm.hapusTugas(0)
        println()
    }

    println("=== Perbandingan strategi UI ===")
    println()
    println("Strategi                | Kode UI yang dibagi | Look & Feel")
    println("------------------------|---------------------|-------------")
    println("Native (1)               | Tidak               | 100% native")
    println("Shared ViewModel (2)     | ViewModel saja      | 100% native")
    println("Compose Multiplatform (3)| UI + ViewModel      | Konsisten lintas platform")
    println()
    println("(1) Tulis UI lengkap per platform (SwiftUI + Compose + JavaFX)")
    println("(2) Bagikan ViewModel/logika; tulis UI per platform")
    println("(3) Bagikan UI Compose untuk Android, iOS, Desktop (beta), Web")
}

Output yang Dihasilkan:

text
=== Multiplatform UI Overview ===

Pendekatan UI yang berbeda dengan ViewModel yang sama:

--- Binding: Jetpack Compose (Android) ---
  [Compose] @Composable
    Text("Daftar Tugas (0)")
  [Compose] @Composable
    Text("Daftar Tugas (1)")
    Row { Text("  0. Belajar KMP") IconButton { hapus(0) } }
  [Compose] @Composable
    Text("Daftar Tugas (2)")
    Row { Text("  0. Belajar KMP") IconButton { hapus(0) } }
    Row { Text("  1. Setup Gradle") IconButton { hapus(1) } }
  [Compose] @Composable
    Text("Daftar Tugas (1)")
    Row { Text("  0. Setup Gradle") IconButton { hapus(0) } }

--- Binding: SwiftUI (iOS) ---
  [SwiftUI] struct TodoView: View
    Text("\(state.judul)")
  [SwiftUI] struct TodoView: View
    Text("\(state.judul)")
    HStack { Text("  0. Belajar KMP"); Button("hapus") { hapus(0) } }
  [SwiftUI] struct TodoView: View
    Text("\(state.judul)")
    HStack { Text("  0. Belajar KMP"); Button("hapus") { hapus(0) } }
    HStack { Text("  1. Setup Gradle"); Button("hapus") { hapus(1) } }
  [SwiftUI] struct TodoView: View
    Text("\(state.judul)")
    HStack { Text("  0. Setup Gradle"); Button("hapus") { hapus(0) } }

--- Binding: JavaFX (JVM Desktop) ---
  [JavaFX] VBox
    Label("Daftar Tugas (0)")
  [JavaFX] VBox
    Label("Daftar Tugas (1)")
    HBox: Label("  0. Belajar KMP"), Button("hapus").setOnAction { hapus(0) }
  [JavaFX] VBox
    Label("Daftar Tugas (2)")
    HBox: Label("  0. Belajar KMP"), Button("hapus").setOnAction { hapus(0) }
    HBox: Label("  1. Setup Gradle"), Button("hapus").setOnAction { hapus(1) }
  [JavaFX] VBox
    Label("Daftar Tugas (1)")
    HBox: Label("  0. Setup Gradle"), Button("hapus").setOnAction { hapus(0) }

--- Binding: Compose Multiplatform (Android + iOS + Desktop) ---
  [Compose Multiplatform] @Composable fun TodoScreen(vm: TodoViewModel)
    Text("Daftar Tugas (0)")
  [Compose Multiplatform] @Composable fun TodoScreen(vm: TodoViewModel)
    Text("Daftar Tugas (1)")
    Row { Text("  0. Belajar KMP"); IconButton { vm.hapusTugas(0) } }
  [Compose Multiplatform] @Composable fun TodoScreen(vm: TodoViewModel)
    Text("Daftar Tugas (2)")
    Row { Text("  0. Belajar KMP"); IconButton { vm.hapusTugas(0) } }
    Row { Text("  1. Setup Gradle"); IconButton { vm.hapusTugas(1) } }
  [Compose Multiplatform] @Composable fun TodoScreen(vm: TodoViewModel)
    Text("Daftar Tugas (1)")
    Row { Text("  0. Setup Gradle"); IconButton { vm.hapusTugas(0) } }

=== Perbandingan strategi UI ===

Strategi                | Kode UI yang dibagi | Look & Feel
------------------------|---------------------|-------------
Native (1)               | Tidak               | 100% native
Shared ViewModel (2)     | ViewModel saja      | 100% native
Compose Multiplatform (3)| UI + ViewModel      | Konsisten lintas platform

(1) Tulis UI lengkap per platform (SwiftUI + Compose + JavaFX)
(2) Bagikan ViewModel/logika; tulis UI per platform
(3) Bagikan UI Compose untuk Android, iOS, Desktop (beta), Web

Mari kita bedah bagian kunci dari kode di atas:

  1. data class StatusLayar: model state di commonMain. Berisi judul, daftar, isLoading. Immutable sehingga aman dipassing ke UI.
  2. class TodoViewModel: logika state di commonMain. Memakai listener callback (StatusLayar) -> Unit—di proyek nyata digantikan StateFlow. Method tambahTugas, hapusTugas, toggleLoading adalah operasi bisnis.
  3. interface UiBinding: abstraksi rendering di commonMain. Hanya satu method render(state). Binding platform mengimplementasikan interface ini dengan toolkitnya masing-masing.
  4. ComposeBinding, SwiftUiBinding, JavaFxBinding, ComposeMultiplatformBinding: empat implementasi yang menampilkan struktur UI yang akan di-generate oleh toolkit masing-masing. Output adalah pseudo-code—bukan kode Compose/SwiftUI sebenarnya—tetapi memperlihatkan perbedaan toolkit.
  5. main() loop empat binding: ViewModel yang sama (TodoViewModel) dihubungkan ke empat binding berbeda. Setiap kali tambahTugas dipanggil, listener di-notify, dan binding yang aktif merender. Struktur state identik—hanya cara rendering yang berbeda.
  6. Output menunjukkan empat toolkit: setiap binding menampilkan struktur UI yang sedikit berbeda—@Composable vs struct View vs VBox—tetapi semua merender state yang sama.

Compose Multiplatform: UI Lengkap Bersama #

Compose Multiplatform (CMP) adalah perluasan Jetpack Compose untuk berjalan di iOS, Desktop, dan Web. Dengan CMP, kita menulis satu @Composable fun TodoScreen() yang berjalan di Android, iOS, Desktop—tidak ada kode UI yang diduplikasi.

kotlin commonMain: Compose Multiplatform
@Composable
fun TodoScreen(vm: TodoViewModel) {
    val state by vm.state.collectAsState()
    Column {
        Text(state.judul)
        for ((i, tugas) in state.daftar.withIndex()) {
            Row {
                Text(tugas)
                Button(onClick = { vm.hapusTugas(i) }) { Text("Hapus") }
            }
        }
    }
}

Kode Compose ini di-compile ke bytecode JVM untuk Desktop, kode native iOS untuk iOS, dan kode IR untuk Web. Satu file .kt, empat target.

Referensi Lanjutan #

Penutup #

Tiga strategi UI—native, shared ViewModel, Compose Multiplatform—memberi tim fleksibilitas. Tim kecil yang ingin nuansa asli memilih native atau shared ViewModel; tim yang ingin satu codebase memilih CMP. Chapter berikutnya akan masuk lebih dalam ke Compose Multiplatform: cara setup, target yang didukung, dan trade-off yang harus dipertimbangkan.