Status Lanjutan dan Efek Samping di Jetpack Compose

Dalam codelab ini, Anda akan mempelajari konsep lanjutan terkait API Status dan Efek Samping di Jetpack Compose. Kita akan melihat cara membuat holder status untuk composable stateful yang logikanya tidak sederhana, cara membuat coroutine dan memanggil fungsi penangguhan dari kode Compose, serta cara memicu efek samping untuk menyelesaikan berbagai kasus penggunaan.

Yang akan Anda pelajari

Yang akan Anda butuhkan

Yang akan Anda buat

Dalam codelab ini, kita akan memulai dari aplikasi yang belum selesai, yaitu aplikasi Studi materi Crane, dan menambahkan fitur untuk meningkatkan aplikasi.

1fb85e2ed0b8b592.gif

Mendapatkan kode

Kode untuk codelab ini dapat ditemukan di repositori GitHub android-compose-codelabs. Untuk melakukan clone kode ini, jalankan:

$ git clone https://github.com/googlecodelabs/android-compose-codelabs

Atau, Anda dapat mendownload repositori sebagai file ZIP:

Download Zip

Melihat aplikasi contoh

Kode yang baru saja Anda download berisi kode untuk semua codelab Compose yang tersedia. Untuk menyelesaikan codelab ini, buka project AdvancedStateAndSideEffectsCodelab di dalam Android Studio Arctic Fox.

Sebaiknya Anda memulai dengan kode di cabang utama dan mengikuti codelab langkah demi langkah sesuai kemampuan Anda.

Selama codelab, Anda akan melihat cuplikan kode yang harus ditambahkan ke project. Di beberapa tempat, Anda juga harus menghapus kode yang disebutkan secara eksplisit dalam komentar pada cuplikan kode.

Memahami kode dan menjalankan aplikasi contoh

Luangkan waktu sejenak untuk mempelajari struktur project dan menjalankan aplikasi.

37d39b9ac4a9d2fa.png

Saat menjalankan aplikasi dari cabang utama, Anda akan melihat bahwa beberapa fungsi seperti panel samping atau pemuatan tujuan penerbangan tidak berfungsi. Itulah yang akan kita lakukan pada langkah berikutnya di codelab.

1fb85e2ed0b8b592.gif

Pengujian UI

Aplikasi tercakup dalam pengujian UI sangat dasar yang tersedia di folder androidTest. Keduanya harus lulus cabang main dan end setiap saat.

[Opsional] Menampilkan peta di layar detail

Sama sekali tidak perlu menampilkan peta kota pada layar detail. Namun, jika ingin melihatnya, Anda perlu mendapatkan kunci API pribadi seperti yang dijelaskan dalam dokumentasi Maps. Sertakan kunci tersebut dalam file local.properties sebagai berikut:

// local.properties file
google.maps.key={insert_your_api_key_here}

Solusi untuk codelab

Untuk mendapatkan cabang end menggunakan git, gunakan perintah ini:

$ git clone -b end https://github.com/googlecodelabs/android-compose-codelabs

Atau, Anda dapat mendownload kode solusi dari sini:

Download kode akhir

Pertanyaan umum (FAQ)

Seperti yang mungkin telah Anda lihat saat menjalankan aplikasi dari cabang main, daftar tujuan penerbangan kosong! Untuk mengetahui apa yang terjadi, buka file home/CraneHome.kt dan lihat composable CraneHomeContent.

Ada komentar TODO di atas definisi suggestedDestinations yang ditetapkan untuk daftar kosong yang diingat. Ini yang ditampilkan di layar: daftar kosong! Pada langkah ini, kita akan memperbaikinya dan menampilkan tujuan yang disarankan yang diekspos MainViewModel.

9cadb1fd5f4ced3c.png

Buka home/MainViewModel.kt dan lihat suggestedDestinations StateFlow yang diinisialisasi ke destinationsRepository.destinations, lalu dapatkan update saat fungsi updatePeople atau toDestinationChanged dipanggil.

Kita ingin UI dalam composable CraneHomeContent diperbarui setiap kali ada item baru yang dimunculkan ke dalam aliran data suggestedDestinations. Kita dapat menggunakan fungsi StateFlow.collectAsState(). Jika digunakan dalam fungsi yang dapat dikomposisi, collectAsState() akan mengumpulkan nilai dari StateFlow dan menampilkan nilai terbaru melalui API Status Compose. Ini akan membuat kode Compose yang membaca nilai status tersebut mengomposisi ulang pada emisi baru.

Kembali ke composable CraneHomeContent dan ganti baris yang menetapkan suggestedDestinations dengan panggilan ke collectAsState di properti suggestedDestinations ViewModel:

import androidx.compose.runtime.collectAsState

