Navigasi untuk UI yang responsif

Navigasi adalah proses berinteraksi dengan UI aplikasi untuk mengakses tujuan konten aplikasi. Prinsip navigasi Android memberikan panduan yang membantu Anda membuat navigasi aplikasi yang konsisten dan intuitif.

UI yang responsif memberikan tujuan konten yang responsif dan sering menyertakan jenis elemen navigasi yang berbeda sebagai respons terhadap perubahan ukuran layar—misalnya, menu navigasi bawah di layar kecil, kolom samping navigasi di layar berukuran sedang, atau panel navigasi yang persisten di layar besar—tetapi UI yang responsif harus tetap mematuhi prinsip navigasi.

Komponen Navigasi Jetpack menerapkan prinsip navigasi dan dapat digunakan untuk memfasilitasi pengembangan aplikasi dengan UI yang responsif.

Gambar 1. Tampilan luas, sedang, dan ringkas dengan panel navigasi, kolom samping, dan panel bawah.

Navigasi UI responsif

Ukuran jendela tampilan yang ditempati oleh aplikasi memengaruhi ergonomi dan kegunaan. Class ukuran jendela memungkinkan Anda menentukan elemen navigasi yang sesuai (seperti menu navigasi, kolom samping, atau panel samping) dan menempatkannya di tempat yang paling mudah diakses oleh pengguna. Dalam pedoman tata letak Desain Material, elemen navigasi menempati ruang tetap di tepi depan layar dan dapat dipindahkan ke tepi bawah jika lebar aplikasinya rapat. Pilihan elemen navigasi sangat bergantung pada ukuran jendela aplikasi dan jumlah item yang harus disimpan elemen.

Class ukuran jendela Sedikit item Banyak item
lebar rapat menu navigasi bawah panel navigasi (tepi depan atau bawah)
lebar sedang kolom samping navigasi panel navigasi (tepi depan)
lebar diperluas kolom samping navigasi panel navigasi persisten (tepi depan)

Dalam tata letak berbasis tampilan, file resource tata letak dapat dikualifikasikan menurut titik henti sementara class ukuran jendela agar menggunakan elemen navigasi yang berbeda untuk dimensi tampilan yang berbeda. Jetpack Compose dapat menggunakan titik henti sementara yang disediakan oleh window size class API untuk secara terprogram menentukan elemen navigasi yang paling sesuai untuk jendela aplikasi.

View

<!-- res/layout/main_activity.xml -->

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.bottomnavigation.BottomNavigationView
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        ... />

    <!-- Content view(s) -->
</androidx.constraintlayout.widget.ConstraintLayout>


<!-- res/layout-w600dp/main_activity.xml -->

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.navigationrail.NavigationRailView
        android:layout_width="wrap_content"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        ... />

    <!-- Content view(s) -->
</androidx.constraintlayout.widget.ConstraintLayout>


<!-- res/layout-w1240dp/main_activity.xml -->

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.navigation.NavigationView
        android:layout_width="wrap_content"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        ... />

    <!-- Content view(s) -->
</androidx.constraintlayout.widget.ConstraintLayout>

Compose

// This method should be run inside a Composable function.
val widthSizeClass = calculateWindowSizeClass(this).widthSizeClass
// You can get the height of the current window by invoking heightSizeClass instead.

@Composable
fun MyApp(widthSizeClass: WindowWidthSizeClass) {
    // Select a navigation element based on window size.
    when (widthSizeClass) {
        WindowWidthSizeClass.Compact -> { CompactScreen() }
        WindowWidthSizeClass.Medium -> { MediumScreen() }
        WindowWidthSizeClass.Expanded -> { ExpandedScreen() }
    }
}

@Composable
fun CompactScreen() {
    Scaffold(bottomBar = {
                NavigationBar {
                    icons.forEach { item ->
                        NavigationBarItem(
                            selected = isSelected,
                            onClick = { ... },
                            icon = { ... })
                    }
                }
            }
        ) {
        // Other content
    }
}

@Composable
fun MediumScreen() {
    Row(modifier = Modifier.fillMaxSize()) {
        NavigationRail {
            icons.forEach { item ->
                NavigationRailItem(
                    selected = isSelected,
                    onClick = { ... },
                    icon = { ... })
            }
        }
        // Other content
    }
}

@Composable
fun ExpandedScreen() {
    PermanentNavigationDrawer(
        drawerContent = {
            icons.forEach { item ->
                NavigationDrawerItem(
                    icon = { ... },
                    label = { ... },
                    selected = isSelected,
                    onClick = { ... }
                )
            }
        },
        content = {
            // Other content
        }
    )
}

