使用 Jetpack Compose 打造可调整且易于访问的应用

1. 简介

在此 Codelab 中,您将学习如何针对手机、平板电脑和可折叠设备构建自适应应用,以及如何使用 Jetpack Compose 以无障碍功能为核心。您还将了解使用 Material 3 组件和主题的最佳做法。

在深入研究之前,务必要了解适应性和无障碍功能的含义。

适应性

应用的界面应能适应不同的屏幕尺寸、屏幕方向和设备类型。自适应布局会根据可用的屏幕空间发生变化。这些更改包括简单布局调整以填充空间、选择相应的导航样式,以及完全更改布局以利用更多空间等。

无障碍

所有人(包括有无障碍功能需求的人)都可以使用 Android 应用。应用应适应不同的场景,以从色彩对比度、可达性等方面提供最佳用户体验。

在此 Codelab 中,您将探索在使用 Jetpack Compose 时如何使用和考虑自适应性和无障碍功能。您将构建一个名为 REPLY 的应用,向您展示如何针对各种屏幕实现自适应。您将了解到自适应和无障碍功能如何共同为用户带来最佳体验。

学习内容

  • 如何将您的应用设计为使用 Jetpack Compose 定位所有屏幕尺寸。
  • 如何针对不同的可折叠设备定位您的应用。
  • 如何使用不同类型的导航来提高可达性和无障碍性。
  • 如何设计 Material 3 配色方案和动态主题,以提供最佳的无障碍体验。
  • 如何使用 Material 3 组件针对各种屏幕尺寸提供最佳体验。

所需条件

  • Android Studio Bumblebee
  • 了解 Kotlin。
  • 基本了解 Compose(例如 @Composable 注解)。
  • 基本熟悉 Compose 布局(例如, RowColumn)。
  • 基本熟悉修饰语(例如, Modifier.padding)。

如果您不熟悉 Compose,建议您在完成此 Codelab 之前学习 Jetpack Compose 基础知识 Codelab

构建内容

  • 互动式回复电子邮件客户端应用,采用 Material 3 的最佳做法、动态主题和适应性设计。

本 Codelab 将实现多设备支持

2. 进行设置

若要下载示例应用,您可以执行以下操作之一:

或者使用以下命令从命令行克隆 GitHub 代码库:

git clone https://github.com/googlecodelabs/android-compose-codelabs.git
cd android-compose-codelabs/ReplyAdaptabilityCodelab

您可以通过更改工具栏中的运行配置,随时在 Android Studio 中运行其中任一模块。

b059413b0cf9113a.png

在 Android Studio 中打开项目

  1. 在“Welcome to Android Studio”窗口中,选择 c01826594f360d94.png Open Existing Project
  2. 选择文件夹 [Download Location]/ReplyAdaptabilityCodelab(请务必选择包含 build.gradleReplyAdaptabilityCodlab 目录)。
  3. Android Studio 导入项目后,测试是否可以运行 startfinished 模块。

探索起始代码

起始代码包含以下四个软件包:

  • MainActivity - 用于启动 ReplyApp 的入口点 Activity。您将在此文件中进行更改。
  • ui - 包含主题、组件和启动撰写界面的 ReplyApp。您将在此软件包中进行更改。
  • util - 包含项目的帮助程序代码。您无需修改此软件包。

此 Codelab 重点介绍 reply 软件包中的文件。在 start 模块中,有几个文件需要熟悉一下。

您将编辑的文件 ui 文件包

  • MainActivity.kt - Android Activity,我们将在此处启动 ReplyApp 并传递折叠状态、大小和布局信息等必要信息。
  • ReplyApp.kt - 主应用界面结构位于您将处理的 ReplyApp.kt 文件中。
  • ReplyAppContent.kt - 此处提供了应用内容和列表详情的 Compose 实现。

我们首先来关注 MainActivity.kt 对于 start 模块,您应该已经有代码在 Activity 中运行。

MainActicity.kt

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)

   setContent {
       ReplyTheme {
           val uiState = viewModel.uiState.collectAsState().value
           ReplyApp(uiState)
       }
   }
}

如果您以任何尺寸运行此应用,都会看到相同的拉伸效果填满最大区域,而界面元素不会发生任何变化。 初始 ReplyApp 设置,无需进行任何更改。

让我们试着改进一下,以便充分利用屏幕空间和改善用户体验,同时仍然以无障碍功能为核心。

3.使应用具有自适应性

本部分将介绍使应用具有适应性的含义,以及 Material 3 为让我们能够更轻松地实现此目标而提供的组件。

我们还会介绍您将定位的屏幕和状态类型,包括手机、平板电脑、大型平板电脑和可折叠设备。

处理窗口大小

在转到“回复”应用之前,我们要先了解市面上有哪些类型的设备和设备可供用户使用。

