更改焦点行为

有时需要覆盖屏幕上元素的默认焦点行为。例如,您可能希望对可组合项进行分组阻止某个可组合项获得焦点,明确地请求某个可组合项获得焦点捕获或释放焦点,或在进入或退出时重定向焦点。本节介绍了当默认行为不符合您的需求时如何更改焦点行为。

通过焦点组提供一致的导航

有时,Jetpack Compose 不会立即猜测制表符导航的正确下一个项目,尤其是在复杂的父级 Composables(如制表符和列表)参与时。

虽然焦点搜索通常遵循 Composables 的声明顺序,但在某些情况下这不可能,例如,当层次结构中的某个 Composables 是一个不可完全显示的水平可滚动项时。以下示例展示了这种情况。

Jetpack Compose 可能会决定将焦点集中在屏幕开头最接近的项目上(如下所示),而不是继续沿着您期望的单向导航路径进行。

Animation of an app showing a top horizontal navigation and a list of items below.
图 1. 一个应用程序的动画,显示了顶部的水平导航和下面的项目列表

在这个示例中,很明显,开发人员不希望焦点从 巧克力 制表符跳到下面的第一个图像,然后又跳回 糕点 制表符。相反,他们希望焦点一直停留在制表符上,直到最后一个制表符,然后才将焦点集中在内部内容上。

Animation of an app showing a top horizontal navigation and a list of items below.
图 2. 一个应用程序的动画,显示了顶部的水平导航和下面的项目列表

在需要按顺序获得焦点的可组合项组(如上例中的制表符行)的情况下,需要将 Composable 包裹在一个具有 focusGroup() 修饰符的父级中。

LazyVerticalGrid(columns = GridCells.Fixed(4)) {
    item(span = { GridItemSpan(maxLineSpan) }) {
        Row(modifier = Modifier.focusGroup()) {
            FilterChipA()
            FilterChipB()
            FilterChipC()
        }
    }
    items(chocolates) {
        SweetsCard(sweets = it)
    }
}

双向导航会查找给定方向上最接近的可组合项——如果来自另一个组的元素比当前组中不可完全显示的项目更近,导航会选择最接近的元素。为了避免这种行为,可以应用 focusGroup() 修饰符。

FocusGroup 使整个组在焦点方面看起来像一个实体,但该组本身不会获得焦点——相反,最接近的子项将获得焦点。这样,导航就知道要在离开组之前转到不可完全显示的项目。

在这种情况下,三个 FilterChip 实例将在 SweetsCard 项目之前获得焦点,即使 SweetsCards 对用户来说是完全可见的,并且某些 FilterChip 可能被隐藏。这是因为 focusGroup 修饰符告诉焦点管理器调整项目获得焦点的顺序,以便导航更容易,与 UI 的一致性更高。

如果没有 focusGroup 修饰符,如果 FilterChipC 不可見,焦点导航将最后选中它。但是,添加这样的修饰符不仅可以使其可发现,而且还将在 FilterChipB 之后立即获得焦点,正如用户所期望的那样。

使可组合项可获得焦点

某些可组合项在设计上是可获得焦点的,例如 Button 或附加了 clickable 修饰符的可组合项。如果您希望专门为可组合项添加可获得焦点的行为,可以使用 focusable 修饰符。

var color by remember { mutableStateOf(Green) }
Box(
    Modifier
        .background(color)
        .onFocusChanged { color = if (it.isFocused) Blue else Green }
        .focusable()
) {
    Text("Focusable 1")
}

使可组合项不可获得焦点

可能存在某些元素不应参与焦点的这种情况。在这些极少数情况下,您可以利用 canFocus 属性Composable 排除在可获得焦点之外。

var checked by remember { mutableStateOf(false) }

Switch(
    checked = checked,
    onCheckedChange = { checked = it },
    // Prevent component from being focused
    modifier = Modifier
        .focusProperties { canFocus = false }
)

使用 FocusRequester 请求键盘焦点

在某些情况下,您可能希望作为对用户交互的响应明确地请求焦点。例如,您可能会询问用户是否要重新开始填写表单,如果用户按下“是”,您希望重新将焦点放在该表单的第一个字段上。

首先,需要将一个 FocusRequester 对象与您希望将键盘焦点移动到的可组合项相关联。在以下代码段中,一个 FocusRequester 对象通过设置名为 Modifier.focusRequester 的修饰符与 TextField 相关联。

val focusRequester = remember { FocusRequester() }
var text by remember { mutableStateOf("") }

TextField(
    value = text,
    onValueChange = { text = it },
    modifier = Modifier.focusRequester(focusRequester)
)

您可以调用 FocusRequester 的 requestFocus 方法 来发送实际的焦点请求。您应该在 Composable 上下文之外调用此方法(否则,它将在每次重新组合时重新执行)。以下代码段展示了如何在点击按钮时请求系统移动键盘焦点。

val focusRequester = remember { FocusRequester() }
var text by remember { mutableStateOf("") }

TextField(
    value = text,
    onValueChange = { text = it },
    modifier = Modifier.focusRequester(focusRequester)
)

Button(onClick = { focusRequester.requestFocus() }) {
    Text("Request focus on TextField")
}

捕获和释放焦点

您可以利用焦点来指导用户提供应用程序执行其任务所需的正确数据——例如,获取有效的电子邮件地址或电话号码。虽然错误状态会通知用户正在发生的事情,但您可能需要包含错误信息的字段保持焦点,直到问题得到解决。

为了捕获焦点,您可以调用 captureFocus() 方法,然后使用 freeFocus() 方法释放它,如以下示例所示。

val textField = FocusRequester()

TextField(
    value = text,
    onValueChange = {
        text = it

        if (it.length > 3) {
            textField.captureFocus()
        } else {
            textField.freeFocus()
        }
    },
    modifier = Modifier.focusRequester(textField)
)

