Mengimplementasikan navigasi untuk UI adaptif

Navigasi adalah interaksi yang memungkinkan pengguna melihat-lihat, masuk, dan keluar dari berbagai konten dalam aplikasi Anda. UI Adaptif di Compose tidak mengubah dasar proses navigasi, dan Anda tetap harus mematuhi semua prinsip navigasi. Komponen Navigasi memudahkan Anda menerapkan pola yang direkomendasikan, dan Anda dapat terus menggunakannya dalam aplikasi dengan tata letak yang sangat adaptif.

Selain prinsip-prinsip di atas, ada beberapa pertimbangan lain untuk meningkatkan pengalaman pengguna di aplikasi dengan tata letak adaptif. Seperti yang dibahas dalam panduan untuk mem-build tata letak adaptif, struktur UI mungkin bergantung pada ruang yang tersedia untuk aplikasi Anda. Semua prinsip navigasi tambahan ini mempertimbangkan apa yang terjadi saat ruang layar tersedia untuk perubahan aplikasi Anda.

UI navigasi responsif

Untuk memberikan pengalaman navigasi terbaik kepada pengguna, Anda harus menyediakan UI navigasi yang disesuaikan dengan ruang yang tersedia untuk aplikasi Anda. Gunakan panel aplikasi bawah, panel navigasi yang selalu ada atau dapat diciutkan, kolom samping, atau mungkin sesuatu yang benar-benar baru berdasarkan ruang layar yang tersedia dan gaya unik aplikasi Anda.

Karena komponen ini memenuhi seluruh lebar atau tinggi layar, logika untuk memutuskan mana yang harus digunakan adalah keputusan tata letak tingkat layar. Oleh karena itu, sebaiknya gunakan Class Ukuran Jendela untuk menentukan jenis UI navigasi yang akan ditampilkan. Class ukuran jendela adalah titik henti sementara yang didesain untuk menyeimbangkan kemudahan dengan fleksibilitas agar aplikasi Anda dapat dioptimalkan untuk sebagian besar kasus yang unik.

enum class WindowSizeClass { Compact, Medium, Expanded }

@Composable
fun MyApp(windowSizeClass: WindowSizeClass) {
    // Perform logic on the size class to decide whether to show
    // the nav rail
    val showNavRail = windowSizeClass != WindowSizeClass.Compact
    MyScreen(
        showNavRail = showNavRail,
        /* ... */
    )
}

Tujuan yang sepenuhnya responsif

Mode multi-aplikasi, perangkat foldable, dan jendela bentuk bebas di Chrome OS dapat menyebabkan ruang yang tersedia untuk aplikasi Anda berubah lebih dari sebelumnya.

Untuk memberikan pengalaman yang lancar bagi pengguna, dalam host navigasi Anda, gunakan grafik navigasi tunggal dengan setiap tujuan yang bersifat responsif. Pendekatan ini memperkuat prinsip utama UI yang responsif: fleksibilitas dan kontinuitas. Jika setiap tujuan dapat menangani peristiwa perubahan ukuran dengan lancar, maka perubahan akan diisolasi hanya pada UI, dan status aplikasi lainnya (termasuk navigasi) tetap dipertahankan, sehingga membantu kontinuitas.

Dengan grafik navigasi paralel, setiap kali aplikasi bertransisi ke class ukuran lain, Anda harus menentukan tujuan pengguna saat ini dalam grafik lain, merekonstruksi data sebelumnya, dan merekonsiliasi informasi status lain yang berbeda antara grafik. Pendekatan ini rumit dan rentan terhadap error.

Pada tujuan tertentu, Anda memiliki banyak opsi untuk membuat tata letak menjadi responsif. Anda dapat menyesuaikan spasi, menggunakan tata letak alternatif, menambahkan kolom informasi tambahan untuk menggunakan lebih banyak ruang, atau menampilkan detail tambahan yang tidak pas dengan lebih sedikit ruang. Anda dapat mempelajari lebih lanjut alat yang tersedia untuk menerapkan perubahan ini di mem-build tata letak adaptif.

Untuk pengalaman pengguna yang lebih baik, Anda dapat menambahkan lebih banyak konten ke tujuan tertentu dengan tata letak kanonis layar besar, seperti sebagai tampilan daftar/detail. Pertimbangan navigasi untuk desain tersebut dibahas di bawah ini.

Membedakan rute dan layar

Komponen navigasi memungkinkan untuk menentukan rute, yang masing-masing sesuai dengan beberapa tujuan. Menavigasi hasil dalam mengubah tujuan mana yang saat ini ditampilkan, bersama dengan pelacakan data sebelumnya, yaitu daftar tujuan tempat pengguna berada sebelumnya.

Di tujuan tertentu, Anda dapat menampilkan konten apa pun yang disukai. Untuk NavHost yang menangani navigasi utama aplikasi, Anda umumnya menampilkan layar berbeda di setiap tujuan, yang akan menggunakan seluruh ruang yang tersedia untuk aplikasi Anda.