@Composable
fun CraneHomeContent(
    onExploreItemClicked: OnExploreItemClicked,
    openDrawer: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: MainViewModel = viewModel(),
) {
    val suggestedDestinations by viewModel.suggestedDestinations.collectAsState()
    // ...
}

Jika menjalankan aplikasi, Anda akan melihat daftar tujuan terisi dan berubah setiap kali Anda mengetuk jumlah orang yang melakukan perjalanan.

4ec666a2d1ac0903.gif

Dalam project, ada file home/LandingScreen.kt yang tidak digunakan saat ini. Kita ingin menambahkan halaman landing ke aplikasi tersebut, yang berpotensi dapat digunakan untuk memuat semua data yang diperlukan di latar belakang.

Halaman landing akan menempati seluruh layar dan menampilkan logo aplikasi di bagian tengah layar. Idealnya, kita akan menampilkan layar, dan—setelah semua data dimuat—kita akan memberi tahu pemanggil bahwa halaman landing dapat ditutup menggunakan callback onTimeout.

Coroutine Kotlin adalah cara yang direkomendasikan untuk melakukan operasi asinkron di Android. Aplikasi biasanya akan menggunakan coroutine untuk memuat sesuatu di latar belakang saat dimulai. Jetpack Compose menawarkan API yang menjadikan penggunaan coroutine aman dalam lapisan UI. Karena aplikasi ini tidak berkomunikasi dengan backend, kita akan menggunakan fungsi delay coroutine untuk menyimulasikan pemuatan berbagai hal di latar belakang.

Efek samping pada Compose adalah perubahan pada status aplikasi yang terjadi di luar cakupan fungsi yang dapat dikomposisi. Perubahan status untuk menampilkan/menyembunyikan halaman landing akan terjadi di callback onTimeout dan karena sebelum memanggil onTimeout kita perlu memuat sesuatu menggunakan coroutine, perubahan status harus terjadi dalam konteks coroutine.

Untuk memanggil fungsi penangguhan secara aman dari dalam composable, gunakan LaunchedEffect API, yang memicu efek samping cakupan coroutine dalam Compose.

Saat memasuki Komposisi, LaunchedEffect akan meluncurkan coroutine dengan blok kode yang diteruskan sebagai parameter. Coroutine akan dibatalkan jika LaunchedEffect keluar dari komposisi.

Meskipun kode berikutnya salah, mari kita lihat cara menggunakan API ini dan diskusikan mengapa kode berikut salah. Kita akan memanggil composable LandingScreen nanti dalam langkah ini.

// home/LandingScreen.kt file

import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.delay

@Composable
fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        // Start a side effect to load things in the background
        // and call onTimeout() when finished.
        // Passing onTimeout as a parameter to LaunchedEffect
        // is wrong! Don't do this. We'll improve this code in a sec.
        LaunchedEffect(onTimeout) {
            delay(SplashWaitTime) // Simulates loading things
            onTimeout()
        }
        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

Beberapa API efek samping seperti LaunchedEffect menggunakan sejumlah variabel kunci sebagai parameter yang digunakan untuk memulai ulang efek setiap kali salah satu kunci tersebut berubah. Apakah Anda menemukan error tersebut? Kita tidak ingin memulai ulang efek jika onTimeout berubah.

Untuk memicu efek samping hanya sekali selama siklus proses composable ini, gunakan konstanta sebagai kunci, misalnya LaunchedEffect(true) { ... }. Namun, saat ini kita tidak sedang melindungi dari perubahan pada onTimeout.

Jika onTimeout berubah saat efek samping dalam proses, tidak ada jaminan bahwa onTimeout terakhir akan dipanggil saat efek selesai. Untuk menjamin hal ini dengan mengambil dan memperbarui nilai baru, gunakan rememberUpdatedState API:

// home/LandingScreen.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState

@Composable
fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        // This will always refer to the latest onTimeout function that
        // LandingScreen was recomposed with
        val currentOnTimeout by rememberUpdatedState(onTimeout)

        // Create an effect that matches the lifecycle of LandingScreen.
        // If LandingScreen recomposes or onTimeout changes,
        // the delay shouldn't start again.
        LaunchedEffect(true) {
            delay(SplashWaitTime)
            currentOnTimeout()
        }

        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

Menampilkan halaman landing

Sekarang kita perlu menampilkan halaman landing saat aplikasi dibuka. Buka file home/MainActivity.kt dan lihat composable MainScreen yang pertama kali dipanggil.

Pada composable MainScreen, kita cukup menambahkan status internal yang melacak apakah landing harus ditampilkan atau tidak:

// home/MainActivity.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue

@Composable
private fun MainScreen(onExploreItemClicked: OnExploreItemClicked) {
    Surface(color = MaterialTheme.colors.primary) {
        var showLandingScreen by remember { mutableStateOf(true) }
        if (showLandingScreen) {
            LandingScreen(onTimeout = { showLandingScreen = false })
        } else {
            CraneHome(onExploreItemClicked = onExploreItemClicked)
        }
    }
}

