使用 Compose 向基于 View 的 Android 应用添加自适应布局

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 支持不同的窗口大小。

前提条件

学习内容

  • 如何支持配置变更。
  • 如何通过修改更少量的代码来添加备用布局。
  • 如何实现针对不同窗口大小表现出不同行为的列表详细信息界面。

构建内容

支持以下各项的 Android 应用:

  • 横向设备屏幕方向
  • 平板电脑、桌面设备和移动设备
  • 让列表详细信息针对不同的屏幕尺寸表现出不同的行为

所需条件

2. 进行设置

下载此 Codelab 的代码并设置项目:

  1. 通过命令行,从此 GitHub 代码库克隆相应代码:
$ git clone https://github.com/android/add-adaptive-layouts
  1. 在 Android Studio 中,打开 AddingAdaptiveLayout 项目。该项目有多个 git 分支:
  • main 分支包含此项目的起始代码。您将更改该分支以完成此 Codelab。
  • end 分支包含此 Codelab 的解决方案。

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 可组合函数:一个设置为 List<MenuItem> 值的 menuItems 对象、一个设置为 Modifier = Modifier 值的 modifier 对象,以及一个设置为 (MenuItem) -> Unit = {} lambda 函数的 onMenuSelected 对象。
  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 将底部导航组件嵌入基于 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 的界面布局集成

  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. 支持横向模式

如果您的应用支持大屏设备,则应支持横向模式和纵向模式。目前,您的应用只有一个 activity:MainActivity activity。

该 activity 在设备上的显示方向是在 AndroidManifest.xml 文件中通过 android:screenOrientation 属性设置的,此属性已设置为 portrait 值。

让应用支持横向模式:

  1. android:screenOrientation 属性设置为 fullUser 值。借助该配置,用户可以锁定屏幕方向。屏幕方向由使用 4 种方向中任一方向的设备方向传感器决定。

AndroidManifest.xml

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

该体育应用通过将 fullUser 值设置为 AndroidManifest.xml 文件中的 activity 元素的 android:screenOrientation 属性来支持横屏模式。

图 2. 在您更新 AndroidManifest.xml 文件后,该应用会在横向模式下运行。

5. 窗口大小类别

这些是分割点,有助于根据您的应用可用的原始窗口大小,将窗口大小分类为预定义的大小类别(较小、中等和较大)。在设计、开发和测试自适应布局时,您应使用这些大小类别。

可用宽度和高度是单独划分的,因此您的应用始终都与两个窗口大小类别相关联:宽度窗口大小类别和高度窗口大小类别。

三个宽度窗口大小类别之间有两个分割点。600dp 值是较小和中等宽度窗口大小类别之间的分割点,840dp 值是中等和较大宽度窗口大小类别之间的分割点。

图 3. 宽度窗口大小类别及其关联的分割点。

三个高度窗口大小类别之间有两个分割点。480dp 值是较小和中等高度窗口大小类别之间的分割点,900dp 值是中等和较大高度窗口大小类别之间的分割点。

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

更新 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() 可组合函数:一个设置为 List<MenuItem> 值的 menuItems 对象、一个设置为 Modifier 值的 modifier 对象和一个 onMenuSelected lambda 函数。
  2. 在函数正文中,调用接受 modifier 对象作为参数的 NavigationRail() 函数。
  3. menuItems 对象中的每个 MenuItem 对象分别调用 NavigationRailItem() 函数(就像您对 BottomNavigationBar() 函数中的 NavigationBarItem 函数所做的那样)。

实现永久性抽屉式导航栏

  1. 创建一个接受三个形参的 NavigationDrawer() 可组合函数:一个设置为 List<MenuItem> 值的 menuItems 对象、一个设置为 Modifier 值的 modifier 对象和一个 onMenuSelected lambda 函数。
  2. 在函数正文中,调用接受 modifier 对象作为参数的 Column() 函数。
  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. 恭喜

恭喜!您已完成此 Codelab,并学习了如何使用 Compose 向基于 View 的 Android 应用添加自适应布局。在此过程中,您了解了 SlidingPaneLayout 类、窗口大小类别,以及如何根据宽度窗口大小类别选择导航组件。

了解更多内容