Biasanya, setiap tujuan bertanggung jawab untuk menampilkan satu layar, dan setiap layar hanya ditampilkan di satu tujuan. Namun, ini bukan persyaratan yang sulit. Bahkan, akan sangat membantu jika tujuan memilih di antara beberapa layar untuk ditampilkan, bergantung pada ukuran yang tersedia untuk aplikasi Anda.

Mari kita lihat JetNews, salah satu contoh resmi Compose. Fungsi utama aplikasi adalah menampilkan artikel, yang dapat dipilih pengguna dari daftar. Jika memiliki cukup ruang, aplikasi dapat menampilkan daftar dan artikel secara bersamaan. Antarmuka ini adalah tata letak daftar/detail, yang merupakan salah satu tata letak kanonis Desain Material.

Layar Daftar, Detail, dan Daftar + Detail di JetNews

Meskipun ketiganya berbeda secara visual, aplikasi menampilkan ketiganya di bawah rute "home" yang sama.

Dalam kode, tujuan memanggil HomeRoute:

@Composable
fun JetnewsNavGraph(
    navController: NavHostController,
    isExpandedScreen: Boolean,
    // ...
) {
    // ...
    NavHost(
        navController = navController,
        startDestination = JetnewsDestinations.HomeRoute
    ) {
        composable(JetnewsDestinations.HomeRoute) {
            // ...
            HomeRoute(
                isExpandedScreen = isExpandedScreen,
                // ...
            )
        }
        // ...
    }
}

Kemudian dari ketiga layar tersebut, kode HomeRoute akan menentukan layar yang akan ditampilkan, yang masing-masing composable diberi akhiran Screen. Aplikasi mengambil keputusan ini berdasarkan kombinasi status aplikasi yang disimpan di HomeViewModel, serta class ukuran jendela yang mendeskripsikan ruang yang tersedia saat ini.

@Composable
fun HomeRoute(
    // if the window size class is expanded
    isExpandedScreen: Boolean,
    // if the user is focused on the selected article
    isArticleOpen: Boolean,
    // ...
) {
    // ...
    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(/* ... */)
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(/* ... */)
        } else {
            HomeListScreen(/* ... */)
        }
    }
}

Dengan pendekatan ini, aplikasi jelas memisahkan operasi navigasi yang menggantikan seluruh HomeRoute dengan tujuan lain (dengan memanggil navigate() di NavController) dari operasi navigasi yang hanya memengaruhi konten dalam tujuan ini (seperti memilih artikel dari daftar). Sebaiknya tangani peristiwa ini dengan mengupdate status bersama yang berlaku untuk semua ukuran jendela, meskipun transisi antara layar daftar dan artikel terlihat sebagai operasi navigasi bagi pengguna jika aplikasi hanya menampilkan satu panel.

Oleh karena itu, saat kita mengetuk artikel dalam daftar, kita mengupdate tanda boolean isArticleOpen:

class HomeViewModel(/* ... */) {
    fun selectArticle(articleId: String) {
        viewModelState.update {
            it.copy(
                isArticleOpen = true,
                selectedArticleId = articleId
            )
        }
    }
}

@Composable
fun HomeRoute(
    isExpandedScreen: Boolean,
    isArticleOpen: Boolean,
    selectedArticleId: String,
    onSelectArticle: (String) -> Unit,
    // ...
) {
    // ...
    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(
            selectedArticleId = selectedArticleId,
            onSelectArticle = onSelectArticle,
            // ...
        )
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(
                selectedArticleId = selectedArticleId,
                // ...
            )
        } else {
            HomeListScreen(
                onSelectArticle = onSelectArticle,
                // ...
            )
        }
    }
}

Demikian pula, kita akan menginstal BackHandler kustom ketika hanya layar artikel yang ditampilkan, yang menetapkan isArticleOpen kembali ke salah.

class HomeViewModel(/* ... */) {
    fun onArticleBackPress() {
        viewModelState.update {
            it.copy(isArticleOpen = false)
        }
    }
}

@Composable
fun HomeRoute(
    isExpandedScreen: Boolean,
    isArticleOpen: Boolean,
    selectedArticleId: String,
    onSelectArticle: (String) -> Unit,
    onArticleBackPress: () -> Unit,
    // ...
) {
    // ...
    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(/* ... */)
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(
                selectedArticleId = selectedArticleId,
                onUpPressed = onArticleBackPress,
                // ...
            )
            BackHandler {
                onArticleBackPress()
            }
        } else {
            HomeListScreen(/* ... */)
        }
    }
}

Lapisan ini menyatukan banyak konsep penting saat mendesain aplikasi Compose. Dengan membuat layar dapat digunakan kembali dan memungkinkan statusnya yang penting untuk ditarik, Anda dapat menukar seluruh layar dengan mudah. Dengan menggabungkan status aplikasi dari ViewModel dengan informasi ukuran yang tersedia, penentuan layar mana yang akan ditampilkan diatur oleh potongan logika sederhana. Terakhir, dengan mempertahankan aliran data searah, UI adaptif Anda akan selalu memanfaatkan ruang yang tersedia sekaligus mempertahankan status pengguna.

Untuk implementasi lengkap, lihat contoh JetNews di GitHub.

Mempertahankan status pengguna