Jika menjalankan aplikasi sekarang, Anda akan melihat LandingScreen muncul dan menghilang setelah 2 detik.

fda616dda280aa3e.gif

Pada langkah ini, kita akan membuat panel navigasi berfungsi. Untuk saat ini, tidak ada yang terjadi jika Anda mencoba mengetuk menu tiga garis.

Buka file home/CraneHome.kt dan lihat composable CraneHome untuk mengetahui di mana kita harus membuka panel navigasi: di callback openDrawer.

Di CraneHome, kita memiliki scaffoldState yang berisi DrawerState. DrawerState memiliki metode untuk membuka dan menutup panel navigasi secara terprogram. Akan tetapi, jika Anda mencoba menulis scaffoldState.drawerState.open() dalam callback openDrawer, Anda akan mendapatkan error. Hal tersebut dikarenakan fungsi open adalah fungsi penangguhan. Kita berada di ranah coroutine lagi.

Selain API untuk membuat panggilan coroutine aman dari lapisan UI, beberapa API Compose merupakan fungsi penangguhan. Salah satu contohnya adalah API untuk membuka panel navigasi. Selain menangguhkan kode asinkron, fungsi penangguhan juga membantu menampilkan konsep yang terjadi dari waktu ke waktu. Membuka panel samping memerlukan waktu, gerakan, dan animasi potensial, dan hal ini tercermin sempurna dengan fungsi penangguhan, yang akan menangguhkan eksekusi coroutine tempatnya dipanggil hingga selesai dan melanjutkan eksekusi.

scaffoldState.drawerState.open() harus dipanggil dalam coroutine. Apa yang dapat kita lakukan? openDrawer adalah fungsi callback sederhana, sehingga:

  • Kita tidak bisa begitu saja memanggil fungsi penangguhan di dalamnya karena openDrawer tidak dieksekusi dalam konteks coroutine.
  • Kita tidak dapat menggunakan LaunchedEffect seperti sebelumnya, karena kita tidak dapat memanggil composable di openDrawer. Kita tidak ada dalam Komposisi.

Jika kita ingin dapat meluncurkan coroutine, cakupan mana yang harus kita gunakan? Idealnya, kita ingin CoroutineScope yang mengikuti siklus proses situs panggilannya. Untuk melakukannya, gunakan rememberCoroutineScope API. Cakupan akan otomatis dibatalkan setelah keluar dari Komposisi. Dengan cakupan tersebut, Anda dapat memulai coroutine saat tidak berada di Komposisi, misalnya, di callback openDrawer.

// home/CraneHome.kt file

import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch

@Composable
fun CraneHome(
    onExploreItemClicked: OnExploreItemClicked,
    modifier: Modifier = Modifier,
) {
    val scaffoldState = rememberScaffoldState()
    Scaffold(
        scaffoldState = scaffoldState,
        modifier = Modifier.statusBarsPadding(),
        drawerContent = {
            CraneDrawer()
        }
    ) {
        val scope = rememberCoroutineScope()
        CraneHomeContent(
            modifier = modifier,
            onExploreItemClicked = onExploreItemClicked,
            openDrawer = {
                scope.launch {
                    scaffoldState.drawerState.open()
                }
            }
        )
    }
}

Jika Anda menjalankan aplikasi, Anda akan melihat panel navigasi terbuka saat Anda mengetuk ikon menu tiga garis.

ad44883754b14efe.gif

LaunchedEffect vs rememberCoroutineScope

Penggunaan LaunchedEffect dalam kasus ini tidak mungkin karena kita perlu memicu panggilan untuk membuat coroutine dalam callback biasa yang berada di luar Komposisi.

Melihat kembali langkah halaman landing yang menggunakan LaunchedEffect, dapatkah Anda menggunakan rememberCoroutineScope dan memanggil scope.launch { delay(); onTimeout(); }, bukan menggunakan LaunchedEffect?

Anda dapat melakukannya dan sepertinya akan berhasil, tetapi hasilnya tidak akan benar. Seperti yang dijelaskan dalam dokumentasi Paradigma Compose, composable dapat dipanggil oleh Compose kapan saja. LaunchedEffect menjamin bahwa efek samping akan dieksekusi saat panggilan ke composable tersebut membuatnya masuk ke Komposisi. Jika Anda menggunakan rememberCoroutineScope dan scope.launch dalam isi LandingScreen, coroutine akan dieksekusi setiap kali LandingScreen dipanggil oleh Compose terlepas dari apakah panggilan tersebut membuatnya masuk ke Komposisi atau tidak. Oleh karena itu, Anda akan menyia-nyiakan resource dan tidak akan mengeksekusi efek samping ini di lingkungan yang terkontrol.

