改变焦点行为

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

使用焦点组提供连贯的导航

有时,Jetpack Compose 无法立即猜测选项卡式导航的正确下一个项目,尤其是在涉及选项卡和列表等复杂的父级 Composables 时。

虽然焦点搜索通常遵循 Composables 的声明顺序,但在某些情况下这是不可能的,例如当层次结构中的某个 Composable 是水平可滚动且未完全可见时。下面的示例对此进行了说明。

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. 应用动画,显示顶部水平导航和下方项目列表

在需要一组可组合项按顺序获取焦点的情况下,例如上一个示例中的 Tab 行,您需要将 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 修饰符会告诉焦点管理器调整项目的焦点顺序,以便导航更容易且与界面更连贯。

如果没有 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 propertyComposable 排除在可聚焦范围之外。

var checked by remember { mutableStateOf(false) }

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

使用 FocusRequester 请求键盘焦点

在某些情况下,您可能希望根据用户互动明确请求焦点。例如,您可能会询问用户是否要重新填写表单,如果他们按下“是”,您希望重新聚焦该表单的第一个字段。

首先要做的是将一个 FocusRequester 对象与您想要移动键盘焦点的可组合项关联起来。在以下代码片段中,通过设置名为 Modifier.focusRequester 的修饰符,将 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 包含在第一个中,因此在声明两个 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 一样,与焦点相关的修饰符会根据您声明它们的顺序而表现不同。例如,以下代码使 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,无论它何时进入或退出层次结构的某个部分——例如,当您的界面有两列时,您希望确保每当第一列处理完毕时,焦点就切换到第二列

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。通过将 focusDirectionfocusProperties 修饰符内部的 enterexit 值结合,可以实现此行为。它们都需要一个 lambda,该 lambda 以焦点来源方向作为参数并返回一个 FocusRequester。此 lambda 可以有三种不同的行为:返回 FocusRequester.Cancel 会阻止焦点继续;FocusRequester.Default 不会改变其行为。而提供附属于另一个 ComposableFocusRequester 会使焦点跳到该特定的 Composable

更改焦点前进方向

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

以下示例展示了焦点机制的默认行为:当检测到 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() 函数将焦点前进到指定的项目,或函数参数中指示的方向。