使用 Compose 在以檢視區塊為基礎的 Android 應用程式中新增自動調整式版面配置

1. 事前準備

Android 裝置有多種外型與尺寸,因此應用程式需能根據不同的螢幕大小來調整版面配置,透過單一 Android 套件 (APK) 或 Android App Bundle (AAB) 供各種使用者和裝置使用。為此,在定義應用程式時,您需要採用回應式和自動調整式的版面配置,而非採用以特定螢幕大小和顯示比例為依據的靜態尺寸。自動調整式版面配置會配合可用的螢幕空間而調整。

本程式碼研究室會介紹建構自動調整式 UI 的基本概念,並說明如何為顯示運動項目清單及各項運動詳細資料的應用程式調整畫面比例,以便支援大螢幕裝置。這款運動應用程式包含三個畫面:主畫面、喜愛項目和設定。主畫面會呈現運動項目清單,並在您從清單選擇某項運動時,顯示新聞報導的預留位置。喜愛項目和設定的畫面也會顯示預留位置文字。只要在底部導覽選單中選取相關項目,即可切換畫面。

這個應用程式開始在大螢幕上顯示時,會有以下版面配置問題:

  • 無法用於直向螢幕模式。
  • 在大螢幕上顯示許多空白區域。
  • 在大螢幕上一律顯示底部導覽選單。

設定應用程式的自動調整功能,就能獲得以下效果:

  • 支援橫向「和」直向螢幕模式。
  • 當有足夠的水平空間時,就並排顯示運動項目清單和各項運動的新聞報導。
  • 遵循 Material Design 指南顯示導覽元件。

這款應用程式是具有多個片段的單一活動應用程式。您可以使用下列檔案:

  • AndroidManifest.xml 檔案,用於提供運動應用程式的中繼資料。
  • MainActivity.kt 檔案,其中包含使用 activity_main.xml 檔案產生的程式碼、override 註解、代表各種寬度視窗類別大小的 enum 類別,以及用於擷取應用程式視窗的寬度視窗大小類別的方法定義。系統會在建立活動時初始化底部導覽選單。
  • activity_main.xml 檔案,用於定義 Main 活動的預設版面配置。
  • layout-sw600dp/activity_main.xml 檔案,用於定義 Main 活動的替代版面配置。如果應用程式的視窗寬度大於或等於 600dp 值,就會啟用替代版面配置。當中顯示的內容與預設版面配置中的起始內容相同。
  • SportsListFragment.kt 檔案,內含運動項目清單實作內容和自訂返回導覽功能。
  • fragment_sports_list.xml 檔案,用於定義運動項目清單的版面配置。
  • navigation_menu.xml 檔案,用於定義底部導覽選單項目。

在精簡視窗中,運動應用程式會顯示運動項目清單,並使用導覽列做為頂端導覽元件。 在中等視窗中,運動應用程式會並排顯示運動項目清單和相關新聞報導。導覽邊欄顯示為頂端導覽元件。 在展開視窗中,這款運動應用程式會顯示導覽匣、運動項目清單和新聞報導。

圖 1. 這款運動應用程式可透過單一 APK 或 AAB 支援顯示不同視窗大小。

必要條件

  • 大致瞭解以檢視區塊為基礎的 UI 開發作業
  • 熟悉 Kotlin 語法,包括 lambda 函式
  • 完成「Jetpack Compose 基本概念」程式碼研究室

課程內容

  • 如何支援設定變更。
  • 如何以小幅修改程式碼的方式,新增替代版面配置。
  • 如何實作清單詳細資料 UI,針對不同視窗大小表現出不同行為。

建構項目

支援以下項目的 Android 應用程式:

  • 橫向的裝置螢幕模式
  • 平板電腦、電腦和行動裝置
  • 適應不同螢幕大小的清單詳細資料行為

軟硬體需求

2. 做好準備

請下載這個程式碼研究室的程式碼並設定專案:

  1. 在指令列中複製這個 GitHub 存放區中的程式碼:
$ git clone https://github.com/android/add-adaptive-layouts
  1. 在 Android Studio 中開啟 AddingAdaptiveLayout 專案。本專案是在多個 Git 分支版本中建構而成:
  • main 分支版本包含本專案的範例程式碼。修改此分支版本的程式碼,才能完成程式碼研究室。
  • end 分支版本包含本程式碼研究室的解決方案。

