1. 简介
用户可以在平板电脑、ChromeOS 设备等大屏设备以及扩展现实设备上,通过硬件键盘与您的应用进行互动。用户使用硬件键盘在应用中导航时能达到和使用触摸屏时同样的效果,这一点非常重要。此外,您的应用面向电视和车载显示屏设计,由于这些设备可能不支持触控输入,而是依靠方向键或旋转编码器进行操作,因此您需要采用类似的键盘导航原则。
借助 Compose,您可以统一处理来自硬件键盘、方向键和旋转编码器的输入。对于这些输入方式而言,良好用户体验的关键原则在于,用户能够直观且一致地将键盘焦点移至他们希望互动的互动式组件上。
在此 Codelab 中,您将学习以下内容:
- 如何实现常见的键盘焦点管理模式,从而实现直观且一致的导航体验
- 如何测试键盘焦点移动行为是否符合预期
前提条件
- 拥有使用 Compose 构建应用的经验
- 具备 Kotlin 方面的基础知识,包括 lambda 和协程
构建内容
您将实现以下典型的键盘焦点管理模式:
- 键盘焦点移动 - 键盘焦点以 Z 形模式,从起始位置开始,自上而下、从左到右移动
- 逻辑初始焦点 - 将焦点设置为用户可能与之互动的界面元素
- 焦点恢复功能 - 将焦点移至用户之前互动过的界面元素
学习内容
- Compose 中的焦点管理基础知识
- 如何将界面元素设为焦点目标
- 如何请求焦点以移动界面元素
- 如何将键盘焦点移至一组界面元素中的特定界面元素
所需条件
- Android Studio Ladybug 或更高版本
- 以下任意设备(用于运行示例应用):
- 配备硬件键盘的大屏设备
- 适用于大屏设备的 Android 虚拟设备,例如可调整大小的模拟器
2. 设置
- 克隆 large-screen-codelabs GitHub 代码库:
git clone https://github.com/android/large-screen-codelabs
或者,您也可以下载并解压 large-screen-codelabs zip 文件:
- 导航到
focus-management-in-compose
文件夹。 - 在 Android Studio 中,打开相应项目。
focus-management-in-compose
文件夹中包含一个项目。 - 如果您没有 Android 平板电脑、可折叠设备或配备硬件键盘的 ChromeOS 设备,请在 Android Studio 中打开设备管理器,然后在手机类别中创建可调整大小的设备。
图 1. 在 Android Studio 中配置可调整大小的模拟器。
3. 查看起始代码
该项目包含两个模块:
- start - 包含项目的起始代码。您将更改该代码才能完成此 Codelab。
- solution — 包含此 Codelab 中已完成的代码。
示例应用包含以下三个标签页:
- 焦点目标
- 焦点遍历顺序
- 焦点小组
应用启动时,系统会显示焦点目标标签页。
图 2. 应用启动时,系统会显示 Focus target(焦点目标)标签页。
ui
软件包中包含您将互动的以下界面代码:
App.kt
- 实现标签页tab.FocusTargetTab.kt
- 包含焦点目标标签页的代码tab.FocusTraversalOrderTab.kt
- 包含焦点遍历顺序标签页的代码tab.FocusGroup.kt
- 包含焦点小组标签页的代码FocusGroupTabTest.kt
- 针对tab.FocusTargetTab.kt
的插桩测试(文件位于androidTest
文件夹中)
4. 焦点目标
焦点目标是键盘焦点可以移动到的界面元素。用户可以按 Tab
键或方向(箭头)键移动键盘焦点:
Tab
键 - 焦点沿一维方向移动到下一个或上一个焦点目标。- 方向键 - 焦点可以在二维空间内向上、向下、向左和向右移动。
标签页属于焦点目标。在示例应用中,当标签页获得焦点时,标签页的背景会进行视觉更新。
图 3. 当焦点移至焦点目标时,组件背景会发生变化。
默认情况下,互动式界面元素是焦点目标
默认情况下,互动式组件是焦点目标。换言之,如果用户可以点按某个界面元素,则该界面元素就是焦点目标。
在示例应用的焦点目标标签页中,有三张卡片。1st card(第 1 张卡片)和3rd card(第 3 张卡片)是焦点目标;2nd card(第 2 张卡片)则不是。当用户按 Tab
键将焦点从第 1 张卡片移至第 3 张卡片时,第 3 张卡片的背景会更新。
图 4. 应用焦点目标不包括第 2 张卡片。
将“第 2 张卡片”设置为焦点目标
您可以将第 2 张卡片更改为互动式界面元素,从而将其设为焦点目标。最简单的方法是使用如下所示的 clickable
修饰符:
- 打开
tabs
软件包中的FocusTargetTab.kt
- 使用
clickable
修饰符修改SecondCard
可组合项,如下所示:
@Composable
fun FocusTargetTab(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier
) {
FirstCard(
onClick = onClick,
modifier = Modifier.width(240.dp)
)
SecondCard(
modifier = Modifier
.width(240.dp)
.clickable(onClick = onClick)
)
ThirdCard(
onClick = onClick,
modifier = Modifier.width(240.dp)
)
}
}
开始运行
现在,用户不仅能够将焦点移动到第 1 张卡片和第 3 张卡片,还可以将焦点移至第 2 张卡片。您可以在焦点目标标签页中进行尝试,确认您可以使用 Tab
键将焦点从第 1 张卡片顺利移至第 2 张卡片。
图 5. 按 Tab
键将焦点从第 1 张卡片移至第 2 张卡片。
5. 以 Z 形模式进行焦点遍历
在采用从左到右的语言设置中,用户希望键盘焦点按从左到右、从上到下的顺序移动。这种焦点遍历顺序称为“Z 形模式”。
不过,在 Compose 中,确定 Tab
键的下一个焦点目标时会忽略布局,而是根据可组合函数调用的顺序使用一维焦点遍历方式。
一维焦点遍历
一维焦点遍历顺序由可组合函数调用的顺序决定,而非应用布局。
在示例应用中,焦点在 Focus traversal order(焦点遍历顺序)标签页中按以下顺序移动:
- 第 1 张卡片
- 第 4 张卡片
- 第 3 张卡片
- 第 2 张卡片
图 6. 焦点遍历遵循可组合函数的调用顺序。
FocusTraversalOrderTab
函数实现了示例应用的 Focus traversal(焦点遍历)标签页。该函数会按以下顺序依次调用卡片的可组合函数:FirstCard
、FourthCard
、ThirdCard
和 SecondCard
。
@Composable
fun FocusTraversalOrderTab(
modifier: Modifier = Modifier
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
FirstCard(
onClick = onClick,
modifier = Modifier.width(240.dp)
)
FourthCard(
onClick = onClick,
modifier = Modifier
.width(240.dp)
.offset(x = 256.dp)
)
ThirdCard(
onClick = onClick,
modifier = Modifier
.width(240.dp)
.offset(y = (-151).dp)
)
}
SecondCard(
modifier = Modifier.width(240.dp)
)
}
}
Z 形模式下的焦点移动
您可以按照以下步骤在示例应用的焦点遍历顺序标签页中集成 Z 形焦点移动方式:
- 打开
tabs.FocusTraversalOrderTab.kt
文件 - 从
ThirdCard
和FourthCard
可组合函数中移除偏移修饰符。 - 将标签页的布局从当前的两列一行更改为一列两行。
- 将
FirstCard
和SecondCard
可组合项移至第一行。 - 将
ThirdCard
和FourthCard
可组合项移至第二行。
修改后的代码如下所示:
@Composable
fun FocusTraversalOrderTab(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
FirstCard(
onClick = onClick,
modifier = Modifier.width(240.dp),
)
SecondCard(
onClick = onClick,
modifier = Modifier.width(240.dp)
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
ThirdCard(
onClick = onClick,
modifier = Modifier.width(240.dp)
)
FourthCard(
onClick = onClick,
modifier = Modifier.width(240.dp)
)
}
}
}
开始运行
现在,用户可以按 Z 形模式,将焦点从右向左、从上到下移动。您可以在焦点遍历顺序标签页中进行尝试,确认是否可以使用 Tab
键,按以下顺序移动焦点:
- 第 1 张卡片
- 第 2 张卡片
- 第 3 张卡片
- 第 4 张卡片
图 7. 以 Z 形模式进行焦点遍历。
6. focusGroup
在 Focus group(焦点小组)标签页上按 right
方向键,将焦点从第 1 张卡片移至第 3 张卡片。由于这两张卡片并非并排显示,这种焦点移动可能会让用户感到有些困惑。
图 8. 焦点从第 1 张卡片意外移至第 3 张卡片。
二维焦点遍历与布局信息相关
按方向键会触发二维焦点遍历。在电视上,这是常见的焦点遍历方式,用户可以使用方向键与应用互动。同样,按键盘箭头键也会触发二维焦点遍历,因为它们模拟了方向键的导航功能。
在二维焦点遍历中,系统会参考界面元素的几何信息,从而确定下一个焦点目标。例如,在焦点目标标签页中,按 down
方向键,焦点会移至第 1 张卡片,而按向上方向键,焦点会移至焦点目标标签页。
图 9. 使用向下和向上方向键进行焦点遍历。
与使用 Tab
键进行一维焦点遍历不同,二维焦点遍历不会循环。例如,当第 2 张卡片获得焦点时,用户按向下键焦点不会移动。
图 10. 当第 2 张卡片处于焦点状态时,按向下方向键无法移动焦点。
焦点目标位于同一级别
以下代码实现了上述界面布局。界面中有四种焦点目标:FirstCard
、SecondCard
、ThirdCard
和 FourthCard
。这四个焦点目标处于同一级别,ThirdCard
是布局中 FirstCard
右侧的第一项。因此,按 right
方向键时,焦点将从第 1 张卡片移至第 3 张卡片。
@Composable
fun FocusGroupTab(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier,
) {
FirstCard(
onClick = onClick,
modifier = Modifier.width(208.dp)
)
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
SecondCard(
onClick = onClick,
modifier = Modifier.width(208.dp)
)
ThirdCard(
onClick = onClick,
modifier = Modifier.width(208.dp)
)
FourthCard(
onClick = onClick,
modifier = Modifier.width(208.dp)
)
}
}
}
使用 focusGroup 修饰符对焦点目标进行分组
您可以按照以下步骤,更改这种可能引起困惑的焦点移动方式:
- 打开
tabs.FocusGroup.kt
文件 - 使用
focusGroup
修饰符,修改FocusGroupTab
可组合函数中的Column
可组合函数。
更新后的代码如下所示:
@Composable
fun FocusGroupTab(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier,
) {
FirstCard(
onClick = onClick,
modifier = Modifier.width(208.dp)
)
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.focusGroup(),
) {
SecondCard(
onClick = onClick,
modifier = Modifier.width(208.dp)
)
ThirdCard(
onClick = onClick,
modifier = Modifier.width(208.dp)
)
FourthCard(
onClick = onClick,
modifier = Modifier.width(208.dp)
)
}
}
}
focusGroup
修饰符会创建一个焦点小组,其中包含修改后的组件内的焦点目标。焦点小组内的焦点目标和焦点小组外的焦点目标位于不同级别,并且 FirstCard
可组合项的右侧没有其他焦点目标。因此,按 right
方向键时,焦点不会从第 1 张卡片移至任何其他卡片。
开始运行
现在,在示例应用的焦点小组标签页中,按 right
方向键,焦点不会从第 1 张卡片移至第 3 张卡片。
7. 请求焦点
用户无法使用键盘或方向键直接选择任意界面元素进行互动。用户需要先将键盘焦点移至某个互动式组件,然后才能与该元素互动。
例如,用户需要将焦点从焦点目标标签页移至第 1 张卡片,然后才能与卡片互动。通过合理设置初始焦点,可以减少用户启动主要任务所需的操作次数。
图 11. 按三次 Tab
键可将焦点移至第 1 张卡片。
使用 FocusRequester 请求焦点
您可以使用 FocusRequester
请求焦点,从而移动界面元素。在调用 requestFocus()
方法之前,应将 FocusRequester
对象与界面元素相关联。
将初始焦点设置为第 1 张卡片
您可以按照以下步骤将初始焦点设置为第 1 张卡片:
- 打开
tabs.FocusTarget.kt
文件 - 在
FocusTargetTab
可组合函数中声明firstCard
值,并使用remember
函数返回的FocusRequester
对象初始化该值。 - 使用
focusRequester
修饰符修改FirstCard
可组合函数。 - 将
firstCard
值指定为focusRequester
修饰符的参数。 - 使用
Unit
值调用LaunchedEffect
可组合函数,并在传递给LaunchedEffect
可组合函数的 lambda 中,对firstCard
值调用 requestFocus() 方法。
在第二步和第三步中,创建了一个 FocusRequester
对象并将其与界面元素相关联。在第五步中,在首次组合 FocusdTargetTab
可组合项时,系统会请求将焦点移至关联的界面元素。
更新后的代码如下所示:
@Composable
fun FocusTargetTab(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val firstCard = remember { FocusRequester() }
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier
) {
FirstCard(
onClick = onClick,
modifier = Modifier
.width(240.dp)
.focusRequester(focusRequester = firstCard)
)
SecondCard(
modifier = Modifier
.width(240.dp)
.clickable(onClick = onClick)
)
ThirdCard(
onClick = onClick,
modifier = Modifier.width(240.dp)
)
}
LaunchedEffect(Unit) {
firstCard.requestFocus()
}
}
开始运行
现在,选择焦点目标标签页时,键盘焦点会移至该标签页中的第 1 张卡片。您可以通过切换标签页来尝试一下。此外,应用启动时,第 1 张卡片也会处于选中状态。
图 12. 选择焦点目标标签页后,焦点会移至第 1 张卡片。
8. 将焦点移至所选标签页
您可以在键盘焦点进入焦点小组时,指定具体的焦点目标。例如,当用户将焦点移至标签页行时,你可以将焦点移至选中的标签页。
您可以通过以下步骤实现此行为:
- 打开
App.kt
文件。 - 在
App
可组合函数中声明focusRequesters
值。 - 将
focusRequesters
值初始化为remember
函数的返回值,该返回值是由FocusRequester
对象组成的列表。返回的列表长度应等于Screens.entries
的长度。 - 使用
focusRequester
修饰符修改 Tab 可组合项,将focusRequester
值的每个FocusRequester
对象与Tab
可组合项相关联。 - 使用
focusProperties
修饰符和focusGroup
修饰符修改 PrimaryTabRow 可组合项。 - 将 lambda 传递给
focusProperties
修饰符,并将enter
属性与另一个 lambda 相关联。 - 从与
enter
属性关联的 lambda 中,返回 FocusRequester 列表中通过selectedTabIndex
索引定位的focusRequesters
对象。
修改后的代码如下所示:
@Composable
fun App(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
var selectedScreen by rememberSaveable { mutableStateOf(Screen.FocusTarget) }
val selectedTabIndex = Screen.entries.indexOf(selectedScreen)
val focusRequesters = remember {
List(Screen.entries.size) { FocusRequester() }
}
Column(modifier = modifier) {
PrimaryTabRow(
selectedTabIndex = selectedTabIndex,
modifier = Modifier
.focusProperties {
enter = {
focusRequesters[selectedTabIndex]
}
}
.focusGroup()
) {
Screen.entries.forEachIndexed { index, screen ->
Tab(
selected = screen == selectedScreen,
onClick = { selectedScreen = screen },
text = { Text(stringResource(screen.title)) },
modifier = Modifier.focusRequester(focusRequester = focusRequesters[index])
)
}
}
when (selectedScreen) {
Screen.FocusTarget -> {
FocusTargetTab(
onClick = context::onCardClicked,
modifier = Modifier.padding(32.dp),
)
}
Screen.FocusTraversalOrder -> {
FocusTraversalOrderTab(
onClick = context::onCardClicked,
modifier = Modifier.padding(32.dp)
)
}
Screen.FocusRestoration -> {
FocusGroupTab(
onClick = context::onCardClicked,
modifier = Modifier.padding(32.dp)
)
}
}
}
}
您可以使用 focusProperties
修饰符控制焦点移动。在传递给修饰符的 lambda 中,修改 FocusProperties。当用户将焦点置于修改后的界面元素上,并按 Tab
键或方向键时,系统会参考 FocusProperties 来选择焦点目标。
当您设置 enter
属性时,系统会对该属性关联的 lambda 进行求值,并将焦点移动到与求值后返回的 FocusRequester
对象相关联的界面元素上。
开始运行
现在,当用户将焦点移至标签页行时,键盘焦点会移至所选标签页。您可以按以下步骤进行尝试:
- 运行应用
- 选择焦点小组标签页
- 使用
down
方向键将焦点移至第 1 张卡片。 - 使用
up
方向键移动焦点。
图 13. 焦点移至所选标签页。
9. 焦点恢复
用户希望在任务被中断时能够轻松地继续执行任务。焦点恢复功能支持从中断状态恢复,它会将键盘焦点移至之前选择的界面元素。
焦点恢复的一个典型用例是视频串流应用的主屏幕。屏幕上有多个视频内容列表,例如某个类别下的电影列表,或某部电视剧的分集列表。用户会浏览这些列表,寻找感兴趣的内容。有时,用户会返回到之前浏览过的列表,并继续浏览其中的内容。借助焦点恢复功能,用户无需手动将键盘焦点移至列表中最后查看过的项目,即可直接继续浏览。
focusRestorer 修饰符会将焦点恢复到焦点小组
使用 focusRestorer
修饰符可保存并恢复对焦点小组的焦点状态。当焦点离开焦点小组时,焦点会存储对之前获得焦点的项目的引用。然后,当焦点重新进入焦点小组时,焦点会恢复到之前获得焦点的项目上。
将焦点恢复功能与“焦点小组”标签页集成
示例应用的焦点小组标签页中有一行,其中包含第 2 张卡片、第 3 张卡片和第 4 张卡片。
图 14. 包含第 2 张卡片、第 3 张卡片和第 4 张卡片的焦点小组。
您可以按照以下步骤在该行中集成焦点恢复功能:
- 打开
tab.FocusGroupTab.kt
文件 - 使用
focusRestorer
修饰符修改FocusGroupTab
可组合项中的Row
可组合项。应先调用该修饰符,然后在调用focusGroup
修饰符。
修改后的代码如下所示:
@Composable
fun FocusGroupTab(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier,
) {
FirstCard(
onClick = onClick,
modifier = Modifier.width(208.dp)
)
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.focusRestorer()
.focusGroup(),
) {
SecondCard(
onClick = onClick,
modifier = Modifier.width(208.dp)
)
ThirdCard(
onClick = onClick,
modifier = Modifier.width(208.dp)
)
FourthCard(
onClick = onClick,
modifier = Modifier.width(208.dp)
)
}
}
}
开始运行
现在,焦点小组标签页中的行会恢复焦点,您可以按以下步骤进行尝试:
- 选择焦点小组标签页
- 将焦点移至第 1 张卡片
- 按
Tab
键将焦点移至第 4 张卡片 - 按
up
方向键将焦点移至第 1 张卡片 - 按
Tab
键
当键盘焦点进入设置为行的焦点小组时,focusRestorer
修饰符会保存第 4 张卡片的引用,并在焦点进入该组时自动将焦点移至该卡片。
图 15. 在按下向上方向键后,再按 Tab
键,焦点会返回到第 4 张卡片。
10. 编写测试
您可以通过测试来测试已实现的键盘焦点管理功能。Compose 提供了一套 API,可用于测试界面元素是否获得焦点,以及对界面组件执行按键操作。如需了解详情,请参阅在 Jetpack Compose 中进行测试 Codelab。
测试“焦点目标”标签页
在上一节中,您修改了 FocusTargetTab
可组合函数,将第 2 张卡片设置为焦点目标。接下来,我们将编写一个测试用例,用于验证上一节中手动实现的功能。编写测试的步骤如下:
- 打开
FocusTargetTabTest.kt
文件。您将在以下步骤中修改testSecondCardIsFocusTarget
函数。 - 通过对第 1 张卡片的
SemanticsNodeInteraction
对象调用requestFocus
方法,请求将焦点移至第 1 张卡片。 - 使用
assertIsFocused()
方法确认第 1 张卡片已获得焦点。 - 在传递给
performKeyInput
方法的 lambda 中,使用Key.Tab
值调用pressKey
方法,来执行Tab
键按下操作。 - 对第 2 张卡片的
SemanticsNodeInteraction
对象调用assertIsFocused()
方法,测试键盘焦点是否会移至第 2 张卡片。
更新后的代码如下所示:
@OptIn(ExperimentalTestApi::class, ExperimentalComposeUiApi::class)
@Test
fun testSecondCardIsFocusTarget() {
composeTestRule.setContent {
LocalInputModeManager
.current
.requestInputMode(InputMode.Keyboard)
FocusTargetTab(onClick = {})
}
val context = InstrumentationRegistry.getInstrumentation().targetContext
// Ensure the 1st card is focused
composeTestRule
.onNodeWithText(context.getString(R.string.first_card))
.requestFocus()
.performKeyInput { pressKey(Key.Tab) }
// Test if focus moves to the 2nd card from the 1st card with Tab key
composeTestRule
.onNodeWithText(context.getString(R.string.second_card))
.assertIsFocused()
}
开始运行
您可以点击 FocusTargetTest
类声明左侧显示的三角形图标来运行测试。如需了解详情,请参阅在 Android Studio 中测试中的运行测试部分。
11. 恭喜
祝贺您!您已经掌握了键盘焦点管理的核心要素:
- 焦点目标
- 焦点遍历
您可以使用以下 Compose 修饰符来控制焦点遍历顺序:
focusGroup
修饰符focusProperties
修饰符
您结合硬件键盘交互、初始焦点以及焦点恢复功能,实现了典型的用户体验模式。这些模式是通过组合以下 API 实现的:
FocusRequester
类focusRequester
修饰符focusRestorer
修饰符LaunchedEffect
可组合函数
您可以使用插桩测试来测试已实现的用户体验。Compose 提供了多种方法来执行按键操作,并测试 SemanticsNode
是否获得了键盘焦点。