compose-focus-navigation

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Compose: focus navigation

Compose:焦点导航

Core principle

核心原则

Focus is stateful UI behavior. Make focus targets explicit, request focus after composition succeeds, and test navigation with the same input model users use: keyboard, D-pad, or remote keys.
焦点是有状态的UI行为。需明确焦点目标,在组合完成后请求焦点,并使用与用户相同的输入模型测试导航:键盘、方向键(D-pad)或遥控器按键。

When to use this skill

何时使用此技能

Use this when UI:
  • Runs on TV, desktop, ChromeOS, keyboard-first Android, or remote-control devices.
  • Uses
    FocusRequester
    ,
    focusRequester
    ,
    focusProperties
    ,
    onFocusChanged
    , or key handlers.
  • Needs initial focus, restored focus, directional navigation, or back/escape behavior.
  • Has a carousel, grid, lazy list, menu, dialog, or modal with focus traps.
  • Has tests asserting which item is focused.
在以下UI场景中使用:
  • 运行在电视、桌面端、ChromeOS、优先使用键盘的Android设备或遥控器控制设备上。
  • 使用了
    FocusRequester
    focusRequester
    focusProperties
    onFocusChanged
    或按键处理器。
  • 需要初始焦点、焦点恢复、定向导航或返回/退出行为。
  • 包含轮播图、网格、懒加载列表、菜单、对话框或带焦点陷阱的模态框。
  • 存在断言哪个项获得焦点的测试用例。

Build focus targets deliberately

刻意构建焦点目标

Start with components that already participate in focus, then add only the focus hooks the behavior needs:
NeedAdd
Normal button/text field/clickable focusNothing extra; use the focusable component
Programmatic initial/restored focus
FocusRequester
+
Modifier.focusRequester(...)
Visual or state reaction to focus changes
Modifier.onFocusChanged { ... }
Custom interactive surface that is not already focusable
Modifier.focusable()
plus role/semantics as appropriate
For example, request and observe focus only when both behaviors are needed:
kotlin
val requester = remember { FocusRequester() }

Button(
    onClick = onClick,
    modifier = Modifier
        .focusRequester(requester)
        .onFocusChanged { state -> isFocused = state.isFocused },
) {
    Text("Play")
}
Prefer focusable components (
Button
,
TextField
, clickable/selectable surfaces) over manually adding
focusable()
to passive layout. Add manual focus only when the element is truly interactive or participates in navigation.
从已支持焦点的组件开始,仅添加行为所需的焦点钩子:
需求需添加的内容
普通按钮/文本框/可点击组件的焦点无需额外操作;使用可聚焦组件
程序化初始/恢复焦点
FocusRequester
+
Modifier.focusRequester(...)
对焦点变化的视觉或状态响应
Modifier.onFocusChanged { ... }
本身不可聚焦的自定义交互界面
Modifier.focusable()
加上适当的角色/语义
例如,仅在同时需要两种行为时才请求并观察焦点:
kotlin
val requester = remember { FocusRequester() }

Button(
    onClick = onClick,
    modifier = Modifier
        .focusRequester(requester)
        .onFocusChanged { state -> isFocused = state.isFocused },
) {
    Text("Play")
}
优先使用可聚焦组件(
Button
TextField
、可点击/可选界面),而非手动给被动布局添加
focusable()
。仅当元素真正具有交互性或参与导航时,才手动添加焦点。

Request focus after composition

组合完成后请求焦点

Call focus requests from an effect, not from the composable body:
kotlin
val initialFocus = remember { FocusRequester() }

LaunchedEffect(initialFocus) {
    initialFocus.requestFocus()
}
If the target appears after loading, key the request to the condition:
kotlin
LaunchedEffect(items.isNotEmpty()) {
    if (items.isNotEmpty()) {
        firstItemRequester.requestFocus()
    }
}
For lazy content, request focus only after the item is actually composed. Keep requesters in stable item state keyed by item id, not by index alone if the list can reorder.
从effect中调用焦点请求,而非在可组合函数体内:
kotlin
val initialFocus = remember { FocusRequester() }

LaunchedEffect(initialFocus) {
    initialFocus.requestFocus()
}
如果目标在加载后才出现,将请求与条件关联:
kotlin
LaunchedEffect(items.isNotEmpty()) {
    if (items.isNotEmpty()) {
        firstItemRequester.requestFocus()
    }
}
对于懒加载内容,仅在项实际组合完成后请求焦点。将请求器存储在由项ID标记的稳定项状态中,若列表可重新排序,则不要仅通过索引存储。

Directional navigation

定向导航