3. 將頂端導覽元件遷移至 Compose

運動應用程式會使用底部導覽選單做為頂端導覽元件。這個導覽元件是透過 BottomNavigationView 類別實作。在本節中,您會將頂端導覽元件遷移至 Compose。

以密封類別代表相關聯選單資源的內容

  1. 建立密封的 MenuItem 類別來代表 navigation_menu.xml 檔案的內容,然後將 iconId 參數、labelId 參數和 destinationId 參數傳遞至該類別。
  2. 新增 Home 物件、Favorite 物件和 Settings 物件,做為與目的地對應的子類別。
sealed class MenuItem(
    // Resource ID of the icon for the menu item
    @DrawableRes val iconId: Int,
    // Resource ID of the label text for the menu item
    @StringRes val labelId: Int,
    // ID of a destination to navigate users
    @IdRes val destinationId: Int
) {

    object Home: MenuItem(
        R.drawable.ic_baseline_home_24,
        R.string.home,
        R.id.SportsListFragment
    )

    object Favorites: MenuItem(
        R.drawable.ic_baseline_favorite_24,
        R.string.favorites,
        R.id.FavoritesFragment
    )

    object Settings: MenuItem(
        R.drawable.ic_baseline_settings_24,
        R.string.settings,
        R.id.SettingsFragment
    )
}

