Antarmuka statis di Bagian 1 hanya menampilkan data contoh yang ditulis manual. Pada Bagian 2 kita membuat aplikasi yang benar-benar interaktif: pengguna dapat menambah transaksi dan melihat daftar diperbarui secara langsung. Untuk mencapai ini, kita menerapkan dua konsep inti Jetpack Compose: state hoisting (state dipegang ViewModel, UI hanya emit event) dan Compose Navigation (back stack antar layar). Kode contoh tetap menggunakan Kotlin murni yang dapat dijalankan di terminal, dengan pola yang sama persis dengan Compose asli.

State Hoisting #

Pola state hoisting Compose mendorong tiga prinsip:

  1. State dipegang oleh pemilik tunggal — biasanya ViewModel. UI hanya membaca dan menampilkan.
  2. Event naik dari UI ke ViewModel — UI tidak mengubah state langsung; UI mengirim event.
  3. State mengalir turun ke UI secara observable — UI mengamati StateFlow dan render ulang otomatis saat state berubah.

Pola ini membuat UI mudah diuji (cukup beri state, periksa output) dan ViewModel reusable di banyak layar. Saat state berubah, hanya UI yang mengamati state terkait yang render ulang — bukan seluruh aplikasi.

ViewModel dengan StateFlow #

ViewModel adalah class yang memegang state UI dan bertahan saat konfigurasi berubah (rotasi layar). StateFlow<T> adalah hot flow yang selalu memancarkan nilai terbaru ke observer. Kode berikut menunjukkan implementasi sederhana keduanya dalam Kotlin murni:

kotlin
// Bagian 2: State management dan Compose Navigation.
// Mendemonstrasikan pola ViewModel dengan StateFlow dan navigasi
// antar layar via back stack. State hoisting: UI mengirim event,
// ViewModel memegang state.

enum class JenisTransaksi { PEMASUKAN, PENGELUARAN }

data class Transaksi(
    val id: Long,
    val jenis: JenisTransaksi,
    val jumlah: Long,
    val kategori: String,
    val catatan: String
)

data class UiStateDaftar(
    val transaksi: List<Transaksi> = emptyList(),
    val sedangMemuat: Boolean = false
)

// Simulasi StateFlow<T>: state observable yang dapat dikoleksi.
class StateFlow<T>(initial: T) {
    private var nilai: T = initial
    private val observer = mutableListOf<(T) -> Unit>()

    fun value(): T = nilai
    fun update(transform: (T) -> T) {
        nilai = transform(nilai)
        observer.forEach { it(nilai) }
    }
    fun collect(block: (T) -> Unit) {
        observer.add(block)
        block(nilai) // emit nilai terbaru saat mulai mengamati
    }
}

sealed class Event {
    data class TambahTransaksi(val t: Transaksi) : Event()
    object MuatData : Event()
}

class TransaksiViewModel {
    val state = StateFlow(UiStateDaftar(sedangMemuat = true))
    private var urutan = 0L

    fun onEvent(e: Event) {
        when (e) {
            is Event.MuatData -> {
                // Simulasi load dari database
                state.update { it.copy(sedangMemuat = false) }
            }
            is Event.TambahTransaksi -> {
                urutan += 1
                val baru = e.t.copy(id = urutan)
                state.update { ui ->
                    ui.copy(transaksi = ui.transaksi + baru)
                }
            }
        }
    }
}

// Compose Navigation: back stack daftar string route.
class NavController(routeAwal: String) {
    private val backStack = mutableListOf(routeAwal)

    fun routeSekarang(): String = backStack.last()
    fun navigate(route: String) {
        backStack.add(route)
        println("  -> navigate ke: $route | stack=${backStack}")
    }
    fun popBack(): Boolean {
        if (backStack.size <= 1) return false
        backStack.removeAt(backStack.lastIndex)
        println("  <- popBack ke: ${backStack.last()} | stack=${backStack}")
        return true
    }
    fun stackSize(): Int = backStack.size
}

fun main() {
    val vm = TransaksiViewModel()
    val nav = NavController("daftar")

    println("=== Initial state ===")
    println("route: ${nav.routeSekarang()} | state.sedangMemuat=${vm.state.value().sedangMemuat}")

    println()
    println("=== UI mengamati state (collect) ===")
    var renderCount = 0
    vm.state.collect { ui ->
        renderCount += 1
        println("  Render #$renderCount: ${ui.transaksi.size} transaksi, sedangMemuat=${ui.sedangMemuat}")
    }

    println()
    println("=== Event: MuatData ===")
    vm.onEvent(Event.MuatData)

    println()
    println("=== User tap tombol Tambah -> navigate form ===")
    nav.navigate("formTambah")

    println()
    println("=== User isi form -> event TambahTransaksi ===")
    vm.onEvent(Event.TambahTransaksi(
        Transaksi(0, JenisTransaksi.PEMASUKAN, 5_000_000, "Gaji", "Gaji Januari")
    ))
    vm.onEvent(Event.TambahTransaksi(
        Transaksi(0, JenisTransaksi.PENGELUARAN, 750_000, "Makan", "Makan siang")
    ))

    println()
    println("=== User tap Simpan -> popBack ke daftar ===")
    nav.popBack()

    println()
    println("=== State akhir ===")
    println("route: ${nav.routeSekarang()} | stack=${nav.stackSize()}")
    println("Total transaksi: ${vm.state.value().transaksi.size}")
    vm.state.value().transaksi.forEach { t ->
        val arah = if (t.jenis == JenisTransaksi.PEMASUKAN) "+" else "-"
        println("  [${t.id}] $arah Rp${t.jumlah} ${t.kategori}")
    }
}