Apakah Anda memperhatikan bahwa jika Anda mengetuk Choose Destination, Anda dapat mengedit kolom ini dan memfilter kota berdasarkan input penelusuran? Anda mungkin juga memperhatikan bahwa setiap kali memodifikasi Choose Destination, gaya teks akan berubah.

99dec71d23aef084.gif

Buka file base/EditableUserInput.kt. Composable stateful CraneEditableUserInput memerlukan beberapa parameter seperti hint dan caption yang sesuai dengan teks opsional di samping ikon. Misalnya, caption To muncul saat Anda menelusuri tujuan.

// base/EditableUserInput.kt file - code in the main branch

@Composable
fun CraneEditableUserInput(
    hint: String,
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null,
    onInputChanged: (String) -> Unit
) {
    // TODO Codelab: Encapsulate this state in a state holder
    var textState by remember { mutableStateOf(hint) }
    val isHint = { textState == hint }

    ...
}

Mengapa?

Logika untuk mengupdate textState dan menentukan sudah sesuai dengan petunjuk atau belum semua yang ditampilkan dalam isi composable CraneEditableUserInput. Hal ini memiliki beberapa kekurangan:

  • Nilai TextField tidak ditarik sehingga tidak dapat dikontrol dari luar, yang membuat pengujian menjadi lebih sulit.
  • Logika composable ini dapat menjadi lebih kompleks dan status internal dapat dengan mudahnya menjadi tidak sinkron.

Dengan membuat holder status yang bertanggung jawab atas status internal composable ini, Anda dapat memusatkan semua perubahan status di satu tempat. Dengan tindakan ini, status menjadi lebih sulit asinkron, dan logika terkait dikelompokkan bersama dalam satu class. Selain itu, status ini dapat mudah ditarik dan dapat digunakan dari pemanggil composable ini.

Dalam hal ini, penarikan status adalah ide bagus karena merupakan komponen UI tingkat rendah yang mungkin digunakan kembali di bagian lain aplikasi. Dengan demikian, semakin fleksibel dan dapat dikontrol suatu status, semakin baik hasilnya.

Membuat holder status

Karena CraneEditableUserInput adalah komponen yang dapat digunakan kembali, mari buat class reguler sebagai holder status bernama EditableUserInputState di file yang sama yang terlihat seperti berikut:

// base/EditableUserInput.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue

class EditableUserInputState(private val hint: String, initialText: String) {

    var text by mutableStateOf(initialText)

    val isHint: Boolean
        get() = text == hint
}

Class harus memiliki ciri berikut:

  • text adalah status yang dapat berubah dari jenis String, seperti yang kita miliki di CraneEditableUserInput. Penting untuk menggunakan mutableStateOf agar Compose melacak perubahan pada nilai dan mengomposisi ulang saat terjadi perubahan.
  • text adalah var, yang memungkinkannya berubah secara langsung dari luar class.
  • Class ini mengambil initialText sebagai dependensi yang digunakan untuk melakukan inisialisasi text.
  • Logika untuk mengetahui apakah text adalah petunjuk atau bukan ada di properti isHint yang melakukan pemeriksaan sesuai permintaan.

Jika logika menjadi lebih kompleks di masa mendatang, kita hanya perlu membuat perubahan pada satu class: EditableUserInputState.

Mengingat holder status

Holder status harus selalu diingat untuk menjaganya tetap dalam Komposisi dan tidak membuat yang baru setiap saat. Ini adalah praktik yang baik untuk membuat metode dalam file yang sama dengan cara melakukannya untuk menghapus boilerplate dan menghindari kesalahan yang mungkin terjadi. Dalam file base/EditableUserInput.kt, tambahkan kode ini:

// base/EditableUserInput.kt file

@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    remember(hint) {
        EditableUserInputState(hint, hint)
    }

Jika kita hanya remember (mengingat) status ini, status ini tidak akan mempertahankan pembuatan ulang aktivitas. Untuk mencapainya, kita dapat menggunakan rememberSaveable API yang berperilaku mirip dengan remember, tetapi nilai yang tersimpan juga mempertahankan pembuatan ulang aktivitas dan proses. Secara internal, status ini menggunakan mekanisme status instance yang disimpan.

rememberSaveable melakukan semua ini tanpa tugas tambahan untuk objek yang dapat disimpan di dalam Bundle. Hal ini tidak berlaku untuk class EditableUserInputState yang telah kita buat di project. Oleh karena itu, kita perlu memberi tahu rememberSaveable cara menyimpan dan memulihkan instance class ini menggunakan Saver.

Membuat saver kustom

Saver menjelaskan cara objek dapat dikonversi menjadi sesuatu yang Saveable (dapat disimpan). Implementasi Saver perlu mengganti dua fungsi:

  • save untuk mengonversi nilai asli ke nilai yang dapat disimpan.
  • restore untuk mengonversi nilai yang dipulihkan ke instance class asli.