焦点修饰符的优先级

Modifiers 可以被看作是只有一个子项的元素,因此当您将它们排队时,左侧(或顶部)的每个 Modifier 都将包装右侧(或底部)的 Modifier。这意味着第二个 Modifier 包含在第一个 Modifier 中,因此当声明两个 focusProperties 时,只有最顶层的那个有效,因为后面的都被包含在最顶层的那个中。

为了更清楚地说明这个概念,请查看以下代码。

Modifier
    .focusProperties { right = item1 }
    .focusProperties { right = item2 }
    .focusable()

在这种情况下,指示 item2 为正确焦点的 focusProperties 将不会使用,因为它包含在前面的那个中;因此,item1 将是使用的那一个。

利用这种方法,父级也可以使用 FocusRequester.Default 将行为重置为默认行为。

Modifier
    .focusProperties { right = Default }
    .focusProperties { right = item1 }
    .focusProperties { right = item2 }
    .focusable()

父级不必是同一个修饰符链的一部分。父级可组合项可以覆盖子级可组合项的焦点属性。例如,考虑以下使按钮不可获得焦点的 FancyButton

@Composable
fun FancyButton(modifier: Modifier = Modifier) {
    Row(modifier.focusProperties { canFocus = false }) {
        Text("Click me")
        Button(onClick = { }) { Text("OK") }
    }
}

用户可以通过将 canFocus 设置为 true 来使此按钮再次可获得焦点。

FancyButton(Modifier.focusProperties { canFocus = true })

像所有 Modifier 一样,与焦点相关的 Modifier 的行为会根据您声明它们的顺序而有所不同。例如,以下代码使 Box 可获得焦点,但 FocusRequester 与此可获得焦点无关,因为它是在可获得焦点之后声明的。

Box(
    Modifier
        .focusable()
        .focusRequester(Default)
        .onFocusChanged {}
)

重要的是要记住,focusRequester 与层次结构中第一个出现在它下面的可获得焦点元素相关联,因此此 focusRequester 指向第一个可获得焦点的子项。如果不可用,它将不指向任何东西。但是,由于 Box 是可获得焦点的(得益于 focusable() 修饰符),您可以使用双向导航导航到它。

作为另一个示例,以下任一方法都有效,因为 onFocusChanged() 修饰符引用第一个出现在 focusable()focusTarget() 修饰符之后的可获得焦点元素。

Box(
    Modifier
        .onFocusChanged {}
        .focusRequester(Default)
        .focusable()
)
Box(
    Modifier
        .focusRequester(Default)
        .onFocusChanged {}
        .focusable()
)

在进入或退出时重定向焦点

有时,您需要提供非常具体的导航,如以下动画所示。

Animation of a screen showing two columns of buttons placed side by side and animating focus from one column to the other.
图 3. 一个屏幕的动画,显示了并排放置的两列按钮,以及从一列到另一列的焦点动画

在我们深入研究如何创建它之前,重要的是要了解焦点搜索的默认行为。如果没有进行任何修改,一旦焦点搜索到达 Clickable 3 项目,按下方向键上的 DOWN(或等效的箭头键)会将焦点移动到 Column 下方显示的任何内容上,退出该组,并忽略右侧的组。如果没有可获得焦点的项目可用,焦点将不会移动到任何地方,而是停留在 Clickable 3 上。

为了改变这种行为并提供预期的导航,您可以利用 focusProperties 修饰符,它可以帮助您管理当焦点搜索进入或退出 Composable 时会发生什么。

val otherComposable = remember { FocusRequester() }

Modifier.focusProperties {
    exit = { focusDirection ->
        when (focusDirection) {
            Right -> Cancel
            Down -> otherComposable
            else -> Default
        }
    }
}

当焦点搜索进入或退出层次结构的特定部分时,可以将焦点指向特定的 Composable——例如,当您的 UI 有两列,并且您希望确保在处理第一列时,焦点切换到第二列。

Animation of a screen showing two columns of buttons placed side by side and animating focus from one column to the other.
图 4. 一个屏幕的动画,显示了并排放置的两列按钮,以及从一列到另一列的焦点动画

在此 gif 中,一旦焦点到达 Column 1 中的 Clickable 3 Composable,下一个获得焦点的项目是另一 Column 中的 Clickable 4。可以通过将 focusDirectionenterexit 值结合起来,在 focusProperties 修饰符中实现此行为。它们都需要一个 lambda,该 lambda 将焦点来自的方向作为参数,并返回一个 FocusRequester。这个 lambda 可以以三种不同的方式运行:返回 FocusRequester.Cancel 会阻止焦点继续,而 FocusRequester.Default 不会改变其行为。相反,提供附加到另一个 ComposableFocusRequester 会使焦点跳转到该特定 Composable

更改焦点前进方向

要将焦点推进到下一个项目或精确方向,您可以利用 onPreviewKey 修饰符,并使用 LocalFocusManagermoveFocus 修饰符来推进焦点。

以下示例展示了焦点机制的默认行为:当检测到 tab 键按下时,焦点将推进到焦点列表中的下一个元素。虽然这通常不需要您配置,但了解系统内部工作原理对于更改默认行为非常重要。

val focusManager = LocalFocusManager.current
var text by remember { mutableStateOf("") }

TextField(
    value = text,
    onValueChange = { text = it },
    modifier = Modifier.onPreviewKeyEvent {
        when {
            KeyEventType.KeyUp == it.type && Key.Tab == it.key -> {
                focusManager.moveFocus(FocusDirection.Next)
                true
            }

            else -> false
        }
    }
)

在此示例中,focusManager.moveFocus() 函数将焦点推进到指定的项目,或推进到函数参数中暗示的方向。