回應式使用者介面 (UI) 導覽功能

「導覽」是指與應用程式 UI 互動,藉此存取應用程式內容目的地的過程。Android「導覽原則」提供多條準則,可協助您建立符合直覺且連貫的應用程式導覽體驗。

回應式 UI 可提供回應式內容目的地,而且通常包含可因應螢幕大小變化的各類型導覽元素,例如小型螢幕上的底部導覽列、中型螢幕上的導覽邊欄,或大型螢幕上的永久性導覽匣,不過回應式 UI 仍應符合導覽原則。

Jetpack Navigation 元件 會導入導覽原則,可用於開發具有回應式 UI 的應用程式。

圖 1. 展開、中等、和精簡寬度螢幕,內含導覽匣、邊欄和底部列。

回應式使用者介面導覽

應用程式所佔用的螢幕視窗大小會影響人體工學和可用性。視窗大小類別可讓您決定適當的導覽元素 (例如導覽列、導覽邊欄或導覽匣),並放置在使用者最方便存取的位置。根據 Material Design 版面配置指南,導覽元素會占用螢幕前端的永久空間,當應用程式寬度較小,可以移至底部邊緣。您選擇的導覽元素大致上取決於應用程式視窗的大小,以及元素必須包含的項目數量。

視窗大小類別 幾個項目 許多項目
精簡寬度 底部導覽列 導覽匣 (前端或底部)
中等寬度 導覽邊欄 導覽匣 (前端)
展開寬度 導覽邊欄 永久性導覽匣 (前端)

在以檢視畫面為基礎的版面配置中,可透過視窗大小類別中斷點限定版面配置資源檔案,以便於不同的螢幕尺寸使用不同的導覽元素。Jetpack Compose 可以使用視窗大小類別 API 提供的中斷點,以程式輔助方式判斷最適合應用程式視窗的導覽元素。

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
        }
    )
}

回應式內容到達網頁

在回應式 UI 中,每個內容到達網頁的版面配置都必須根據視窗大小的變化進行調整。您的應用程式可以調整版面配置間距、重新定位元素、新增或移除內容,或變更 UI 元素 (包括導覽元素)。(請參閱 將 UI 遷移至回應式版面配置支援不同的螢幕大小)。

如果每個到達網頁都能妥善處理大小調整事件,那麼變更就只涉及 UI。應用程式狀態的其餘部分 (包括導覽) 則不受影響。

導覽不應對視窗大小變更產生副作用。請勿只為了因應不同的視窗大小而建立內容到達網頁。例如,請勿針對摺疊式裝置的不同螢幕建立不同的內容目的地。

當視窗大小變更時,導覽會面臨以下問題:

  • 在前往新的到達網頁前,系統可能會短暫顯示舊的到達網頁 (先前的視窗大小)
  • 為保持可還原性 (例如裝置在摺疊及展開狀態間切換時),必須為每個視窗大小啟用導覽功能
  • 難以在到達網頁之間保持應用程式狀態,因為導覽功能在彈出返回堆疊時可能會刪除狀態

此外,當視窗大小發生變化時,您的應用程式甚至可能未在前景運行。相較於前景應用程式,您的應用程式的版面配置可能需要多一點空間。當使用者返回應用程式時,應用程式的方向和視窗大小都可能有所異動。

如果您的應用程式會依視窗大小要求不重複的內容到達網頁,建議您將相關到達網頁組合成含有替代版面配置的單一到達網頁。

具有替代版面配置的內容到達網頁

作為回應式設計的一部分,單一導覽到達網頁可以根據應用程式視窗大小替代版面配置。每個版面配置會佔滿整個視窗,但會針對不同的視窗大小提供不同的版面配置。

標準化範例為清單詳細資料檢視畫面。對於小型視窗,您的應用程式會為清單顯示一個內容版面配置,也為詳細資料顯示一個內容版面配置。導覽至清單詳細資料檢視畫面的到達網頁,一開始只會顯示清單版面配置。選取清單項目後,您的應用程式隨即顯示詳細資料版面配置取代清單。選取返回控制項後,系統隨即顯示清單版面配置取代詳細資料。不過,如果是展開的視窗大小,清單和詳細資料版面配置會並列顯示。

