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 應用程式:
- 橫向的裝置螢幕模式
- 平板電腦、電腦和行動裝置
- 適應不同螢幕大小的清單詳細資料行為
軟硬體需求
- Android Studio Bumblebee | 2021.1.1 以上版本
- Android 平板電腦或模擬器
2. 做好準備
請下載這個程式碼研究室的程式碼並設定專案:
- 在指令列中複製這個 GitHub 存放區中的程式碼:
$ git clone https://github.com/android/add-adaptive-layouts
- 在 Android Studio 中開啟
AddingAdaptiveLayout
專案。本專案是在多個 Git 分支版本中建構而成:
main
分支版本包含本專案的範例程式碼。修改此分支版本的程式碼,才能完成程式碼研究室。end
分支版本包含本程式碼研究室的解決方案。
3. 將頂端導覽元件遷移至 Compose
運動應用程式會使用底部導覽選單做為頂端導覽元件。這個導覽元件是透過 BottomNavigationView
類別實作。在本節中,您會將頂端導覽元件遷移至 Compose。
以密封類別代表相關聯選單資源的內容
- 建立密封的
MenuItem
類別來代表navigation_menu.xml
檔案的內容,然後將iconId
參數、labelId
參數和destinationId
參數傳遞至該類別。 - 新增
Home
物件、Favorite
物件和Settings
物件,做為與目的地對應的子類別。
MenuItem.kt
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
)
}
以可組合函式實作底部導覽選單
- 定義包含以下三個參數的
BottomNavigationBar
可組合函式:menuItems
物件 (設為List<MenuItem>
值)、modifier
物件 (設為Modifier = Modifier
值),以及onMenuSelected
物件 (設為(MenuItem) -> Unit = {}
lambda 函式)。 - 在
BottomNavigationBar
可組合函式主體中,呼叫包含modifier
參數的NavigationBar()
函式。 - 在傳遞至
NavigationBar()
函式的 lambda 函式中,對menuItems
參數呼叫forEach()
方法,然後在設為foreach()
方法呼叫的 lambda 函式中,呼叫NavigationBarItem()
函式。 - 將以下項目傳遞至
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
函式)。
Navigation.kt
@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 版面配置
- 在
MainActivity.kt
檔案中,定義設為MenuItem
物件清單的navigationMenuItems
變數。MenuItems
物件會依照清單順序顯示在底部導覽選單中。 - 呼叫
BottomNavigationBar()
函式,將底部導覽選單嵌入ComposeView
物件。 - 在傳遞至
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
。
請讓應用程式支援橫向模式:
- 將
android:screenOrientation
屬性設為fullUser
值。使用者可利用這項設定鎖定螢幕方向。螢幕方向是根據裝置方向感應器而定 (有四種螢幕方向)。
AndroidManifest.xml
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="fullUser">
圖 2. 更新 AndroidManifest.xml
檔案後,應用程式就能在橫向模式下執行。
5. 視窗大小類別
這些中斷點值可協助將視窗大小區分為預先定義的大小類別,包括精簡、中等和展開大小,以及應用程式可用的原始視窗大小。您可以運用這些大小類別來設計、開發及測試自動調整式版面配置。
系統會分別處理可用的寬度和高度,因此與應用程式相關聯的視窗大小類別一律有兩個:寬度視窗大小類別和高度視窗大小類別。
圖 3. 寬度視窗大小類別及相關聯的中斷點。
圖 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>
圖 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 時,應根據實際環境自動選擇適合的元件。在本節中,您將根據目前的寬度視窗大小類別,選擇頂端導覽列的導覽元件。下表說明每個視窗大小類別預期使用的導覽元件:
寬度視窗大小類別 | 導覽元件 |
精簡 | 底部導覽 |
中等 | 導覽邊欄 |
展開 | 固定式導覽匣 |
實作導覽邊欄
- 建立
NavRail()
可組合函式,其中包含以下三個參數:menuItems
物件 (設為List<MenuItem>
值)、modifier
物件 (設為Modifier
值),以及onMenuSelected
lambda 函式。 - 在函式主體中呼叫
NavigationRail()
函式,其中包含modifier
物件做為參數。 - 對
menuItems
物件中的每個MenuItem
物件呼叫NavigationRailItem()
函式,與您對BottomNavigationBar()
函式中的NavigationBarItem
函式執行的動作相同。
實作固定式導覽匣
- 建立
NavigationDrawer()
可組合函式,其中包含以下三個參數:menuItems
物件 (設為List<MenuItem>
值)、modifier
物件 (設為Modifier
值),以及onMenuSelected
lambda 函式。 - 在函式主體中呼叫
Column()
函式,其中包含modifier
物件做為參數。 - 對
menuItems
物件中的每個MenuItem
物件呼叫Row()
函式。 - 在
Row()
函式主體新增icon
和text
標籤,與您對BottomNavigationBar()
函式中的NavigationBarItem
函式執行的動作相同。
依據寬度視窗大小類別選擇正確的導覽元件
- 如要擷取目前的寬度視窗大小類別,請在傳遞至
ComposeView
物件上setContent()
方法的可組合函式中,呼叫rememberWidthSizeClass()
函式。 - 建立條件分支版本,以根據擷取的寬度視窗大小類別選擇導覽元件,並呼叫選定的該元件。
- 將
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
類別的替代版面配置資源:
- 開啟
layout-sw600dp/activity_main.xml
檔案。 - 更新限制條件,讓
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
類別、視窗大小類別,以及可根據寬度視窗大小類別選擇的導覽元件。