Untuk kasus kita, daripada membuat implementasi kustom Saver untuk class EditableUserInputState, kita dapat menggunakan beberapa API Compose yang ada seperti listSaver atau mapSaver (yang menyimpan nilai untuk disimpan dalam List atau Map) untuk mengurangi jumlah kode yang perlu kita tulis.

Sebaiknya tempatkan definisi Saver di dekat class yang digunakan. Karena perlu diakses secara statis, tambahkan Saver untuk EditableUserInputState dalam companion object. Dalam file base/EditableUserInput.kt, tambahkan implementasi Saver:

// base/EditableUserInput.kt file

import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver

class EditableUserInputState(private val hint: String, initialText: String) {
    var text by mutableStateOf(initialText)

    val isHint: Boolean
        get() = text == hint

    companion object {
        val Saver: Saver<EditableUserInputState, *> = listSaver(
            save = { listOf(it.hint, it.text) },
            restore = {
                EditableUserInputState(
                    hint = it[0],
                    initialText = it[1],
                )
            }
        )
    }
}

Dalam hal ini, kita menggunakan listSaver sebagai detail implementasi untuk menyimpan dan memulihkan instance EditableUserInputState di saver.

Kita sekarang dapat menggunakan saver ini di rememberSaveable (bukan remember) dalam metode rememberEditableUserInputState yang kita buat sebelumnya:

// base/EditableUserInput.kt file
import androidx.compose.runtime.saveable.rememberSaveable

@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    rememberSaveable(hint, saver = EditableUserInputState.Saver) {
        EditableUserInputState(hint, hint)
    }

Dengan penggunaan saver ini, status yang diingat EditableUserInput akan mempertahankan pembuatan ulang aktivitas dan proses.

Menggunakan holder status

Kita akan menggunakan EditableUserInputState, bukan text dan isHint, tetapi kita tidak ingin menggunakannya sebagai status internal di CraneEditableUserInput karena tidak ada cara bagi composable pemanggil untuk mengontrol status. Sebagai gantinya, kita ingin menarik EditableUserInputState agar pemanggil dapat mengontrol status CraneEditableUserInput. Jika kita menarik status, composable dapat digunakan dalam pratinjau dan diuji dengan lebih mudah karena Anda dapat memodifikasi statusnya dari pemanggil.

Untuk melakukan ini, kita perlu mengubah parameter fungsi yang dapat dikomposisi dan memberikannya nilai default jika diperlukan. Karena kita mungkin ingin mengizinkan CraneEditableUserInput dengan petunjuk kosong, kita menambahkan argumen default:

@Composable
fun CraneEditableUserInput(
    state: EditableUserInputState = rememberEditableUserInputState(""),
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null
) { /* ... */ }

Anda mungkin telah mengetahui bahwa parameter onInputChanged tidak ada lagi. Karena status dapat ditarik, jika pemanggil ingin mengetahui apakah input berubah, mereka dapat mengontrol status dan meneruskan status tersebut ke fungsi ini.

Selanjutnya, kita perlu menyesuaikan isi fungsi untuk menggunakan status yang ditarik, bukan status internal yang digunakan sebelumnya. Setelah pemfaktoran ulang, fungsi akan terlihat seperti ini:

@Composable
fun CraneEditableUserInput(
    state: EditableUserInputState = rememberEditableUserInputState(""),
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null
) {
    CraneBaseUserInput(
        caption = caption,
        tintIcon = { !state.isHint },
        showCaption = { !state.isHint },
        vectorImageId = vectorImageId
    ) {
        BasicTextField(
            value = state.text,
            onValueChange = { state.text = it },
            textStyle = if (state.isHint) {
                captionTextStyle.copy(color = LocalContentColor.current)
            } else {
                MaterialTheme.typography.body1.copy(color = LocalContentColor.current)
            },
            cursorBrush = SolidColor(LocalContentColor.current)
        )
    }
}

Pemanggil holder status

Karena kita mengubah API CraneEditableUserInput, kita perlu memeriksa di semua tempat yang dipanggil untuk memastikan kita meneruskan parameter yang sesuai.

Satu-satunya tempat dalam project yang menjadi tempat kita memanggil API ini adalah dalam file home/SearchUserInput.kt. Buka dan arahkan ke fungsi yang dapat dikomposisi ToDestinationUserInput; Anda akan melihat error build di sana. Karena petunjuk sekarang menjadi bagian dari holder status, dan kita menginginkan petunjuk khusus untuk instance CraneEditableUserInput ini dalam Komposisi, kita perlu mengingat status pada level ToDestinationUserInput dan meneruskannya ke CraneEditableUserInput:

// home/SearchUserInput.kt file

import androidx.compose.samples.crane.base.rememberEditableUserInputState

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )
}

snapshotFlow