View

SlidingPaneLayout 可讓您建立單一導覽到達網頁,以便在大螢幕上並排顯示兩個內容窗格,但像手機等小螢幕裝置則一次只能顯示一個窗格。

<!-- 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>

如要進一步瞭解如何使用 SlidingPaneLayout 導入清單詳細資料版面配置,請參閱建立雙窗格版面配置

Compose

在 Compose 中,只要在單一路徑中結合替代的可組合項目,即可導入清單詳細資料檢視畫面,該路徑根據視窗大小類別,針對各個大小類別提供適當的可組合項。

路徑是指通向內容目到達網頁的導覽路徑,但通常是單一可組合項,但也可以是多個替代可組合項。商業邏輯決定顯示哪些替代可組合項。無論顯示哪一個替代可組合項,可組合項都會填滿應用程式視窗。

清單/詳細資料檢視畫面包含三個可組合函式,例如:

/* 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)
  }
}

單一導覽路徑可存取清單詳細資料檢視畫面:

@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 (導覽到達網頁) 決定要發出三個可組合項中的哪一個:ListAndDetail 適用於展開的視窗大小;ListOfItemsItemDetail 適用於精簡版視窗,取決於是否已選取清單項目。

路徑包含於 NavHost 中,例如:

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

只要檢查應用程式的 WindowMetrics,即可提供 isExpandedWindowSize 引數。

selectedItemId 引數可透過 ViewModel 提供,後者可維持所有視窗大小的狀態。當使用者從清單中選取項目時,系統會隨即更新 selectedItemId 狀態變數:

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,
        /*...*/
      )
    }
  }
}

當項目詳細資料可組合項佔據整個應用程式視窗時,路徑包含自訂的 BackHandler

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,
        /*...*/
      )
    }
  }
}

結合 ViewModel 的應用程式狀態與視窗大小類別資訊,讓您選擇適當的可組合項變得輕而易舉。透過維持 單向資料流,應用程式可以在充分利用可用的顯示空間,同時維持應用程式狀態。

如需 Compose 中清單/詳細資料檢視畫面的完整實作方式,請參閱 GitHub 上的 JetNews 範例。

一張導覽圖

如要為任何裝置或視窗大小提供一致的使用者體驗,請使用單一導覽圖,其中各個內容到達網頁的版面配置均採用回應式。

如果您為每個視窗大小類別使用不同的導覽圖,那麼每當應用程式從某個大小類別轉換至另一個大小類別時,您都必須決定使用者在其他導覽圖中的目前到達網頁、建構返回堆疊,以及協調圖表之間的不同狀態資訊。

巢狀導覽主機

您的應用程式可能含具有本身內容到達網頁的內容到達網頁。例如,在清單詳細資料檢視畫面中,項目詳細資料窗格可能包含 UI 元素,用於前往取代項目詳細資料的內容。

如要導入這種子導覽,詳細資料窗格可以是一個擁有自己導覽圖的巢狀導覽主機,而其中的導覽圖可指定透過詳細資料窗格存取的到達網頁:

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(...) }
    }
}

這不同於巢狀導覽圖,因為巢狀 NavHost 的導覽圖並未與主要導覽圖連結。也就是說,您無法從某個導覽圖中的目的地直接切換至另一個導覽圖中的目的地。

詳情請參閱「巢狀導覽圖」和「使用 Compose 導覽」。

保留狀態

如要提供回應式內容到達網頁,您的應用程式必須在裝置旋轉或折疊或調整應用程式大小時保持其狀態。根據預設,諸如此類的設定變更會重新建立應用程式的活動、片段、檢視區塊階層和可組合項。如要儲存 UI 狀態,建議您使用 ViewModelrememberSaveable,這兩者在每次設定變更後都仍然有效 (請參閱「儲存 UI 狀態」和「狀態與 Jetpack Compose」。)