Tujuan konten responsif

Pada UI yang responsif, tata letak setiap tujuan konten harus menyesuaikan dengan perubahan ukuran jendela. Aplikasi Anda dapat menyesuaikan spasi tata letak, memosisikan ulang elemen, menambahkan atau menghapus konten, atau mengubah elemen UI, seperti elemen navigasi. (Lihat Memigrasikan UI ke tata letak responsif dan Membuat tata letak adaptif.)

Jika setiap tujuan dapat dengan baik menangani peristiwa perubahan ukuran, perubahan akan diisolasi ke UI. Status aplikasi lainnya, seperti navigasi, tidak akan terpengaruh.

Navigasi tidak boleh terjadi sebagai efek samping dari perubahan ukuran jendela. Jangan membuat tujuan konten hanya untuk mengakomodasi ukuran jendela yang berbeda. Misalnya, jangan membuat tujuan konten yang berbeda untuk layar perangkat foldable yang berbeda.

Menavigasi sebagai efek samping dari perubahan ukuran jendela memiliki masalah berikut:

  • Tujuan lama (untuk ukuran jendela sebelumnya) mungkin akan terlihat sesaat sebelum menavigasi ke tujuan baru
  • Agar tetap dapat dikembalikan ke setelan semula (misalnya, saat perangkat dilipat dan dibentangkan), navigasi diperlukan untuk setiap ukuran jendela
  • Mempertahankan status aplikasi antartujuan bisa jadi tidak mudah karena navigasi dapat merusak status setelah memunculkan data sebelumnya

Selain itu, aplikasi Anda mungkin tidak berada di latar depan saat perubahan ukuran jendela berlangsung. Tata letak aplikasi Anda mungkin memerlukan lebih banyak ruang daripada aplikasi latar depan, dan ketika pengguna kembali ke aplikasi Anda, orientasi dan ukuran jendela semuanya bisa saja telah berubah.

Jika aplikasi Anda memerlukan tujuan konten unik berdasarkan ukuran jendela, sebaiknya Anda menggabungkan tujuan yang relevan ke dalam satu tujuan yang menyertakan tata letak alternatif.

Tujuan konten dengan tata letak alternatif

Sebagai bagian dari desain yang responsif, satu tujuan navigasi dapat memiliki tata letak alternatif bergantung pada ukuran jendela aplikasi. Setiap tata letak menggunakan seluruh jendela, tetapi tata letak yang berbeda disajikan untuk ukuran jendela yang berbeda.

Contoh kanonis adalah tampilan detail daftar. Untuk ukuran jendela kecil, aplikasi Anda akan menampilkan satu tata letak konten untuk daftar dan satu tata letak untuk detailnya. Menavigasi ke tujuan tampilan daftar-detail awalnya hanya akan menampilkan tata letak daftar. Saat item daftar dipilih, aplikasi Anda akan menampilkan tata letak detail sehingga daftar akan digantikan. Saat kontrol kembali dipilih, tata letak daftar akan ditampilkan sehingga detail akan digantikan. Namun, untuk ukuran jendela yang diperluas, tata letak daftar dan detail akan ditampilkan berdampingan.

View

SlidingPaneLayout memungkinkan Anda membuat satu tujuan navigasi yang menampilkan dua panel konten secara berdampingan di layar besar, tetapi di perangkat kecil seperti ponsel, hanya satu panel yang dapat ditampilkan.

<!-- Single destination for list and detail. -->

<navigation ...>

    <!-- Fragment that implements SlidingPaneLayout. -->
    <fragment
        android:id="@+id/article_two_pane"
        android:name="com.example.app.ListDetailTwoPaneFragment" />

    <!-- Other destinations... -->
</navigation>

Lihat Membuat tata letak dua panel untuk detail cara mengimplementasikan tata letak daftar-detail menggunakan SlidingPaneLayout.

Compose

Di Compose, tampilan daftar-detail dapat diterapkan dengan menggabungkan beberapa composable alternatif dalam satu rute yang menggunakan class ukuran jendela untuk menampilkan composable yang sesuai untuk setiap class ukuran.

Rute adalah jalur navigasi ke tujuan konten, yang biasanya adalah composable tunggal, tetapi juga bisa berupa beberapa composable alternatif. Logika bisnis menentukan composable alternatif yang ditampilkan. Composable mengisi jendela aplikasi, apa pun alternatif yang ditampilkan.

Tampilan detail daftar terdiri dari tiga composable, misalnya:

/* Displays a list of items. */
@Composable
fun ListOfItems(
    onItemSelected: (String) -> Unit,
) { /*...*/ }

/* Displays the detail for an item. */
@Composable
fun ItemDetail(
    selectedItemId: String? = null,
) { /*...*/ }

/* Displays a list and the detail for an item side by side. */
@Composable
fun ListAndDetail(
    selectedItemId: String? = null,
    onItemSelected: (String) -> Unit,
) {
  Row {
    ListOfItems(onItemSelected = onItemSelected)
    ItemDetail(selectedItemId = selectedItemId)
  }
}

Satu rute navigasi memberikan akses ke tampilan daftar-detail:

@Composable
fun ListDetailRoute(
    // Indicates that the display size is represented by the expanded window size class.
    isExpandedWindowSize: Boolean = false,
    // Identifies the item selected from the list. If null, a item has not been selected.
    selectedItemId: String?,
) {
  if (isExpandedWindowSize) {
    ListAndDetail(
      selectedItemId = selectedItemId,
      /*...*/
    )
  } else {
    // If the display size cannot accommodate both the list and the item detail,
    // show one of them based on the user's focus.
    if (selectedItemId != null) {
      ItemDetail(
        selectedItemId = selectedItemId,
        /*...*/
      )
    } else {
      ListOfItems(/*...*/)
    }
  }
}

ListDetailRoute (tujuan navigasi) menentukan mana dari tiga composable yang akan ditampilkan: ListAndDetail untuk ukuran jendela yang diperluas; ListOfItems atau ItemDetail untuk ukuran rapat, bergantung apakah item daftar telah dipilih atau belum.

Rute disertakan dalam NavHost, misalnya:

NavHost(navController = navController, startDestination = "listDetailRoute") {
  composable("listDetailRoute") {
    ListDetailRoute(isExpandedWindowSize = isExpandedWindowSize,
                    selectedItemId = selectedItemId)
  }
  /*...*/
}

Anda dapat memberikan argumen isExpandedWindowSize dengan memeriksa WindowMetrics aplikasi Anda.

Argumen selectedItemId dapat diberikan oleh ViewModel yang mempertahankan status di semua ukuran jendela. Saat pengguna memilih item dari daftar, variabel status selectedItemId akan diperbarui:

class ListDetailViewModel : ViewModel() {

  data class ListDetailUiState(
      val selectedItemId: String? = null,
  )

  private val viewModelState = MutableStateFlow(ListDetailUiState())

  fun onItemSelected(itemId: String) {
    viewModelState.update {
      it.copy(selectedItemId = itemId)
    }
  }
}

val listDetailViewModel = ListDetailViewModel()

@Composable
fun ListDetailRoute(
    isExpandedWindowSize: Boolean = false,
    selectedItemId: String?,
    onItemSelected: (String) -> Unit = { listDetailViewModel.onItemSelected(it) },
) {
  if (isExpandedWindowSize) {
    ListAndDetail(
      selectedItemId = selectedItemId,
      onItemSelected = onItemSelected,
      /*...*/
    )
  } else {
    if (selectedItemId != null) {
      ItemDetail(
        selectedItemId = selectedItemId,
        /*...*/
      )
    } else {
      ListOfItems(
        onItemSelected = onItemSelected,
        /*...*/
      )
    }
  }
}

Rute juga menyertakan BackHandler kustom saat composable detail item mengisi seluruh jendela aplikasi:

class ListDetailViewModel : ViewModel() {

  data class ListDetailUiState(
      val selectedItemId: String? = null,
  )

  private val viewModelState = MutableStateFlow(ListDetailUiState())

  fun onItemSelected(itemId: String) {
    viewModelState.update {
      it.copy(selectedItemId = itemId)
    }
  }

  fun onItemBackPress() {
    viewModelState.update {
      it.copy(selectedItemId = null)
    }
  }
}

val listDetailViewModel = ListDetailViewModel()

@Composable
fun ListDetailRoute(
    isExpandedWindowSize: Boolean = false,
    selectedItemId: String?,
    onItemSelected: (String) -> Unit = { listDetailViewModel.onItemSelected(it) },
    onItemBackPress: () -> Unit = { listDetailViewModel.onItemBackPress() },
) {
  if (isExpandedWindowSize) {
    ListAndDetail(
      selectedItemId = selectedItemId,
      onItemSelected = onItemSelected,
      /*...*/
    )
  } else {
    if (selectedItemId != null) {
      ItemDetail(
        selectedItemId = selectedItemId,
        /*...*/
      )
      BackHandler {
        onItemBackPress()
      }
    } else {
      ListOfItems(
        onItemSelected = onItemSelected,
        /*...*/
      )
    }
  }
}