以可組合函式實作底部導覽選單

  1. 定義包含以下三個參數的 BottomNavigationBar 可組合函式:menuItems 物件 (設為 List<MenuItem> 值)、modifier 物件 (設為 Modifier = Modifier 值),以及 onMenuSelected 物件 (設為 (MenuItem) -> Unit = {} lambda 函式)。
  2. BottomNavigationBar 可組合函式主體中,呼叫包含 modifier 參數的 NavigationBar() 函式。
  3. 在傳遞至 NavigationBar() 函式的 lambda 函式中,對 menuItems 參數呼叫 forEach() 方法,然後在設為 foreach() 方法呼叫的 lambda 函式中,呼叫 NavigationBarItem() 函式。
  4. 將以下項目傳遞至 NavigationBarItem() 函式:selected 參數 (設為 false 值)、onClick 參數 (設為 lambda 函式,其中包含使用 MenuItem 參數的 onMenuSelected 函式)、icon 參數 (設為 lambda 函式,其中包含接受 painter = painterResource(id = menuItem.iconId) 參數和 contentDescription = null 參數的 Icon 函式),以及 label 參數 (設為 lambda 函式,其中包含接受 (text = stringResource(id = menuItem.labelId) 參數的 Text 函式)。
@Composable
fun BottomNavigationBar(
    menuItems: List<MenuItem>,
    modifier: Modifier = Modifier,
    onMenuSelected: (MenuItem) -> Unit = {}
) {

    NavigationBar(modifier = modifier) {
        menuItems.forEach { menuItem ->
            NavigationBarItem(
                selected = false,
                onClick = { onMenuSelected(menuItem) },
                icon = {
                    Icon(
                        painter = painterResource(id = menuItem.iconId),
                        contentDescription = null)
                },
                label = { Text(text = stringResource(id = menuItem.labelId))}
            )
        }
    }
}

在版面配置資源檔案中將 BottomNavigationView 元素替換為 ComposeView 元素

  • activity_main.xml 檔案中,將 BottomNavigationView 元素替換為 ComposeView 元素。這項操作會透過 Compose,將底部導覽元件嵌入以檢視區塊為基礎的 UI 版面配置。

activity_main.xml

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">

        <androidx.fragment.app.FragmentContainerView
            android:id="@+id/nav_host_fragment_content_main"
            android:name="androidx.navigation.fragment.NavHostFragment"
            android:layout_width="0dp"
            android:layout_height="0dp"
            app:defaultNavHost="true"
            app:layout_constraintBottom_toTopOf="@id/top_navigation"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:navGraph="@navigation/nav_graph" />

        <androidx.compose.ui.platform.ComposeView
            android:id="@+id/navigation"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:menu="@menu/top_navigation" />
    </androidx.constraintlayout.widget.ConstraintLayout>

整合以 Compose 為基礎的底部導覽選單與以檢視區塊為基礎的 UI 版面配置

  1. MainActivity.kt 檔案中,定義設為 MenuItem 物件清單的 navigationMenuItems 變數。MenuItems 物件會依照清單順序顯示在底部導覽選單中。
  2. 呼叫 BottomNavigationBar() 函式,將底部導覽選單嵌入 ComposeView 物件。
  3. 在傳遞至 BottomNavigationBar 函式的回呼函式中,找到與使用者所選項目相關聯的目的地。

MainActivity.kt

val navigationMenuItems = listOf(
    MenuItem.Home,
    MenuItem.Favorites,
    MenuItem.Settings
)

binding.navigation.setContent {
    MaterialTheme {
        BottomNavigationBar(menuItems = navigationMenuItems){ menuItem ->
            navController.navigate(screen.destinationId)
        }
    }
}

4. 支援水平螢幕模式

如果應用程式支援大螢幕裝置,就應該同時支援橫向和直向螢幕模式。目前應用程式只有單一活動:MainActivity 活動。

如要設定活動在裝置上的顯示方向,需使用 AndroidManifest.xml 檔案中的 android:screenOrientation 屬性,目前的設定值為 portrait

請讓應用程式支援橫向模式:

  1. android:screenOrientation 屬性設為 fullUser 值。使用者可利用這項設定鎖定螢幕方向。螢幕方向是根據裝置方向感應器而定 (有四種螢幕方向)。

AndroidManifest.xml

<activity
    android:name=".MainActivity"
    android:exported="true"
    android:screenOrientation="fullUser">

只要在 AndroidManifest.xml 檔案中,將活動元素的 android:screenOrientation 屬性值設為 fullUser,運動應用程式就能支援水平模式。

圖 2. 更新 AndroidManifest.xml 檔案後,應用程式就能在橫向模式下執行。

5. 視窗大小類別

這些中斷點值可協助將視窗大小區分為預先定義的大小類別,包括精簡、中等和展開大小,以及應用程式可用的原始視窗大小。您可以運用這些大小類別來設計、開發及測試自動調整式版面配置。

系統會分別處理可用的寬度和高度,因此與應用程式相關聯的視窗大小類別一律有兩個:寬度視窗大小類別和高度視窗大小類別。

三個寬度視窗大小類別之間有兩個中斷點。精簡和中等寬度視窗大小類別之間的中斷點值為 600 dp,中等和展開視窗大小類別之間的值則為 840 dp。

圖 3. 寬度視窗大小類別及相關聯的中斷點。

三個高度視窗大小類別之間有兩個中斷點。精簡和中等高度視窗大小類別之間的中斷點值為 480 dp,中等和展開視窗大小類別之間的值則為 900 dp。

圖 4. 高度視窗大小類別及相關聯的中斷點。

視窗大小類別代表應用程式目前視窗的大小。換句話說,您無法根據實體裝置大小判斷視窗大小類別。即使在相同裝置上執行應用程式,相關聯的視窗大小類別也會因設定而變更,例如在分割畫面模式下執行應用程式時。這會造成兩種重要影響:

  • 實體裝置不能保證特定的視窗大小類別。
  • 視窗大小類別可能在應用程式的生命週期中發生變化。

在應用程式中加入自動調整式版面配置後,即可對各種視窗大小測試應用程式的顯示效果,尤其是精簡、中等和展開的視窗大小類別。您必須對每個視窗大小類別進行測試,但對於許多使用情境而言仍顯不足。請務必在各種不同大小的視窗上測試應用程式,確保 UI 能正確縮放。詳情請參閱「大螢幕版面配置」和「大螢幕應用程式品質」。

6. 在大螢幕上並排顯示清單和詳細資料窗格

清單詳細資料 UI 可能需要根據目前的寬度視窗大小類別表現出不同行為。當中等或展開寬度視窗大小類別與應用程式建立關聯後,即表示應用程式有足夠空間來並排顯示清單窗格和詳細資料窗格,因此使用者可同時查看項目清單及所選項目的詳細資料,無需經過畫面轉換。然而,這種配置在較小型螢幕上可能會太擁擠,因此建議您一次只顯示一個窗格,並讓清單窗格先行顯示。當使用者輕觸清單中的項目時,詳細資料窗格會顯示所選項目的詳細資料。SlidingPaneLayout 類別可管理相關邏輯,判斷這兩種使用者體驗中的哪一種更適合目前的視窗大小。

設定清單窗格版面配置

SlidingPaneLayout 類別是以檢視區塊為基礎的 UI 元件。您將為清單窗格修改版面配置資源檔案,也就是本程式碼研究室中的 fragment_sports_list.xml 檔案。

SlidingPaneLayout 類別包含兩個子元素。每個子元素的寬度和權重屬性都是影響 SlidingPaneLayout 類別的主要因素,用來判斷視窗大小是否足夠並排顯示兩個檢視區塊。如果視窗大小不足,系統會顯示全螢幕詳細資料 UI,取代全螢幕清單。當視窗大於讓窗格並排顯示的最低尺寸需求時,就會依權重值調整兩個窗格的比例。

SlidingPaneLayout 類別已套用至 fragment_sports_list.xml 檔案。清單窗格寬度已設為 1280dp。這就是清單窗格和詳細資料窗格無法並排顯示的原因。

當螢幕寬度大於 580dp 時,讓窗格並排顯示:

  • RecyclerView 的寬度設為 280dp

fragment_sports_list.xml

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

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="280dp"
        android:layout_height="match_parent"
        android:layout_weight="1"
        android:clipToPadding="false"
        android:padding="8dp"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />

    <androidx.fragment.app.FragmentContainerView
        android:layout_height="match_parent"
        android:layout_width="300dp"
        android:layout_weight="1"
        android:id="@+id/detail_container"
        android:name="com.example.android.sports.NewsDetailsFragment"/>
</androidx.slidingpanelayout.widget.SlidingPaneLayout>

在您更新 sports_list_fragment.xml 檔案後,運動應用程式會並排顯示運動項目清單和所選運動的新聞報導。

圖 5. 更新版面配置資源檔案後,清單窗格和詳細資料窗格就會並排顯示。

切換詳細資料窗格

當中等或展開寬度視窗大小類別與應用程式建立關聯時,清單窗格和詳細資料窗格就會並排顯示。不過,當使用者從清單窗格中選取某個項目時,螢幕畫面會完全轉換成詳細資料窗格。

應用程式將畫面切換成只顯示運動新聞,但應用程式應該要繼續顯示運動項目清單和相關新聞。

圖 6. 選取清單中的某項運動後,畫面隨即轉換為詳細資料窗格。

之所以發生這個問題,是因為使用者在清單窗格中選取某個項目時觸發了導覽事件。您可以在 SportsListFragment.kt 檔案中找到相關程式碼。

SportsListFragment.kt

val adapter = SportsAdapter {
    sportsViewModel.updateCurrentSport(it)
    // Navigate to the details pane.
    val action =
       SportsListFragmentDirections.actionSportsListFragmentToNewsFragment()
    this.findNavController().navigate(action)
}

當螢幕空間不足以並排顯示清單窗格和詳細資料窗格時,請務必讓畫面僅完全轉換為詳細資料窗格:

  • adapter 函式變數中,新增 if 陳述式來檢查 SlidingPaneLayout 類別的 isSlidable 屬性是否為 true,以及 SlidingPaneLayout 類別的 isOpen 屬性是否為 false。

SportsListFragment.kt

val adapter = SportsAdapter {
    sportsViewModel.updateCurrentSport(it)
    if(slidingPaneLayout.isSlidable && !slidingPaneLayout.isOpen){
        // Navigate to the details pane.
        val action =
           SportsListFragmentDirections.actionSportsListFragmentToNewsFragment()
        this.findNavController().navigate(action)
    }
}

7. 依據寬度視窗大小類別選擇正確的導覽元件

應用程式採用 Material Design 時,應根據實際環境自動選擇適合的元件。在本節中,您將根據目前的寬度視窗大小類別,選擇頂端導覽列的導覽元件。下表說明每個視窗大小類別預期使用的導覽元件:

寬度視窗大小類別

導覽元件

精簡

底部導覽

中等

導覽邊欄

展開

固定式導覽匣

實作導覽邊欄

  1. 建立 NavRail() 可組合函式,其中包含以下三個參數:menuItems 物件 (設為 List<MenuItem> 值)、modifier 物件 (設為 Modifier 值),以及 onMenuSelected lambda 函式。
  2. 在函式主體中呼叫 NavigationRail() 函式,其中包含 modifier 物件做為參數。
  3. menuItems 物件中的每個 MenuItem 物件呼叫 NavigationRailItem() 函式,與您對 BottomNavigationBar() 函式中的 NavigationBarItem 函式執行的動作相同。

實作固定式導覽匣

  1. 建立 NavigationDrawer() 可組合函式,其中包含以下三個參數:menuItems 物件 (設為 List<MenuItem> 值)、modifier 物件 (設為 Modifier 值),以及 onMenuSelected lambda 函式。
  2. 在函式主體中呼叫 Column() 函式,其中包含 modifier 物件做為參數。
  3. menuItems 物件中的每個 MenuItem 物件呼叫 Row() 函式。
  4. Row() 函式主體新增 icontext 標籤,與您對 BottomNavigationBar() 函式中的 NavigationBarItem 函式執行的動作相同。

依據寬度視窗大小類別選擇正確的導覽元件

  1. 如要擷取目前的寬度視窗大小類別,請在傳遞至 ComposeView 物件上 setContent() 方法的可組合函式中,呼叫 rememberWidthSizeClass() 函式。
  2. 建立條件分支版本,以根據擷取的寬度視窗大小類別選擇導覽元件,並呼叫選定的該元件。
  3. Modifier 物件傳遞至 NavigationDrawer 函式,並將寬度值指定為 256dp

ActivityMain.kt

binding.navigation.setContent {
    MaterialTheme {
        when(rememberWidthSizeClass()){
            WidthSizeClass.COMPACT ->
                BottomNavigationBar(menuItems = navigationMenuItems){ menuItem ->
                    navController.navigate(screen.destinationId)
                }
            WidthSizeClass.MEDIUM ->
                NavRail(menuItems = navigationMenuItems){ menuItem ->
                    navController.navigate(screen.destinationId)
                }
            WidthSizeClass.EXPANDED ->
                NavigationDrawer(
                    menuItems = navigationMenuItems,
                    modifier = Modifier.width(256.dp)
                ) { menuItem ->
                    navController.navigate(screen.destinationId)
                }
        }

    }
}

將導覽元件放置於適當位置

現在應用程式可根據目前寬度視窗大小類別,選擇正確的導覽元件,但無法將所選元件放置於預期的位置。這是因為 ComposeView 元素位於 FragmentViewContainer 元素之下。

請更新 MainActivity 類別的替代版面配置資源:

  1. 開啟 layout-sw600dp/activity_main.xml 檔案。
  2. 更新限制條件,讓 ComposeView 元素和 FragmentContainer 元素橫向並排。

layout-sw600dp/activity_main.xml

  <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_behavior="@string/appbar_scrolling_view_behavior">

        <androidx.fragment.app.FragmentContainerView
            android:id="@+id/nav_host_fragment_content_main"
            android:name="androidx.navigation.fragment.NavHostFragment"
            android:layout_width="0dp"
            android:layout_height="0dp"
            app:defaultNavHost="true"
            app:layout_constraintLeft_toRightOf="@+id/top_navigation"
            app:layout_constraintRight_toRightOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:navGraph="@navigation/nav_graph" />

        <androidx.compose.ui.platform.ComposeView
            android:id="@+id/top_navigation"
            android:layout_width="wrap_content"
            android:layout_height="0dp"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:menu="@menu/top_navigation" />
    </androidx.constraintlayout.widget.ConstraintLayout>

應用程式修改完成後,即可根據相關聯的寬度視窗大小類別選擇正確的導覽元件。第一張螢幕截圖顯示中等寬度視窗大小類別的畫面,第二個螢幕截圖則顯示展開寬度視窗大小類別的畫面。

當運動應用程式與中等寬度視窗大小類別建立關聯時,會顯示導覽邊欄、運動項目清單和新聞報導。 當運動應用程式與展開寬度視窗大小類別建立關聯時,主畫面會顯示導覽匣、運動項目清單和新聞報導。

圖 7. 中等和展開寬度視窗大小類別的畫面。

8. 恭喜

恭喜!您已完成本程式碼研究室,並瞭解如何使用 Compose,在以 View 為基礎的 Android 應用程式中新增自動調整式版面配置。在過程中,您學到 SlidingPaneLayout 類別、視窗大小類別,以及可根據寬度視窗大小類別選擇的導覽元件。

瞭解詳情