我们的手机尺寸介于 4 英寸到 7 英寸之间。然后是平板电脑,从小到平板电脑,大到不如笔记本电脑。

我们首先根据 WIndowSizeClass 将这些不同的尺寸划分为 3 个类别。这些类别经过专门选择,可在保持布局简洁性的同时,灵活地针对独特情形优化您的应用。窗口大小类始终由应用可用的屏幕空间决定,它可能不是用于多任务处理或其他细分的整个物理屏幕。

基于 WindowSizeClass 的设备大小分布

WindowStateUtils**.kt**

enum class WindowSize { COMPACT, MEDIUM, EXPANDED }

fun getWindowSizeClass(windowDpSize: DpSize): WindowSize = when {
   windowDpSize.width < 0.dp -> throw IllegalArgumentException("Dp value cannot be negative")
   windowDpSize.width < 600.dp -> WindowSize.COMPACT
   windowDpSize.width < 840.dp -> WindowSize.MEDIUM
   else -> WindowSize.EXPANDED
}

WindowStateUtils.kt 提供了 rememberWindowSizeClass(),,这有助于我们获取 Compose 记住的状态,这样一来,每当大小发生变化时,界面树都会根据新大小再次渲染。

WindowStateUtils.kt

fun Activity.rememberWindowSizeClass(): WindowSize {
   // Get the size (in pixels) of the window
   val windowSize = rememberWindowSize()

   // Convert the window size to [Dp]
   val windowDpSize = with(LocalDensity.current) {
       windowSize.toDpSize()
   }

   // Calculate the window size class
   return getWindowSizeClass(windowDpSize)
}

如需开始支持自适应尺寸,您只需将 rememberWindowSizeClass() 添加到 Compose 界面的开头并将其传递给 ReplyApp 即可。您现在可以对 MainActivity.kt 进行更改,使其如下所示。

MainActivity.kt

setContent {
   ReplyTheme(dynamicColor = false, darkTheme = false) {
       val windowSize = rememberWindowSizeClass()
       ReplyApp(windowSize, uiState)
   }
}

进行这些更改后,您可以看到 ReplyApp 包含有关最新窗口大小的信息,以正确使用空格。

4.处理折叠状态

此外,您还需要确保应用响应折叠状态变化,而不仅仅是屏幕尺寸。首屏状态可能会有很多,但您不妨从一些这种情况着手。它们已在 util 类中定义。

WindowStateUtils.kt

/**
* Information about the posture of the device
*/
sealed interface DevicePosture {
   object NormalPosture : DevicePosture

   data class TableTopPosture(
       val hingePosition: Rect
   ) : DevicePosture

   data class BookPosture(
       val hingePosition: Rect
   ) : DevicePosture
}

您希望确保界面在从折叠位置展开时能够展开。您还需要考虑具有合页位置的 BookPosture 和 TableTopPosture,因为您不希望在合页呈现文本或其他有用信息。

让我们检查一下 activity 生命周期中的折叠状态。在调用 setContent() 之前,请在 Activity 的 onCreate() 方法中添加此代码。