Menggabungkan status aplikasi dari ViewModel dengan informasi class ukuran jendela membuat pemilihan composable yang sesuai menjadi lebih masuk akal. Dengan mempertahankan aliran data searah, aplikasi Anda dapat sepenuhnya menggunakan ruang tampilan yang tersedia sekaligus mempertahankan status aplikasi.

Untuk implementasi tampilan daftar-detail lengkap di Compose, lihat contoh JetNews di GitHub.

Satu grafik navigasi

Untuk memberikan pengalaman pengguna yang konsisten di semua ukuran jendela atau perangkat, gunakan satu grafik navigasi jika setiap tujuan konten tersebut memiliki tata letak yang responsif.

Jika Anda menggunakan grafik navigasi yang berbeda untuk setiap class ukuran jendela, setiap kali aplikasi bertransisi dari satu class ukuran ke class ukuran lainnya, Anda harus menentukan tujuan pengguna saat ini di grafik lain, membuat data sebelumnya, dan merekonsiliasi informasi status yang berbeda di antara grafik.

Host navigasi bertingkat

Aplikasi Anda mungkin menyertakan tujuan konten yang memiliki tujuan kontennya sendiri. Misalnya, dalam tampilan daftar-detail, panel detail item bisa menyertakan elemen UI yang menavigasi ke konten yang menggantikan detail item.

Untuk menerapkan jenis sub-navigasi ini, panel detail dapat menjadi host navigasi bertingkat menggunakan grafik navigasinya sendiri yang menentukan tujuan yang diakses dari panel detail:

View

<!-- layout/two_pane_fragment.xml -->

<androidx.slidingpanelayout.widget.SlidingPaneLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/sliding_pane_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list_pane"
        android:layout_width="280dp"
        android:layout_height="match_parent"
        android:layout_gravity="start"/>

    <!-- Detail pane is a nested navigation host. Its graph is not connected
         to the main graph that contains the two_pane_fragment destination. -->
    <androidx.fragment.app.FragmentContainerView
        android:id="@+id/detail_pane"
        android:layout_width="300dp"
        android:layout_weight="1"
        android:layout_height="match_parent"
        android:name="androidx.navigation.fragment.NavHostFragment"
        app:navGraph="@navigation/detail_pane_nav_graph" />
</androidx.slidingpanelayout.widget.SlidingPaneLayout>

Compose

@Composable
fun ItemDetail(selectedItemId: String? = null) {
    val navController = rememberNavController()
    NavHost(navController, "itemSubdetail1") {
        composable("itemSubdetail1") { ItemSubdetail1(...) }
        composable("itemSubdetail2") { ItemSubdetail2(...) }
        composable("itemSubdetail3") { ItemSubdetail3(...) }
    }
}

Ini berbeda dengan grafik navigasi bertingkat karena grafik navigasi NavHost bertingkat tidak terhubung ke grafik navigasi utama sehingga Anda tidak dapat menavigasi langsung dari tujuan dalam satu grafik ke tujuan di grafik lain.

Untuk informasi selengkapnya, lihat Grafik navigasi bertingkat dan Menavigasi dengan Compose.

Status yang dipertahankan

Untuk memberikan tujuan konten yang responsif, aplikasi harus mempertahankan statusnya saat perangkat diputar atau dilipat, atau jendela aplikasi diubah ukurannya. Secara default, perubahan konfigurasi seperti ini akan membuat ulang aktivitas aplikasi, fragmen, hierarki tampilan, dan composable. Cara yang direkomendasikan untuk menyimpan status UI adalah dengan ViewModel atau rememberSaveable karena akan tetap ada meskipun terjadi perubahan konfigurasi. (Lihat Menyimpan status UI dan Status dan Jetpack Compose.)

Perubahan ukuran harus dapat dikembalikan ke setelan semula, misalnya, saat pengguna memutar perangkat lalu memutarnya kembali.

Tata letak yang responsif dapat menampilkan bagian konten yang berbeda dengan ukuran jendela yang berbeda. Dengan demikian, tata letak yang responsif sering kali perlu menyimpan status tambahan yang berkaitan dengan konten, meskipun status tersebut tidak berlaku untuk ukuran jendela saat ini. Misalnya, tata letak mungkin memiliki ruang untuk menampilkan widget scroll tambahan hanya pada lebar jendela yang lebih besar. Jika perubahan ukuran menyebabkan lebar jendela menjadi terlalu kecil, widget akan disembunyikan. Jika ukuran aplikasi berubah menjadi dimensi sebelumnya, widget scroll akan terlihat lagi, dan posisi scroll asli harus dipulihkan.

