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

Stay organized with collections Save and categorize content based on your preferences.

「導覽」是與應用程式的 UI 互動的過程,藉此存取應用程式內容到達網頁。Android 的 導覽原則 提供多條準則,可協助您建立一致性、直覺化的應用程式導覽。

回應式 UI 可提供回應式的內容到達網頁,而且通常會包含不同類型的導覽元素,以因應螢幕大小變化,例如小型顯示器上的底部 導覽列、中型顯示器上的導覽邊欄,或者大型顯示器上的永久性導覽匣,但回應式 UI 仍應符合導覽原則。

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

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

回應式使用者介面導覽

應用程式所佔用的螢幕視窗大小會影響人體工學和可用性。視窗大小類別可讓您決定適當的導覽元素 (例如,導覽列、邊欄或導覽匣),並將其放置在使用者最方便存取的位置。在質感設計版面配置準則中,導覽元素佔用螢幕前端的永久空間,當應用程式寬度為精簡版時,即可移至底部邊緣。您選擇的導覽元素大致上取決於應用程式視窗的大小,以及元素必須容納的項目數量。

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

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

檢視畫面

<!-- 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-w840dp/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 = {
                BottomNavigation {
                    icons.forEach { item ->
                        BottomNavigationItem(
                            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。應用程式狀態的其餘部分 (包括導航) 則不受影響。

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

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

  • 在您前往新的到達網頁前,系統可能需要短暫時間顯示舊的到達網頁 (針對上一個視窗大小)
  • 為保持可還原性 (例如,當裝置處於折疊及展開狀態時),您需要每個視窗大小啟用導覽功能
  • 在到達網頁之間保持應用程式狀態絕非易事,因為導覽功能可能會在彈出返回堆疊時,導覽功能會刪除狀態

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

如果您的應用程式根據視窗大小要求唯一的內容到達網頁,請考慮將相關的到達網頁組合成一個包含替代版面配置的單一到達網頁。

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

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

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

檢視畫面

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 元素,用於前往取代項目詳細資料的內容。

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

檢視畫面

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

NavHost(navController = navController, startDestination = "listDetailRoute") {
  composable("listDetailRoute") {
    ListDetailRoute(isExpandedWindowSize = isExpandedWindowSize,
                    selectedItemId = selectedItemId)
  }
  navigation(startDestination = "itemSubdetail1", route = "itemSubDetail") {
    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.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?,
) { /*...*/ }