Kode di atas tidak memiliki fungsi untuk memberi tahu pemanggil ToDestinationUserInput saat input berubah. Sehubungan dengan cara penyusunan aplikasi, kita tidak ingin menarik EditableUserInputState lebih tinggi dalam hierarki karena kita ingin menggabungkan composable lain seperti FlySearchContent dengan status ini. Bagaimana kita dapat memanggil lambda onToDestinationChanged dari ToDestinationUserInput dan tetap mempertahankan agar composable ini dapat digunakan kembali?

Kita dapat memicu efek samping menggunakan LaunchedEffect setiap kali input berubah dan memanggil lambda onToDestinationChanged:

// home/SearchUserInput.kt file

import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )

    val currentOnDestinationChanged by rememberUpdatedState(onToDestinationChanged)
    LaunchedEffect(editableUserInputState) {
        snapshotFlow { editableUserInputState.text }
            .filter { !editableUserInputState.isHint }
            .collect {
                currentOnDestinationChanged(editableUserInputState.text)
            }
    }
}

Kita sudah menggunakan LaunchedEffect dan rememberUpdatedState sebelumnya, tetapi kode di atas juga menggunakan API baru. Kita menggunakan snapshotFlow API untuk mengonversi objek State<T> Compose ke Flow. Saat status yang dibaca di dalam snapshotFlow berubah, Flow akan memunculkan nilai baru ke kolektor. Dalam hal ini, kita mengonversi status menjadi alur untuk menggunakan daya operator alur. Dengan demikian, kita filter (memfilter) saat text (teks) bukanlah hint (petunjuk), dan collect (mengumpulkan) item yang dimunculkan untuk memberi tahu induk bahwa tujuan saat ini berubah.

Tidak ada perubahan visual pada langkah codelab ini, tetapi kita telah meningkatkan kualitas bagian kode ini. Jika menjalankan aplikasi sekarang, Anda akan melihat semuanya berfungsi seperti sebelumnya.

Saat Anda mengetuk tujuan, layar detail akan terbuka dan Anda dapat melihat lokasi kota pada peta. Kode tersebut ada dalam file details/DetailsActivity.kt. Dalam composable CityMapView, kita memanggil fungsi rememberMapViewWithLifecycle. Jika Anda membuka fungsi ini, yang sudah tersedia dalam file details/MapViewUtils.kt, Anda akan melihat fungsi tersebut tidak terhubung ke siklus proses apa pun. Fungsi tersebut hanya mengingat MapView dan memanggil onCreate di dalamnya:

// details/MapViewUtils.kt file - code in the main branch

@Composable
fun rememberMapViewWithLifecycle(): MapView {
    val context = LocalContext.current
    // TODO Codelab: DisposableEffect step. Make MapView follow the lifecycle
    return remember {
        MapView(context).apply {
            id = R.id.map
            onCreate(Bundle())
        }
    }
}

Meskipun aplikasi berjalan dengan baik, ini menjadi masalah karena MapView tidak mengikuti siklus proses yang benar. Oleh karena itu, aplikasi tidak akan mengetahui kapan dipindahkan ke latar belakang, kapan Tampilan harus dijeda, dll. Mari kita perbaiki.

Karena MapView adalah Tampilan dan bukan composable, kita ingin mengikuti siklus proses Activity yang digunakannya, bukan siklus proses Komposisi. Artinya, kita perlu membuat LifecycleEventObserver untuk memproses peristiwa siklus proses dan memanggil metode yang tepat di MapView. Kemudian, kita perlu menambahkan observer ini ke siklus proses aktivitas saat ini.

Mari kita mulai dengan membuat fungsi yang menampilkan LifecycleEventObserver yang memanggil metode terkait dalam MapView yang diberikan peristiwa tertentu:

// details/MapViewUtils.kt file

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver

private fun getMapLifecycleObserver(mapView: MapView): LifecycleEventObserver =
    LifecycleEventObserver { _, event ->
        when (event) {
            Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle())
            Lifecycle.Event.ON_START -> mapView.onStart()
            Lifecycle.Event.ON_RESUME -> mapView.onResume()
            Lifecycle.Event.ON_PAUSE -> mapView.onPause()
            Lifecycle.Event.ON_STOP -> mapView.onStop()
            Lifecycle.Event.ON_DESTROY -> mapView.onDestroy()
            else -> throw IllegalStateException()
        }
    }

Sekarang, kita perlu menambahkan observer ini ke siklus proses saat ini, yang dapat kita gunakan dengan LifecycleOwner saat ini dengan lokal komposisi LocalLifecycleOwner. Namun, tidak cukup hanya dengan menambahkan observer; kita juga harus dapat menghapusnya. Kita memerlukan efek samping yang memberi tahu kita saat efek keluar dari Komposisi sehingga kita dapat mengeksekusi beberapa kode pembersihan. API efek samping yang kita cari adalah DisposableEffect.