Cakupan ViewModel

Panduan developer Bermigrasi ke komponen Navigasi merekomendasikan arsitektur aktivitas tunggal tempat tujuan diterapkan sebagai fragmen dan model datanya diterapkan menggunakan ViewModel.

ViewModel selalu mencakup siklus proses, dan setelah siklus proses tersebut berakhir secara permanen, ViewModel akan dihapus dan dapat dibuang. Siklus proses yang mencakup ViewModel—dengan demikian, seberapa luas ViewModel dapat dibagikan—bergantung pada delegasi properti yang digunakan untuk mendapatkan ViewModel.

Dalam kasus yang paling sederhana, setiap tujuan navigasi adalah satu fragmen dengan status UI yang sepenuhnya terisolasi sehingga setiap fragmen dapat menggunakan delegasi properti viewModels() untuk mendapatkan ViewModel yang dicakupkan ke fragmen tersebut.

Untuk berbagi status UI antarfragmen, buat cakupan ViewModel ke aktivitas dengan memanggil activityViewModels() dalam fragmen (yang setara dengan aktivitas hanya viewModels()). Ini memungkinkan aktivitas dan fragmen apa pun yang terpasang di aktivitas untuk berbagi instance ViewModel. Namun, dalam arsitektur satu aktivitas, cakupan ViewModel ini berlaku secara efektif selama aplikasi berjalan, sehingga ViewModel tetap ada di memori meskipun tidak ada fragmen yang menggunakannya.

Misalnya grafik navigasi Anda memiliki urutan tujuan fragmen yang mewakili alur checkout, dan status saat ini untuk seluruh pengalaman checkout berada dalam ViewModel yang dibagikan di antara fragmen. Pencakupan ViewModel pada aktivitas tidak hanya terlalu luas, tetapi sebenarnya juga mengekspos masalah lain: jika pengguna melalui alur checkout untuk satu pesanan, lalu melaluinya lagi untuk pesanan kedua, kedua pesanan tersebut menggunakan instance ViewModel checkout yang sama. Sebelum checkout pesanan kedua, Anda harus menghapus data dari pesanan pertama secara manual, dan kesalahan apa pun dapat merugikan pengguna.

Sebagai gantinya, buat cakupan ViewModel ke grafik navigasi di NavController saat ini. Buat grafik navigasi bertingkat untuk mengenkapsulasi tujuan yang merupakan bagian dari alur checkout. Kemudian, di setiap tujuan fragmen tersebut, gunakan delegasi properti navGraphViewModels() dan teruskan ID grafik navigasi untuk mendapatkan ViewModel bersama. Hal ini memastikan bahwa setelah pengguna keluar dari alur checkout dan grafik navigasi bertingkat berada di luar cakupan, instance ViewModel yang terkait akan dihapus dan tidak akan digunakan untuk checkout berikutnya.

Cakupan Delegasi properti Dapat berbagi ViewModel dengan
Fragmen Fragment.viewModels() Hanya fragmen saat ini
Aktivitas Activity.viewModels()

Fragment.activityViewModels()

Aktivitas dan semua fragmen yang disematkan
Grafik navigasi Fragment.navGraphViewModels() Semua fragmen di grafik navigasi yang sama

Perhatikan bahwa jika Anda menggunakan host navigasi bertingkat (lihat di atas), tujuan dalam host tersebut tidak dapat berbagi ViewModel dengan tujuan di luar host saat menggunakan navGraphViewModels() karena grafik tidak terhubung. Dalam hal ini, Anda dapat menggunakan cakupan aktivitas.

Status yang ditarik

Di Compose, Anda dapat mempertahankan status selama perubahan ukuran jendela dengan penarikan status. Dengan menarik status composable ke posisi yang lebih tinggi di hierarki komposisi, status dapat dipertahankan meskipun composable tidak lagi terlihat.

Di bagian Compose pada Tujuan konten dengan tata letak alternatif di atas, kita menarik status composable tampilan daftar-detail ke ListDetailRoute sehingga status tersebut dipertahankan, apa pun composable yang akan ditampilkan:

@Composable
fun ListDetailRoute(
    // Indicates that the display size is represented by the expanded window size class.
    isExpandedWindowSize: Boolean = false,
    // Identifies the item selected from the list. If null, a item has not been selected.
    selectedItemId: String?,
) { /*...*/ }

Referensi lainnya