1. 准备工作
Android 设备的形状和尺寸多种多样,因此您需要通过单个 Android 软件包 (APK) 或 Android App Bundle (AAB) 让应用布局适应不同的屏幕尺寸,从而覆盖不同的用户和设备。为此,您需要采用响应式自适应布局来定义您的应用,而不是使用只适合特定屏幕尺寸和宽高比的静态尺寸来定义应用。自适应布局会根据可用的屏幕空间自动调整。
在此 Codelab 中,您将学习有关如何构建自适应界面的基础知识,并对一款显示体育项目列表及每个体育项目的详细信息的应用进行调整,使其支持大屏设备。该体育应用包含三个屏幕:主屏幕、收藏屏幕和设置屏幕。主屏幕会显示一个体育项目列表;如果您选择列表中的某个体育项目,该屏幕还会显示相关新闻的占位符。收藏屏幕和设置屏幕也会显示占位文本。您可以在底部导航菜单中选择关联项,从而切换屏幕。
开始时,该应用在大屏设备上存在以下布局问题:
- 您无法在纵向模式下使用该应用。
- 该应用在大屏设备上显示时有很多空白区域。
- 该应用在大屏设备上始终显示底部导航菜单。
您要对该应用进行自适应设置,使其:
- 支持横向模式和纵向模式。
- 当横向空间充足时,并排显示体育项目列表和每个体育项目的相关新闻。
- 根据 Material Design 准则显示导航组件。
该应用是包含多个 fragment 的单 activity 应用。您将处理以下文件:
AndroidManifest.xml
文件,用于提供该体育应用的相关元数据。MainActivity.kt
文件,其中包含使用activity_main.xml
文件生成的代码、override
注解、表示宽度窗口类别大小的enum
类,以及用于检索宽度窗口大小类别的方法定义。创建 activity 时,底部导航栏会初始化。activity_main.xml
文件,用于定义Main
activity 的默认布局。layout-sw600dp/activity_main.xml
文件,用于定义Main
activity 的备用布局。当应用的窗口宽度大于或等于600dp
值时,备用布局生效。开始时,内容与默认布局相同。SportsListFragment.kt
文件,其中包含体育项目列表实现和自定义的返回导航方式。fragment_sports_list.xml
文件,用于定义体育项目列表的布局。navigation_menu.xml
文件,用于定义底部导航菜单项。
图 1. 该体育应用通过单个 APK 或 AAB 支持不同的窗口大小。
前提条件
- 掌握基于 View 的界面开发基础知识
- 有使用 Kotlin 语法(包括 lambda 函数)的经验
- 完成了 Jetpack Compose 基础知识 Codelab
学习内容
- 如何支持配置变更。
- 如何通过修改更少量的代码来添加备用布局。
- 如何实现针对不同窗口大小表现出不同行为的列表详细信息界面。
构建内容
支持以下各项的 Android 应用:
- 横向设备屏幕方向
- 平板电脑、桌面设备和移动设备
- 让列表详细信息针对不同的屏幕尺寸表现出不同的行为
所需条件
- Android Studio Bumblebee | 2021.1.1 或更高版本
- Android 平板电脑或模拟器
2. 进行设置
下载此 Codelab 的代码并设置项目:
- 通过命令行,从此 GitHub 代码库克隆相应代码:
$ git clone https://github.com/android/add-adaptive-layouts
- 在 Android Studio 中,打开
AddingAdaptiveLayout
项目。该项目有多个 git 分支:
main
分支包含此项目的起始代码。您将更改该分支以完成此 Codelab。end
分支包含此 Codelab 的解决方案。
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
可组合函数:一个设置为List<MenuItem>
值的menuItems
对象、一个设置为Modifier = Modifier
值的modifier
对象,以及一个设置为(MenuItem) -> Unit = {}
lambda 函数的onMenuSelected
对象。 - 在
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 将底部导航组件嵌入基于 View 的界面布局。
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 的底部导航菜单与基于 View 的界面布局集成
- 在
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. 支持横向模式
如果您的应用支持大屏设备,则应支持横向模式和纵向模式。目前,您的应用只有一个 activity:MainActivity
activity。
该 activity 在设备上的显示方向是在 AndroidManifest.xml
文件中通过 android:screenOrientation
属性设置的,此属性已设置为 portrait
值。
让应用支持横向模式:
- 将
android:screenOrientation
属性设置为fullUser
值。借助该配置,用户可以锁定屏幕方向。屏幕方向由使用 4 种方向中任一方向的设备方向传感器决定。
AndroidManifest.xml
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="fullUser">
图 2. 在您更新 AndroidManifest.xml
文件后,该应用会在横向模式下运行。
5. 窗口大小类别
这些是分割点,有助于根据您的应用可用的原始窗口大小,将窗口大小分类为预定义的大小类别(较小、中等和较大)。在设计、开发和测试自适应布局时,您应使用这些大小类别。
可用宽度和高度是单独划分的,因此您的应用始终都与两个窗口大小类别相关联:宽度窗口大小类别和高度窗口大小类别。
图 3. 宽度窗口大小类别及其关联的分割点。
图 4. 高度窗口大小类别及其关联的分割点。
窗口大小类别表示应用的当前窗口大小。换言之,您无法按实体设备大小来确定窗口大小类别。即使应用在同一设备上运行,关联的窗口大小类别也会因配置而异(例如,当您在分屏模式下运行应用时)。这有两个重大影响:
- 实体设备不能保证特定的窗口大小类别。
- 窗口大小类别在应用的整个生命周期内可能会发生变化。
向应用添加自适应布局后,您要在各种窗口大小下测试应用,尤其是在较小、中等和较大窗口大小类别下。必须对每个窗口大小类别进行测试,但在很多情况下,仅仅这样做还不够。请务必针对各种窗口大小测试您的应用,以便确保界面正确缩放。如需了解详情,请参阅大屏布局和大屏应用质量。
6. 在大屏设备上并排布局列表窗格和详细信息窗格
列表详细信息界面可能需要根据当前宽度窗口大小类别表现出不同的行为。如果中等或较大宽度窗口大小类别与您的应用相关联,则意味着您的应用拥有足够的空间,可以并排显示列表窗格和详细信息窗格,让用户无需转换屏幕即可查看项列表和所选项的详细信息。不过,在较小的屏幕上,并排布局可能会过于拥挤。在此类屏幕上,最好一次显示一个窗格,并首先显示列表窗格。当用户点按列表中的某一项时,详细信息窗格会显示所选项的详细信息。SlidingPaneLayout
类会管理逻辑,以确定这两种用户体验中的哪一种适合当前的窗口大小。
配置列表窗格布局
SlidingPaneLayout
类是一种基于 View 的界面组件。您将修改列表窗格的布局资源文件,即此 Codelab 中的 fragment_sports_list.xml
文件。
SlidingPaneLayout
类接受两个子元素。每个子元素的宽度和权重属性是 SlidingPaneLayout
类确定窗口大小是否足以并排显示两个视图的关键因素。如果窗口不够大,系统会将全屏列表替换为全屏详细信息界面。如果窗口大小大于并排显示两个窗格的最低要求,系统会引用权重值来按比例设置窗格大小。
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()
可组合函数:一个设置为List<MenuItem>
值的menuItems
对象、一个设置为Modifier
值的modifier
对象和一个onMenuSelected
lambda 函数。 - 在函数正文中,调用接受
modifier
对象作为参数的NavigationRail()
函数。 - 为
menuItems
对象中的每个MenuItem
对象分别调用NavigationRailItem()
函数(就像您对BottomNavigationBar()
函数中的NavigationBarItem
函数所做的那样)。
实现永久性抽屉式导航栏
- 创建一个接受三个形参的
NavigationDrawer()
可组合函数:一个设置为List<MenuItem>
值的menuItems
对象、一个设置为Modifier
值的modifier
对象和一个onMenuSelected
lambda 函数。 - 在函数正文中,调用接受
modifier
对象作为参数的Column()
函数。 - 为
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. 恭喜
恭喜!您已完成此 Codelab,并学习了如何使用 Compose 向基于 View 的 Android 应用添加自适应布局。在此过程中,您了解了 SlidingPaneLayout
类、窗口大小类别,以及如何根据宽度窗口大小类别选择导航组件。