Output yang Dihasilkan:

text
=== Initial state ===
route: daftar | state.sedangMemuat=true

=== UI mengamati state (collect) ===
  Render #1: 0 transaksi, sedangMemuat=true

=== Event: MuatData ===
  Render #2: 0 transaksi, sedangMemuat=false

=== User tap tombol Tambah -> navigate form ===
  -> navigate ke: formTambah | stack=[daftar, formTambah]

=== User isi form -> event TambahTransaksi ===
  Render #3: 1 transaksi, sedangMemuat=false
  Render #4: 2 transaksi, sedangMemuat=false

=== User tap Simpan -> popBack ke daftar ===
  <- popBack ke: daftar | stack=[daftar]

=== State akhir ===
route: daftar | stack=1
Total transaksi: 2
  [1] + Rp5000000 Gaji
  [2] - Rp750000 Makan

Mari kita bedah komponen utama:

  1. class StateFlow<T>(initial: T): implementasi minimal StateFlow. Menyimpan nilai terkini dan daftar observer. update(transform) mengubah nilai lalu memberitahu semua observer. collect(block) mendaftarkan observer dan langsung memancarkan nilai terkini (perilaku StateFlow asli).
  2. sealed class Event: mendefinisikan semua event yang dikenal ViewModel. sealed membuat when di onEvent menjadi exhaustive — jika event baru ditambah tanpa handler, kompilator memperingatkan.
  3. vm.onEvent(Event.TambahTransaksi(...)): UI mengirim event ke ViewModel. ViewModel memutuskan bagaimana state berubah; UI tidak tahu detail implementasi. Inilah inti state hoisting.
  4. state.update { ui -> ui.copy(transaksi = ui.transaksi + baru) }: imutable update. copy() membuat instance UiStateDaftar baru dengan daftar transaksi yang diperluas. State lama tidak dimutasi; observer menerima referensi baru.
  5. NavController(routeAwal: String): simulasi Compose Navigation. Back stack adalah MutableList<String>; navigate push route baru, popBack pop route terakhir. Compose Navigation asli memakai navController.navigate("route") dan navController.popBackStack().

Compose Navigation #

Compose Navigation memakai konsep route sebagai identifier string. Setiap layar diidentifikasi oleh route unik:

kotlin NavHost Compose asli
NavHost(navController = navController, startDestination = "daftar") {
    composable("daftar") { LayarDaftar(vm, onTambah = { navController.navigate("formTambah") }) }
    composable("formTambah") { LayarForm(vm, onSimpan = { vm.onEvent(...); navController.popBack() }) }
}

Setiap composable("route") { ... } mendaftarkan layar untuk route tersebut. Saat navController.navigate("formTambah") dipanggil, Compose menambah route ke back stack dan render LayarForm. Saat popBack(), route terakhir dihapus dan layar sebelumnya dirender ulang.

Back Stack dan Lifecycle #

Back stack memungkinkan tombol Back peranti berfungsi: tap Back akan pop route terakhir. Compose Navigation otomatis menangani ini dengan BackHandler. Saat back stack tinggal satu route (start destination), tap Back keluar aplikasi.

Lifecycle layar Compose mengikuti back stack: layar yang tidak terlihat (di bawah top of stack) tetap di-compose tetapi lifecycle-nya STOPPED. Saat pengguna pop kembali, lifecycle kembali STARTED. ViewModel yang dipakai layar tetap bertahan selama back stack masih merujuknya.

ViewModel Survival saat Rotasi #

Salah satu keuntungan utama ViewModel adalah bertahan saat konfigurasi berubah (rotasi layar, ganti bahasa). Saat layar di-rotate, Activity dibuat ulang, tetapi ViewModel yang dipakai layar tersebut tetap hidup. State UI tidak hilang, dan pengguna tidak perlu mengisi form lagi.

Mekanisme ini dicapai dengan viewModel<TransaksiViewModel>() di Compose yang memakai ViewModelStoreOwner. Saat Activity dibuat ulang, ViewModelStore lama dipindahkan ke Activity baru, sehingga ViewModel tetap dipegang.

Referensi Lanjutan #

Ringkasan Bagian 2 #

State management dan navigasi kini lengkap. TransaksiViewModel memegang state via StateFlow, UI mengamati state dan mengirim event. NavController mengelola back stack dengan navigate dan popBack. State bertahan saat rotasi karena dipegang oleh ViewModel. Pada Bagian 3 kita menghubungkan ViewModel ke database lokal via Room, sehingga transaksi tersimpan persisten dan dapat dimuat kembali saat aplikasi dibuka.