Pertimbangan paling penting untuk UI adaptif adalah mempertahankan status pengguna saat perangkat diputar atau dilipat, atau jendela aplikasi diubah ukurannya. Secara khusus, semua perubahan ukuran ini harus dapat dikembalikan.

Misalnya, anggap pengguna melihat beberapa layar di aplikasi Anda, lalu memutar perangkatnya. Jika pengguna mengurungkan rotasi tersebut (yaitu, memutar perangkat kembali ke posisi awal), perangkat harus dikembalikan ke layar yang persis sama seperti semula, dengan semua status dipertahankan. Jika mereka men-scroll sebagian konten sebelum memutar, konten akan dikembalikan ke posisi scroll yang sama setelah diputar kembali.

Menyimpan posisi scroll daftar setelah memutar

Perubahan orientasi dan perubahan ukuran jendela menyebabkan perubahan konfigurasi, yang secara default membuat ulang Activity dan composable Anda. Status dapat disimpan melalui perubahan konfigurasi ini dengan rememberSaveable atau ViewModel, yang dapat Anda pelajari lebih lanjut di Status dan Jetpack Compose. Jika Anda tidak menggunakan alat seperti ini, status pengguna akan hilang.

Tata letak adaptif cenderung memiliki status tambahan, karena dapat menampilkan konten yang berbeda pada ukuran layar yang berbeda. Oleh karena itu, penting juga menyimpan status pengguna untuk konten tambahan tersebut, bahkan untuk komponen yang tidak lagi terlihat.

Misalnya beberapa konten scroll hanya terlihat pada lebar yang lebih besar. Jika rotasi menyebabkan lebar menjadi terlalu kecil untuk menampilkan konten scroll, konten scroll akan disembunyikan. Saat pengguna memutar perangkat mereka kembali, konten scroll akan terlihat lagi, dan posisi scroll asli seharusnya dipulihkan.

Menyimpan posisi scroll detail saat memutar

Di Compose, Anda dapat melakukannya dengan pengangkatan status. Dengan mengangkat status komponen lebih tinggi dalam hierarki komposisi, statusnya dapat dipertahankan meskipun tidak lagi terlihat.

Di JetNews, kita mengangkat status ke HomeRoute, sehingga status dipertahankan dan digunakan ulang saat mengubah layar mana yang terlihat:

@Composable
fun HomeRoute(
    // if the window size class is expanded
    isExpandedScreen: Boolean,
    // if the user is focused on the selected article
    isArticleOpen: Boolean,
    selectedArticleId: String,
    // ...
) {
    val homeListState = rememberHomeListState()
    val articleState = rememberSaveable(
        selectedArticleId,
        saver = ArticleState.Saver
    ) {
        ArticleState()
    }

    if (isExpandedScreen) {
        HomeListWithArticleDetailsScreen(
            homeListState = homeListState,
            articleState = articleState,
            // ...
        )
    } else {
        // if we don't have room for both the list and article details,
        // show one of them based on the user's focus
        if (isArticleOpen) {
            ArticleScreen(
                articleState = articleState,
                // ...
            )
        } else {
            HomeListScreen(
                homeListState = homeListState,
                // ...
            )
        }
    }
}

Menghindari navigasi sebagai efek samping dari perubahan ukuran

Jika Anda menambahkan layar ke aplikasi yang memanfaatkan ruang tambahan yang dapat disediakan oleh layar yang lebih besar, Anda mungkin ingin menambahkan tujuan baru ke aplikasi Anda untuk tata letak yang baru didesain.

Namun, anggaplah pengguna melihat tata letak baru ini di layar bagian dalam perangkat foldable. Jika pengguna melipat perangkat, mungkin tidak ada cukup ruang untuk menampilkan tata letak baru di layar luar. Ini akan memperkenalkan persyaratan untuk membuka tempat lain jika ukuran layar baru terlalu kecil. Ada beberapa masalah:

  • Menavigasi sebagai efek samping komposisi dapat menyebabkan tujuan lama terlihat sesaat, karena perlu ditampilkan sebelum navigasi terjadi
  • Untuk menjaga reversibilitas, kita juga perlu menavigasi kembali setelah lipatan dibuka
  • Akan sangat sulit untuk mempertahankan status pengguna di antara perubahan ini, karena bernavigasi dapat menghapus status lama setelah menampilkan data sebelumnya

Sebagai pertimbangan tambahan, aplikasi Anda bahkan mungkin tidak berada di latar depan saat perubahan ini terjadi. Aplikasi Anda mungkin menampilkan tata letak yang memerlukan lebih banyak ruang, lalu pengguna menempatkan aplikasi di latar belakang. Jika nanti pengguna kembali ke aplikasi Anda, orientasi, ukuran, dan layar fisiknya mungkin telah berubah sejak aplikasi terakhir kali dilanjutkan.

Jika Anda hanya ingin menampilkan beberapa tujuan untuk ukuran layar tertentu, pertimbangkan untuk menggabungkan tujuan yang relevan ke dalam satu rute, dan menampilkan berbagai layar pada rute tersebut seperti yang dijelaskan di atas.