大小變更應具有可還原性,例如在使用者旋轉裝置又轉回原本方向時,還原為原本大小。

回應式版面配置可根據不同視窗大小顯示不同內容片段,因此回應式版面配置通常需要儲存其他與內容相關的狀態,即使狀態不適用於目前視窗大小也仍需儲存。舉例來說,某個版面配置只可能在較寬的視窗上顯示額外的捲動小工具。如果調整大小事件導致視窗寬度太窄,小工具就會隱藏。當應用程式調整大小為先前的尺寸時,捲動小工具會再次顯示,並且應還原到原本的捲動位置。

ViewModel 範圍

開發人員指南「改用導覽元件」建議採用單一活動架構,以片段實作到達網頁,並使用 ViewModel 導入資料模型。

ViewModel 的範圍一律限定為某個生命週期,當該生命週期永久結束後,系統就會清除 並捨棄 ViewModelViewModel 的生命週期限定範圍,以及 ViewModel 因此可共用的範圍,都取決於使用哪一個屬性委派取得 ViewModel

在最簡單的情況下,每個導覽到達網頁都是單一片段,具有完全獨立的 UI 狀態。因此,每個片段都可以使用 viewModels() 屬性委派,取得範圍限定為該片段的 ViewModel

如要在片段之間共用 UI 狀態,請在片段中呼叫 activityViewModels(),將 ViewModel 範圍限定為活動 (活動相當於 viewModels())。這樣一來,活動及附加至活動的任何片段都能共用 ViewModel 例項。不過,在單一活動架構中,這個 ViewModel 範圍只要應用程式存在,即可持續有效,因此即使並未用於片段,ViewModel 仍會留存在記憶體中。

假設導覽圖有一系列代表結帳流程的片段到達網頁,並且整個結帳體驗的目前狀態都處於在片段之間共用的 ViewModel。將 ViewModel 的範圍限定為活動,不僅過於廣泛,實際上也曝露出另一個問題:如果使用者先完成一張訂單的結帳流程,再完成第二張訂單的結帳流程,則兩張訂單會使用相同的結帳 ViewModel 例項。在第二張訂單結帳之前,您必須手動清除第一張訂單的資料,任何錯誤都可能會讓使用者付出高昂代價。

請將 ViewModel 範圍改為設定在目前 NavController 中的導覽圖。建立一個巢狀導覽圖,用於封裝屬於結帳流程的到達網頁。接著,在每個片段到達網頁中使用 navGraphViewModels() 屬性委派,並透過傳遞導覽圖 ID 取得共用的 ViewModel。這能確保使用者離開結帳流程後,巢狀導覽圖不超出範圍,系統就會捨棄對應的 ViewModel 例項,而不會用於下次的結帳。

範圍 資源委派 ViewModel 可共用的物件:
片段 Fragment.viewModels() 僅限目前的片段
Activity Activity.viewModels()

Fragment.activityViewModels()

活動及附加至活動中的所有片段
導覽圖 Fragment.navGraphViewModels() 同一導覽圖中的所有片段

請注意,如果您使用的是巢狀導覽主機 (如上所示),則在使用 navGraphViewModels() 時,該主機中的到達網頁無法與主機以外的目的地共用 ViewModel,因為導覽圖並未連結。在這種情況下,您可以改用活動的範圍。

提升的狀態

在 Compose 中,您可以透過狀態提升,在視窗大小變更期間保留狀態。如果將可組合項的狀態提升至組合樹狀結構中較高位置,即使可組合項狀態不再顯示,系統仍可保留狀態。

在上述「具有替代版面配置的內容到達網頁」一節的「Compose」部分,我們將清單/詳細資料檢視畫面的可組合函式狀態提升為 ListDetailRoute,因此無論顯示何種可組合函式,狀態都會保留:

@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?,
) { /*...*/ }

其他資源