DisposableEffect dimaksudkan untuk efek samping yang perlu dibersihkan setelah kunci berubah atau composable akan keluar dari Komposisi. Kode rememberMapViewWithLifecycle akhir akan benar-benar melakukan hal tersebut. Implementasikan baris berikut dalam project Anda:

// details/MapViewUtils.kt file

import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalLifecycleOwner

@Composable
fun rememberMapViewWithLifecycle(): MapView {
    val context = LocalContext.current
    val mapView = remember {
        MapView(context).apply {
            id = R.id.map
        }
    }

    val lifecycle = LocalLifecycleOwner.current.lifecycle
    DisposableEffect(key1 = lifecycle, key2 = mapView) {
        // Make MapView follow the current lifecycle
        val lifecycleObserver = getMapLifecycleObserver(mapView)
        lifecycle.addObserver(lifecycleObserver)
        onDispose {
            lifecycle.removeObserver(lifecycleObserver)
        }
    }

    return mapView
}

Observer ditambahkan ke lifecycle saat ini, dan akan dihapus setiap kali siklus proses saat ini berubah atau composable ini keluar dari Komposisi. Dengan key dalam DisposableEffect, jika lifecycle atau mapView berubah, observer akan dihapus dan ditambahkan lagi ke lifecycle yang tepat.

Dengan perubahan yang baru saja dibuat, MapView akan selalu mengikuti lifecycle LifecycleOwner saat ini dan perilakunya akan sama seperti jika digunakan di lingkup Tampilan.

Jalankan aplikasi dan buka layar detail untuk memastikan MapView masih dirender dengan benar. Tidak ada perubahan visual dalam langkah ini.

Di bagian ini, kita akan menyempurnakan cara layar detail dimulai. Composable DetailsScreen dalam file details/DetailsActivity.kt mendapatkan cityDetails secara sinkron dari ViewModel dan memanggil DetailsContent jika hasilnya sukses.

Namun, cityDetails dapat berkembang menjadi lebih mahal untuk dimuat pada UI thread dan dapat menggunakan coroutine untuk memindahkan pemuatan data ke thread lain. Mari kita tingkatkan kode ini untuk menambahkan layar pemuatan dan menampilkan DetailsContent saat data sudah siap.

Salah satu cara untuk memodelkan status layar adalah dengan class berikut yang mencakup semua kemungkinan: data yang akan ditampilkan di layar serta sinyal pemuatan dan error. Tambahkan class DetailsUiState ke file DetailsActivity.kt:

// details/DetailsActivity.kt file

data class DetailsUiState(
    val cityDetails: ExploreModel? = null,
    val isLoading: Boolean = false,
    val throwError: Boolean = false
)

Kita dapat memetakan hal yang perlu ditampilkan di layar dan UiState di lapisan ViewModel menggunakan aliran data, StateFlow dari jenis DetailsUiState, yang diperbarui ViewModel saat informasi sudah siap dan yang dikumpulkan Compose dengan collectAsState() API yang sudah Anda ketahui.

Namun, untuk latihan ini, kita akan mengimplementasikan alternatif. Jika ingin memindahkan logika pemetaan uiState ke lingkup Compose, kita dapat menggunakan produceState API.

produceState memungkinkan Anda mengonversi status non-Compose ke Status Compose. Ini akan meluncurkan coroutine yang tercakup dalam Komposisi yang dapat mendorong nilai menjadi State yang ditampilkan menggunakan properti value. Seperti halnya LaunchedEffect, produceState juga menggunakan kunci untuk membatalkan dan memulai ulang komputasi.

Untuk kasus penggunaan kita, kita dapat menggunakan produceState untuk memunculkan update uiState dengan nilai awal DetailsUiState(isLoading = true) seperti berikut:

// details/DetailsActivity.kt file

import androidx.compose.runtime.produceState

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {

    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        // In a coroutine, this can call suspend functions or move
        // the computation to different Dispatchers
        val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }

    // TODO: ...
}

Selanjutnya, bergantung pada uiState, kita menampilkan data, menampilkan layar pemuatan, atau melaporkan error. Berikut adalah kode lengkap untuk composable DetailsScreen:

// details/DetailsActivity.kt file

import androidx.compose.foundation.layout.Box
import androidx.compose.material.CircularProgressIndicator

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {
    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }

    when {
        uiState.cityDetails != null -> {
            DetailsContent(uiState.cityDetails!!, modifier.fillMaxSize())
        }
        uiState.isLoading -> {
            Box(modifier.fillMaxSize()) {
                CircularProgressIndicator(
                    color = MaterialTheme.colors.onSurface,
                    modifier = Modifier.align(Alignment.Center)
                )
            }
        }
        else -> { onErrorLoading() }
    }
}

Jika menjalankan aplikasi, Anda akan melihat bagaimana indikator lingkaran berputar pemuatan muncul sebelum menampilkan detail kota.