override fun onCreate(savedInstanceState: Bundle?) {
   super.onCreate(savedInstanceState)

    /* Flow of [DevicePosture] that emits every time there is a change in the windowLayoutInfo
    */
   val devicePostureFlow =  WindowInfoTracker.getOrCreate(this).windowLayoutInfo(this)
       .flowWithLifecycle(this.lifecycle)
       .map { layoutInfo ->
           val foldingFeature =
               layoutInfo.displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull()
           when {
               isTableTopPosture(foldingFeature) ->
                   DevicePosture.TableTopPosture(foldingFeature.bounds)
               isBookPosture(foldingFeature) ->
                   DevicePosture.BookPosture(foldingFeature.bounds)
               isSeparating(foldingFeature) ->
                   DevicePosture.Separating(foldingFeature.bounds, foldingFeature.orientation)
               else -> DevicePosture.NormalPosture
           }
       }
       .stateIn(
           scope = lifecycleScope,
           started = SharingStarted.Eagerly,
           initialValue = DevicePosture.NormalPosture
       )

现在,您可以仅观察设备折叠状态作为 Compose 状态,这有助于界面响应折叠状态变化。将这些更改添加到 setContent()

MainActivity.kt

setContent {
   ReplyTheme(dynamicColor = false, darkTheme = false) {
       val devicePosture = devicePostureFlow.collectAsState().value
       ReplyApp(windowSize, devicePosture, uiState)
   }
}

Compose 界面现在已准备好响应设备大小和折叠状态更改。您可以从此处继续,针对不同的状态设计界面。每当折叠状态发生变化时,我们希望界面做出这样的反应。

可折叠设备界面调整

5. 动态导航

在上一部分中,您已使界面对大小、配置和折叠状态变化做出反应。现在,您需要了解如何针对不同状态的设备调整用户互动。

我们先从导航开始,因为它是用户首先互动的对象。请注意,用户以不同方式持有不同类型的设备。我们来看一些 Material 导航组件

底部导航栏

底部导航栏非常适合尺寸较小的设备,因为我们自然而然地将设备放在手腕上可轻松触及底部所有导航接触点的位置。当设备尺寸较小或可折叠设备处于可折叠状态时,您可以使用此尺寸。

对于中等尺寸的设备或大多数处于横屏模式的手机,导航栏非常适合用来轻松导航和覆盖,因为我们的拇指自然就落在设备左上角。您还可以结合使用抽屉式导航栏和导航栏来显示更多信息。

抽屉式导航栏提供了一种用于查看导航标签详细信息的简单方式,而且在您使用平板电脑或更大设备时可以轻松访问。您可以将抽屉式导航栏与导航栏、底部导航栏结合使用,并将永久抽屉式导航栏用于非常宽的设备的固定导航。

现在,随着设备状态和大小的变化,在不同类型的导航之间切换,同时以用户互动和无障碍功能为核心。

我们来为该应用添加动态导航。打开 ReplyApp.kt 并将其添加到 ReplyApp 可组合项中,

ReplyApp.kt

/**
* This will help us select type of navigation depending on window size and
* fold state of the device.
*/
val navigationType: ReplyNavigationType

when (windowSize) {
   WindowSize.COMPACT -> {
       navigationType = ReplyNavigationType.BOTTOM_NAVIGATION
   }
   WindowSize.MEDIUM -> {
       navigationType = ReplyNavigationType.NAVIGATION_RAIL
   }
   WindowSize.EXPANDED -> {
       navigationType = if (foldingDevicePosture is DevicePosture.BookPosture) {
           ReplyNavigationType.NAVIGATION_RAIL
       } else {
           ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER
       }
   }
}

由于抽屉式导航栏发挥着 ReplyAppContent 的容器界面的作用,,请使用一个永久或模态抽屉式导航栏来封装它,具体视我们的 navigationType 而定(如下所示) 、

ReplyApp.kt

if (navigationType == ReplyNavigationType.PERMANENT_NAVIGATION_DRAWER) {
   PermanentNavigationDrawer(drawerContent = {                    NavigationDrawerContent(selectedDestination) }) {
       ReplyAppContent(navigationType, contentType, replyHomeUIState)
   }
} else {
   ModalNavigationDrawer(
       drawerContent = {
           NavigationDrawerContent(
               selectedDestination,
               onDrawerClicked = {
                   scope.launch {
                       drawerState.close()
                   }
               }
           )
       },
       drawerState = drawerState
   ) {
       ReplyAppContent(navigationType, contentType, replyHomeUIState,
           onDrawerClicked = {
               scope.launch {
                   drawerState.open()
               }
           }
       )
   }
}

现在,您有了一个动态 NavigationType,可用于在任何配置更改时更改导航。我们来将 navigationType 添加到 ReplyAppContent() 中,以实现导航动态效果。

ReplyApp.kt

@Composable
fun ReplyAppContent(
   navigationType: ReplyNavigationType,
   contentType: ReplyContentType,
   replyHomeUIState: ReplyHomeUIState,
   onDrawerClicked: () -> Unit = {}
) {
   Row(modifier = Modifier.fillMaxSize()) {
       AnimatedVisibility(visible = navigationType == ReplyNavigationType.NAVIGATION_RAIL) {
           ReplyNavigationRail(
               onDrawerClicked = onDrawerClicked
           )
       }
       Column(modifier = Modifier
           .fillMaxSize()
           .background(MaterialTheme.colorScheme.inverseOnSurface)
       ) {
           // Reply List content

           AnimatedVisibility(visible = navigationType == ReplyNavigationType.BOTTOM_NAVIGATION) {
               ReplyBottomNavigationBar()
           }
       }
   }
}

再次运行应用以试用动态导航

再次运行应用时,您会发现,每当屏幕配置发生变化或展开折叠设备时,导航都会更改为该大小的相应类型。

显示针对不同尺寸的设备的自适应调整。

恭喜,您已学习了不同类型的导航以支持不同类型的屏幕尺寸和状态!

在下一部分中,您将了解如何利用剩余的屏幕区域,而不是将同一列表项边缘延伸到边缘。

6.屏幕空间使用情况

应用会显示拉伸以填满剩余空间,无论是小平板电脑、展开设备还是大平板电脑。您希望利用屏幕空间向用户显示更多信息。

navigationType 一样,您需要创建 contentType,以便帮助我们确定列表内容还是动态显示列表和详细信息内容屏幕状态更改、

ReplyApp.kt

val contentType: ReplyContentType
when (windowSize) {
   WindowSize.COMPACT -> {
       contentType = ReplyContentType.LIST_ONLY
   }
   WindowSize.MEDIUM -> {
       contentType = if (foldingDevicePosture != DevicePosture.NormalPosture) {
           ReplyContentType.LIST_AND_DETAIL
       } else {
           ReplyContentType.LIST_ONLY
       }
   }
   WindowSize.EXPANDED -> {
       contentType = ReplyContentType.LIST_AND_DETAIL
   }
}

您现在可以将此内容类型传递给 ReplyAppContent,,每当出现配置更改时,它会调整为适合您的布局。您还可以考虑折叠折叠状态和合页位置来决定列表和详情布局的位置,以免内容被置于合页位置。

ReplyApp.kt

@Composable
fun ReplyAppContent(
   navigationType: ReplyNavigationType,
   contentType: ReplyContentType,
   replyHomeUIState: ReplyHomeUIState,
   onDrawerClicked: () -> Unit = {}
) {
   Row(modifier = Modifier.fillMaxSize()) {
       Column(modifier = Modifier
           .fillMaxSize()
           .background(MaterialTheme.colorScheme.inverseOnSurface)
       ) {
           if (contentType == ReplyContentType.LIST_AND_DETAIL) {
               ReplyListAndDetailContent(
                   replyHomeUIState = replyHomeUIState,
                   modifier = Modifier.weight(1f),
               )
           } else {
               ReplyListOnlyContent(replyHomeUIState = replyHomeUIState, modifier = Modifier.weight(1f))
           }
       }
   }
}

添加所有更改后的 ReplyApp 的最终视图

再次运行应用以试用具有完全自适应能力的应用

再次运行应用,请注意,每当屏幕配置发生变化或我们展开折叠设备时,导航和屏幕内容都会动态变化以响应设备状态的变化。借助 Jetpack Compose,您可以非常轻松地以声明式模式编写这些类型的更改。

恭喜!您已成功将应用设置为适用于各种设备状态和尺寸。接下来,尝试在可折叠设备、平板电脑或其他移动设备上运行该应用。

在接下来的几个部分中,您将了解这些针对兼容性做出的更改如何帮助我们为无障碍设计铺设结构。

7. 增强无障碍功能

可达性

可达性是指无需使用极端的手部位置或切换手部位置即可导航或使用设备,以便启动与应用的交互。

在“回复”应用的“动态”导航部分,您添加了多种根据屏幕状态使用的导航模式。底部导航栏、导航栏和抽屉式导航栏等 Material 组件使我们能够根据不同类型设备存放的方式轻松访问导航。

可达性演示,显示适用于不同平板电脑尺寸的导航栏和抽屉式导航栏。

我们还添加了列表和详情外形规格,使用户能够轻松切换讨论帖,并且在大屏幕设备上可用左右手滚动浏览这些讨论帖,而无需更改展示位置。

色彩对比度

在 Android 12 及更高版本中,Reply 应用支持动态主题,其中的配色方案是通过壁纸选择和其他自定义设置生成的。使用动态配色的产品符合无障碍要求,因为最终用户能够体验到的算法组合就是符合这些标准的。

如需了解详情,请参阅动态配色

Material 3 浅色方案和深色模式配色方案。

对于此应用,我们还采用了 Material 3 配色方案,该配色方案旨在满足色彩对比度的无障碍功能标准。色调调色板的系统对于在默认情况下访问任何配色方案至关重要。

使用 Material 3 主题进行色彩对比度演示。

根据色调(而不是十六进制值或色调)组合颜色是确保任何颜色输出可访问的关键系统之一。您可以随时创建完整的 Material 3 配色方案,方法是选择一组正确的主色、辅色和三级颜色,并使用 Material 主题构建器针对浅色变体和深色变体创建 Material 3 配色方案。生成的变体已符合色彩对比度的无障碍功能标准。

在 Android 11 及更低版本中,动态主题不可用,我们会回退到使用 Material 主题构建器生成的固定 Material 3 配色方案。

您可以使用 Material 主题构建器试用新的颜色主题。

您可以直接将生成的颜色放入 ui/theme/Color.kt 文件中,看看实际效果如何。

8. 恭喜

恭喜您,您已成功完成了此 Codelab,并学习了如何使用 Jetpack Compose 设计可自适应且无障碍的应用。

您已了解如何检查设备的尺寸和折叠状态,以及如何相应地更新应用界面、导航和其他功能。您还学习了如何利用 Material 3 的配色方案和排版来改进用户体验和无障碍功能。

后续步骤

请查看 Compose 开发者在线课程中的其他 Codelab。

示例应用

  • 示例应用是许多应用(其包含 Codelab 中介绍的最佳做法)的集合。

参考文档