Use
focusProperties
when default spatial search is wrong:
kotlin
Modifier.focusProperties {
    up = headerRequester
    down = firstRowRequester
    left = FocusRequester.Cancel
}
Use this sparingly. Too many hard-coded links create stale focus graphs when layouts change. Prefer natural focus order unless the design requires a specific jump or trap.
当默认空间搜索不符合需求时,使用
focusProperties
kotlin
Modifier.focusProperties {
    up = headerRequester
    down = firstRowRequester
    left = FocusRequester.Cancel
}
谨慎使用此方式。过多硬编码链接会在布局变化时导致焦点图失效。除非设计需要特定跳转或陷阱,否则优先使用自然焦点顺序。

Key events

按键事件

Use key handlers for behavior that is not normal click/focus traversal:
kotlin
Modifier.onPreviewKeyEvent { event ->
    if (event.type == KeyEventType.KeyUp && event.key == Key.Back) {
        onBack()
        true
    } else {
        false
    }
}
Return
true
only when consumed. Returning
true
too broadly breaks text entry, accessibility shortcuts, and parent navigation.
For rapid D-pad input, throttle at the boundary that owns the expensive behavior (for example row scrolling or paging), not globally across the whole screen.
对不属于正常点击/焦点遍历的行为使用按键处理器:
kotlin
Modifier.onPreviewKeyEvent { event ->
    if (event.type == KeyEventType.KeyUp && event.key == Key.Back) {
        onBack()
        true
    } else {
        false
    }
}
仅在处理完事件时返回
true
。过于宽泛地返回
true
会破坏文本输入、无障碍快捷方式和父级导航。
对于快速方向键输入,在拥有昂贵行为的边界处进行节流(例如行滚动或分页),而非在整个屏幕全局节流。

Focus restoration

焦点恢复

Preserve focus by semantic identity:
  • Track selected/focused item id, not just index.
  • Use stable
    key
    values in lazy lists and grids.
  • When content refreshes, re-request focus for the same id if it still exists.
  • If it no longer exists, choose a deterministic fallback: nearest neighbor, first item, or parent container.
通过语义标识保留焦点:
  • 跟踪选中/聚焦项的ID,而非仅索引。
  • 在懒加载列表和网格中使用稳定的
    key
    值。
  • 当内容刷新时,如果相同ID的项仍存在,则重新请求焦点。
  • 如果该项不存在,选择确定性的回退方案:最近相邻项、第一个项或父容器。

Common mistakes

常见错误

MistakeFix
Adding
focusRequester
and
onFocusChanged
to every button
Add them only when requesting or observing focus
requestFocus()
in the composable body
Move to
LaunchedEffect
Initial focus keyed to
Unit
while target appears later
Key to loaded/visible condition
Focus requesters stored by lazy list indexStore by stable item id
Everything gets custom
focusProperties
Let spatial search work; override only broken edges
Key handler returns
true
for all keys
Consume only handled keys
Tests click nodes in TV/D-pad UISend key input and assert focus
错误修复方案
给每个按钮都添加
focusRequester
onFocusChanged
仅在需要请求或观察焦点时添加
在可组合函数体内调用
requestFocus()
移至
LaunchedEffect
初始焦点与
Unit
关联,但目标稍后才出现
与加载/可见条件关联
焦点请求器按懒加载列表索引存储按稳定项ID存储
所有元素都使用自定义
focusProperties
让空间搜索发挥作用;仅覆盖有问题的情况
按键处理器对所有按键都返回
true
仅处理已处理的按键
在电视/方向键UI中测试点击节点发送按键输入并断言焦点

Testing

测试

Test focus through user input:
kotlin
composeTestRule.onNodeWithTag("screen").performKeyInput {
    pressKey(Key.DirectionDown)
}

composeTestRule.onNodeWithTag("play-button").assertIsFocused()
Prefer asserting focused semantics over visual styling. Use screenshot tests only for focus appearance, not for deterministic focus ownership.
Broader test-shape choices (plain UI vs integration, semantics-first):
compose-ui-testing-patterns
.
通过用户输入测试焦点:
kotlin
composeTestRule.onNodeWithTag("screen").performKeyInput {
    pressKey(Key.DirectionDown)
}

composeTestRule.onNodeWithTag("play-button").assertIsFocused()
优先断言聚焦语义而非视觉样式。仅将截图测试用于焦点外观,而非确定性焦点归属。
更广泛的测试形态选择(纯UI vs 集成测试、语义优先):
compose-ui-testing-patterns

Red flags during review

评审中的危险信号

  • "It focuses correctly when I tap it" for a keyboard/TV UI.
  • Initial focus works only with fixed data and fails after loading/refresh.
  • Focus state is inferred from selected data state when focus and selection are different concepts.
  • The focus graph is described in comments but not encoded or tested.
  • 针对键盘/电视UI说“我点击时焦点正常”。
  • 初始焦点仅在固定数据下有效,加载/刷新后失效。
  • 焦点状态从选中数据状态推断,而焦点与选中是不同概念。
  • 焦点图仅在注释中描述,未编码或测试。