18956feb88725ca5.gif

Peningkatan terakhir pada Crane adalah menampilkan tombol Scroll to top setiap kali Anda men-scroll dalam daftar tujuan penerbangan setelah meneruskan elemen pertama layar. Mengetuk tombol akan membawa Anda ke elemen pertama dalam daftar.

59d2d10bd334bdb.gif

Buka file base/ExploreSection.kt yang berisi kode ini. Composable ExploreSection sesuai dengan yang Anda lihat di tampilan latar scaffold.

Solusi untuk menerapkan perilaku yang terlihat dalam video seharusnya tidak mengejutkan Anda. Namun, ada API baru yang belum kita lihat dan penting dalam kasus penggunaan ini: derivedStateOf API.

derivedStateOf digunakan saat Anda menginginkan State Compose yang berasal dari State lain. Penggunaan fungsi ini menjamin bahwa penghitungan hanya akan terjadi setiap kali salah satu status yang digunakan dalam penghitungan berubah.

Kalkulasi apakah pengguna telah meneruskan item pertama menggunakan listState semudah memeriksa jika listState.firstVisibleItemIndex > 0. Namun, firstVisibleItemIndex digabungkan dalam mutableStateOf API, yang membuatnya menjadi Status Compose yang dapat diobservasi. Kalkulasi kita juga harus berupa Status Compose karena kita ingin mengomposisi ulang UI untuk menampilkan tombol.

Penerapan yang naif dan tidak efisien akan terlihat seperti contoh berikut. Jangan menyalinnya ke dalam project; implementasi yang benar nanti disalin ke dalam project Anda bersama logika lainnya untuk layar:

// DO NOT DO THIS - It's executed on every recomposition
val showButton = listState.firstVisibleItemIndex > 0

Alternatif yang lebih baik dan lebih efisien adalah menggunakan derivedStateOf API yang menghitung showButton hanya saat listState.firstVisibleItemIndex berubah:

// Show the button if the first visible item is past
// the first item. We use a remembered derived state to
// minimize unnecessary compositions
val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

Kode baru untuk composable ExploreSection sudah tidak asing bagi Anda. Pelajari kembali cara kita menggunakan rememberCoroutineScope untuk memanggil fungsi penangguhan listState.scrollToItem di dalam callback onClick Button. Kita menggunakan Box untuk menempatkan Button yang ditampilkan secara bersyarat di atas ExploreList:

// base/ExploreSection.kt file

import androidx.compose.material.FloatingActionButton
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import com.google.accompanist.insets.navigationBarsPadding
import kotlinx.coroutines.launch

@Composable
fun ExploreSection(
    modifier: Modifier = Modifier,
    title: String,
    exploreList: List<ExploreModel>,
    onItemClicked: OnExploreItemClicked
) {
    Surface(modifier = modifier.fillMaxSize(), color = Color.White, shape = BottomSheetShape) {
        Column(modifier = Modifier.padding(start = 24.dp, top = 20.dp, end = 24.dp)) {
            Text(
                text = title,
                style = MaterialTheme.typography.caption.copy(color = crane_caption)
            )
            Spacer(Modifier.height(8.dp))
            Box(Modifier.weight(1f)) {
                val listState = rememberLazyListState()
                ExploreList(exploreList, onItemClicked, listState = listState)

                // Show the button if the first visible item is past
                // the first item. We use a remembered derived state to
                // minimize unnecessary compositions
                val showButton by remember {
                    derivedStateOf {
                        listState.firstVisibleItemIndex > 0
                    }
                }
                if (showButton) {
                    val coroutineScope = rememberCoroutineScope()
                    FloatingActionButton(
                        backgroundColor = MaterialTheme.colors.primary,
                        modifier = Modifier
                            .align(Alignment.BottomEnd)
                            .navigationBarsPadding()
                            .padding(bottom = 8.dp),
                        onClick = {
                            coroutineScope.launch {
                                listState.scrollToItem(0)
                            }
                        }
                    ) {
                        Text("Up!")
                    }
                }
            }
        }
    }
}

Jika menjalankan aplikasi, Anda akan melihat tombol muncul di bagian bawah setelah men-scroll dan meneruskan elemen pertama layar.

Selamat, Anda berhasil menyelesaikan codelab ini dan mempelajari konsep lanjutan API status dan efek samping dalam aplikasi Jetpack Compose.

Anda telah mempelajari cara membuat holder status, API efek samping seperti LaunchedEffect, rememberUpdatedState, DisposableEffect, produceState, dan derivedStateOf, serta cara menggunakan coroutine di Jetpack Compose.

Apa selanjutnya?

Lihat codelab lainnya di jalur Compose, dan contoh kode lainnya, termasuk Crane.

Dokumentasi

Untuk mendapatkan informasi selengkapnya dan panduan tentang topik ini, lihat dokumentasi berikut: