【Jetpack Compose娓娓道来】第36课:焦点管理与键盘输入——让遥控器、键盘和输入法都“找得到北”

摘要 :

触摸屏时代焦点是隐形的,但换成键盘、遥控器、D-pad 后,焦点就成了用户唯一的"手指"。本文从"遥控器按了没反应"的真实场景出发,讲清 Compose 焦点管理的三个核心概念(焦点、FocusState、FocusRequester),并逐一拆解焦点遍历(focusGroup、focusProperties、onEnter)、焦点恢复(focusRestorer 与自记稳定 ID)、键盘事件(onKeyEvent / onPreviewKeyEvent)、TV 与桌面端 D-pad 导航、输入法联动等关键问题,最后给出常见陷阱速查表与"三层管理"思维模型,帮你用几行 Modifier 把焦点真正"管"起来。

目录

前言

先说个生活里的小场景。

你窝在沙发上,拿起电视遥控器,想点开刚看中的那部电影。手指按下"下"键,屏幕上的高亮框却"嗖"地一下跳到了旁边的推荐位;再按一下,又不知道跑哪去了。你盯着屏幕找了半天,才把那个小小的框"追"回来。

这不是你的问题,是程序没把"焦点"管好。

在手机上,我们习惯了用手指直接点------点哪儿就是哪儿,几乎不用操心"当前选中的是哪个元素"。可一旦换成键盘、遥控器、方向键,甚至游戏手柄,情况就完全不一样了:用户看不见鼠标,也摸不到屏幕,唯一能告诉他"现在在哪"的,就是那个会移动的高亮框。 这个高亮框背后,就是 Compose 里的"焦点"。

所以这一课,我们不打算一上来就甩一堆 API。我们想陪你慢慢走一遍:

  • 先看看焦点没管好时,用户到底会"卡"在哪儿;
  • 再弄明白焦点、FocusState、FocusRequester 这几个词到底在说什么;
  • 然后一步步处理"焦点走错顺序""返回后焦点丢了""按键没反应"这些真实问题;
  • 最后给你一张速查表和一套思维模型,让你下次遇到类似情况时,心里有底。

不用怕概念多。你会发现,焦点这件事说穿了就一句话:它是键盘和遥控器用户的"手指",你把它管好了,用户就走得顺;管不好,用户就迷路。 而我们要做的,不过是给这根"手指"指对方向。

准备好了吗?我们从那个"按了没反应"的现场开始。

一、先看一个"遥控器按了没反应"的现场

第40课我们把自定义Layout讲透了,你现在可以造任何形状的布局。但布局造出来只是"摆好了",还有一个问题管不了:用户怎么"选中"这些元素?

用手机触摸屏时,手指点哪里就是哪里,焦点问题基本不存在。但一旦换成键盘、遥控器、D-pad(方向键)、甚至游戏手柄,情况就变了:

  • TV 应用里,用户按遥控器的"下"键,焦点从海报跳到按钮,但跳错了位置------跳到了旁边的推荐位,而不是下面的"播放"按钮。
  • 桌面应用里,用户按 Tab 键切换输入框,顺序乱了------先跳到邮箱,再跳到密码,然后跳回邮箱上面的"记住我"复选框。
  • 页面里有一个自定义的卡片列表 ,每张卡片用 Box + clickable 拼的。遥控器按方向键,焦点根本进不去 卡片区域,因为 Box 默认不可聚焦。
  • 用户从列表页进详情页,再返回列表页,焦点回到了第一个项,而不是他刚才浏览到的第 20 项。

这些问题的根源都是焦点(Focus) 。触摸屏时代,焦点是隐形的;键盘和遥控器时代,焦点是用户唯一的"手指"------焦点去哪,用户就看到哪、操作到哪。

这一课,我们把 Compose 的焦点管理与键盘输入彻底讲清楚。不是背 API,是搞清楚默认行为什么时候够用、什么时候会坑你、以及怎么用几行 Modifier 把焦点"管"起来。

下面是本课要解决的焦点问题全景图:
#mermaid-svg-UPGhshO2HTYD3KwK{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-UPGhshO2HTYD3KwK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-UPGhshO2HTYD3KwK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-UPGhshO2HTYD3KwK .error-icon{fill:#552222;}#mermaid-svg-UPGhshO2HTYD3KwK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-UPGhshO2HTYD3KwK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-UPGhshO2HTYD3KwK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-UPGhshO2HTYD3KwK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-UPGhshO2HTYD3KwK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-UPGhshO2HTYD3KwK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-UPGhshO2HTYD3KwK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-UPGhshO2HTYD3KwK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-UPGhshO2HTYD3KwK .marker.cross{stroke:#333333;}#mermaid-svg-UPGhshO2HTYD3KwK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-UPGhshO2HTYD3KwK p{margin:0;}#mermaid-svg-UPGhshO2HTYD3KwK .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-UPGhshO2HTYD3KwK .cluster-label text{fill:#333;}#mermaid-svg-UPGhshO2HTYD3KwK .cluster-label span{color:#333;}#mermaid-svg-UPGhshO2HTYD3KwK .cluster-label span p{background-color:transparent;}#mermaid-svg-UPGhshO2HTYD3KwK .label text,#mermaid-svg-UPGhshO2HTYD3KwK span{fill:#333;color:#333;}#mermaid-svg-UPGhshO2HTYD3KwK .node rect,#mermaid-svg-UPGhshO2HTYD3KwK .node circle,#mermaid-svg-UPGhshO2HTYD3KwK .node ellipse,#mermaid-svg-UPGhshO2HTYD3KwK .node polygon,#mermaid-svg-UPGhshO2HTYD3KwK .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-UPGhshO2HTYD3KwK .rough-node .label text,#mermaid-svg-UPGhshO2HTYD3KwK .node .label text,#mermaid-svg-UPGhshO2HTYD3KwK .image-shape .label,#mermaid-svg-UPGhshO2HTYD3KwK .icon-shape .label{text-anchor:middle;}#mermaid-svg-UPGhshO2HTYD3KwK .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-UPGhshO2HTYD3KwK .rough-node .label,#mermaid-svg-UPGhshO2HTYD3KwK .node .label,#mermaid-svg-UPGhshO2HTYD3KwK .image-shape .label,#mermaid-svg-UPGhshO2HTYD3KwK .icon-shape .label{text-align:center;}#mermaid-svg-UPGhshO2HTYD3KwK .node.clickable{cursor:pointer;}#mermaid-svg-UPGhshO2HTYD3KwK .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-UPGhshO2HTYD3KwK .arrowheadPath{fill:#333333;}#mermaid-svg-UPGhshO2HTYD3KwK .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-UPGhshO2HTYD3KwK .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-UPGhshO2HTYD3KwK .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UPGhshO2HTYD3KwK .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-UPGhshO2HTYD3KwK .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UPGhshO2HTYD3KwK .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-UPGhshO2HTYD3KwK .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-UPGhshO2HTYD3KwK .cluster text{fill:#333;}#mermaid-svg-UPGhshO2HTYD3KwK .cluster span{color:#333;}#mermaid-svg-UPGhshO2HTYD3KwK div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-UPGhshO2HTYD3KwK .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-UPGhshO2HTYD3KwK rect.text{fill:none;stroke-width:0;}#mermaid-svg-UPGhshO2HTYD3KwK .icon-shape,#mermaid-svg-UPGhshO2HTYD3KwK .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UPGhshO2HTYD3KwK .icon-shape p,#mermaid-svg-UPGhshO2HTYD3KwK .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-UPGhshO2HTYD3KwK .icon-shape .label rect,#mermaid-svg-UPGhshO2HTYD3KwK .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UPGhshO2HTYD3KwK .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-UPGhshO2HTYD3KwK .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-UPGhshO2HTYD3KwK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
用户输入设备
触摸屏?
手指直接点击

焦点问题基本不存在
键盘 / 遥控器 / D-pad / 手柄
焦点 = 用户唯一的'手指'
焦点去哪,用户就看到哪
焦点跳错位置 → 用户迷失
本课目标:用 Modifier 把焦点'管'起来

二、先搞清楚三个概念:焦点、FocusState、FocusRequester

2.1 什么是"焦点"

焦点是"当前接收键盘/遥控器事件的元素"。 同一时刻,一个窗口里通常只有一个元素拥有焦点。你按方向键、Tab、Enter,事件都发给这个焦点元素。

Compose 里不是所有元素天生可聚焦。Button、TextField、以及加了 Modifier.clickable 的元素默认可聚焦 ;普通的 Text、没有 clickable 的 Box 默认不可聚焦 。要让一个普通容器可聚焦,加 Modifier.focusable()。

kotlin 复制代码
Box(
    modifier = Modifier
        .focusable()
        .onFocusChanged { focusState ->
            // focusState.isFocused / isCaptured
        }
)

onFocusChanged 用来监听焦点变化,focusState.isFocused 表示"这个元素当前是否拥有焦点"。

2.2 FocusRequester:焦点的"遥控器"

FocusRequester 是一个"请求焦点的凭证"。你把它附加到某个元素上,然后在代码里调用 requestFocus(),焦点就会跳到那个元素。

三步走:

kotlin 复制代码
val focusRequester = remember { FocusRequester() }

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

Button(onClick = { focusRequester.requestFocus() }) {
    Text("把焦点放到输入框")
}

关键点 :requestFocus() 不要在组合期间直接调用 。要么放在事件回调里(比如按钮点击),要么放在 LaunchedEffect(Unit) 里做初始焦点。

kotlin 复制代码
LaunchedEffect(Unit) {
    focusRequester.requestFocus()
}

下面是焦点、FocusState、FocusRequester 三者关系的示意图:
#mermaid-svg-UbXLM1ljjE7dQFgk{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-UbXLM1ljjE7dQFgk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-UbXLM1ljjE7dQFgk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-UbXLM1ljjE7dQFgk .error-icon{fill:#552222;}#mermaid-svg-UbXLM1ljjE7dQFgk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-UbXLM1ljjE7dQFgk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-UbXLM1ljjE7dQFgk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-UbXLM1ljjE7dQFgk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-UbXLM1ljjE7dQFgk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-UbXLM1ljjE7dQFgk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-UbXLM1ljjE7dQFgk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-UbXLM1ljjE7dQFgk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-UbXLM1ljjE7dQFgk .marker.cross{stroke:#333333;}#mermaid-svg-UbXLM1ljjE7dQFgk svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-UbXLM1ljjE7dQFgk p{margin:0;}#mermaid-svg-UbXLM1ljjE7dQFgk .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-UbXLM1ljjE7dQFgk .cluster-label text{fill:#333;}#mermaid-svg-UbXLM1ljjE7dQFgk .cluster-label span{color:#333;}#mermaid-svg-UbXLM1ljjE7dQFgk .cluster-label span p{background-color:transparent;}#mermaid-svg-UbXLM1ljjE7dQFgk .label text,#mermaid-svg-UbXLM1ljjE7dQFgk span{fill:#333;color:#333;}#mermaid-svg-UbXLM1ljjE7dQFgk .node rect,#mermaid-svg-UbXLM1ljjE7dQFgk .node circle,#mermaid-svg-UbXLM1ljjE7dQFgk .node ellipse,#mermaid-svg-UbXLM1ljjE7dQFgk .node polygon,#mermaid-svg-UbXLM1ljjE7dQFgk .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-UbXLM1ljjE7dQFgk .rough-node .label text,#mermaid-svg-UbXLM1ljjE7dQFgk .node .label text,#mermaid-svg-UbXLM1ljjE7dQFgk .image-shape .label,#mermaid-svg-UbXLM1ljjE7dQFgk .icon-shape .label{text-anchor:middle;}#mermaid-svg-UbXLM1ljjE7dQFgk .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-UbXLM1ljjE7dQFgk .rough-node .label,#mermaid-svg-UbXLM1ljjE7dQFgk .node .label,#mermaid-svg-UbXLM1ljjE7dQFgk .image-shape .label,#mermaid-svg-UbXLM1ljjE7dQFgk .icon-shape .label{text-align:center;}#mermaid-svg-UbXLM1ljjE7dQFgk .node.clickable{cursor:pointer;}#mermaid-svg-UbXLM1ljjE7dQFgk .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-UbXLM1ljjE7dQFgk .arrowheadPath{fill:#333333;}#mermaid-svg-UbXLM1ljjE7dQFgk .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-UbXLM1ljjE7dQFgk .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-UbXLM1ljjE7dQFgk .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UbXLM1ljjE7dQFgk .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-UbXLM1ljjE7dQFgk .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UbXLM1ljjE7dQFgk .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-UbXLM1ljjE7dQFgk .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-UbXLM1ljjE7dQFgk .cluster text{fill:#333;}#mermaid-svg-UbXLM1ljjE7dQFgk .cluster span{color:#333;}#mermaid-svg-UbXLM1ljjE7dQFgk div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-UbXLM1ljjE7dQFgk .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-UbXLM1ljjE7dQFgk rect.text{fill:none;stroke-width:0;}#mermaid-svg-UbXLM1ljjE7dQFgk .icon-shape,#mermaid-svg-UbXLM1ljjE7dQFgk .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-UbXLM1ljjE7dQFgk .icon-shape p,#mermaid-svg-UbXLM1ljjE7dQFgk .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-UbXLM1ljjE7dQFgk .icon-shape .label rect,#mermaid-svg-UbXLM1ljjE7dQFgk .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-UbXLM1ljjE7dQFgk .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-UbXLM1ljjE7dQFgk .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-UbXLM1ljjE7dQFgk :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 焦点系统核心概念
onFocusChanged 监听
requestFocus() 命令
焦点 Focus

当前接收事件的元素
FocusState

监听焦点状态变化
FocusRequester

主动请求焦点的凭证
用户按键
处理键盘/遥控器事件

三、焦点遍历:默认顺序什么时候会"犯傻"

3.1 默认遍历顺序

Compose 的默认焦点遍历遵循声明顺序 (Tab 键)和几何位置(方向键)。大多数简单布局下,这是对的。但一旦布局复杂起来,默认行为就会犯傻。

官方文档举了一个很典型的例子:一个 LazyVerticalGrid,顶部一行是 FilterChip(标签栏),下面是 SweetsCard 网格。如果标签栏没有包 focusGroup() ,当用户按 Tab 从最后一个标签 FilterChipC 往下走时,焦点可能直接跳到下方的网格项 ,而不是继续在标签栏里循环。更糟的是,如果 FilterChipC 因为横向滚动而"部分不可见",焦点搜索可能完全忽略它,跳到别的组里去。

3.2 focusGroup:告诉 Compose"这一组是一个整体"

修法很简单:把一组需要"连续遍历"的元素,包在一个加了 Modifier.focusGroup() 的父容器里。

kotlin 复制代码
Row(modifier = Modifier.focusGroup()) {
    FilterChipA()
    FilterChipB()
    FilterChipC()
}

focusGroup() 的作用是:让这一组在焦点搜索时表现为"一个实体" 。焦点管理器会优先在组内按顺序遍历,只有组内遍历完了,才会离开这个组。

什么时候该用 focusGroup() :标签栏、工具栏、对话框底部的一排按钮、卡片列表里的一行操作按钮------任何"用户期望按顺序走完"的横向或纵向集合。

3.3 focusProperties:精细控制"往哪走"

如果默认的遍历方向也不对,可以用 Modifier.focusProperties 直接指定每个方向键该去哪个元素:

kotlin 复制代码
val first = remember { FocusRequester() }
val second = remember { FocusRequester() }
val third = remember { FocusRequester() }

Column {
    Button(
        onClick = {},
        modifier = Modifier
            .focusRequester(first)
            .focusProperties { down = second }
    ) { Text("第一个") }

    Button(
        onClick = {},
        modifier = Modifier
            .focusRequester(second)
            .focusProperties {
                down = third
                up = first
            }
    ) { Text("第二个") }

    Button(
        onClick = {},
        modifier = Modifier
            .focusRequester(third)
            .focusProperties { up = second }
    ) { Text("第三个") }
}

focusProperties 可以覆盖 up、down、left、right 四个方向的目标。这在 TV 的 2D 网格导航里非常常用------网格的几何位置和用户的心理预期不一致时,用这个把方向"掰正"。

3.4 onEnter:进入一组时,把焦点"重定向"到更合理的位置

官方文档给了一个非常经典的场景:一个确认对话框,左边"取消",右边"确认"。用户从表单字段往下按方向键进入按钮组时,默认会落在视觉顺序的第一个------"取消"上。但**"确认"是推荐操作**,用户更希望焦点直接落在"确认"上。

修法是:在父容器 上加 focusGroup() 和 focusProperties,在 onEnter 里请求目标子元素的焦点:

kotlin 复制代码
val confirmRequester = remember { FocusRequester() }

Row(
    modifier = Modifier
        .focusGroup()
        .focusProperties {
            onEnter = {
                confirmRequester.requestFocus()
            }
        }
) {
    Button(onClick = {}) { Text("取消") }
    Button(
        onClick = {},
        modifier = Modifier.focusRequester(confirmRequester)
    ) { Text("确认") }
}

关键理解 :focusGroup() 保证了"组是一个整体",focusProperties { onEnter = { ... } } 保证了"进组时先去哪"。两者配合,焦点的入口行为就被完全控制了。

下面是焦点遍历问题的诊断与修复流程图:
#mermaid-svg-0Ki5SKb3vApwivwv{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-0Ki5SKb3vApwivwv .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-0Ki5SKb3vApwivwv .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-0Ki5SKb3vApwivwv .error-icon{fill:#552222;}#mermaid-svg-0Ki5SKb3vApwivwv .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-0Ki5SKb3vApwivwv .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-0Ki5SKb3vApwivwv .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-0Ki5SKb3vApwivwv .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-0Ki5SKb3vApwivwv .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-0Ki5SKb3vApwivwv .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-0Ki5SKb3vApwivwv .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-0Ki5SKb3vApwivwv .marker{fill:#333333;stroke:#333333;}#mermaid-svg-0Ki5SKb3vApwivwv .marker.cross{stroke:#333333;}#mermaid-svg-0Ki5SKb3vApwivwv svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-0Ki5SKb3vApwivwv p{margin:0;}#mermaid-svg-0Ki5SKb3vApwivwv .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-0Ki5SKb3vApwivwv .cluster-label text{fill:#333;}#mermaid-svg-0Ki5SKb3vApwivwv .cluster-label span{color:#333;}#mermaid-svg-0Ki5SKb3vApwivwv .cluster-label span p{background-color:transparent;}#mermaid-svg-0Ki5SKb3vApwivwv .label text,#mermaid-svg-0Ki5SKb3vApwivwv span{fill:#333;color:#333;}#mermaid-svg-0Ki5SKb3vApwivwv .node rect,#mermaid-svg-0Ki5SKb3vApwivwv .node circle,#mermaid-svg-0Ki5SKb3vApwivwv .node ellipse,#mermaid-svg-0Ki5SKb3vApwivwv .node polygon,#mermaid-svg-0Ki5SKb3vApwivwv .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-0Ki5SKb3vApwivwv .rough-node .label text,#mermaid-svg-0Ki5SKb3vApwivwv .node .label text,#mermaid-svg-0Ki5SKb3vApwivwv .image-shape .label,#mermaid-svg-0Ki5SKb3vApwivwv .icon-shape .label{text-anchor:middle;}#mermaid-svg-0Ki5SKb3vApwivwv .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-0Ki5SKb3vApwivwv .rough-node .label,#mermaid-svg-0Ki5SKb3vApwivwv .node .label,#mermaid-svg-0Ki5SKb3vApwivwv .image-shape .label,#mermaid-svg-0Ki5SKb3vApwivwv .icon-shape .label{text-align:center;}#mermaid-svg-0Ki5SKb3vApwivwv .node.clickable{cursor:pointer;}#mermaid-svg-0Ki5SKb3vApwivwv .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-0Ki5SKb3vApwivwv .arrowheadPath{fill:#333333;}#mermaid-svg-0Ki5SKb3vApwivwv .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-0Ki5SKb3vApwivwv .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-0Ki5SKb3vApwivwv .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-0Ki5SKb3vApwivwv .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-0Ki5SKb3vApwivwv .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-0Ki5SKb3vApwivwv .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-0Ki5SKb3vApwivwv .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-0Ki5SKb3vApwivwv .cluster text{fill:#333;}#mermaid-svg-0Ki5SKb3vApwivwv .cluster span{color:#333;}#mermaid-svg-0Ki5SKb3vApwivwv div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-0Ki5SKb3vApwivwv .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-0Ki5SKb3vApwivwv rect.text{fill:none;stroke-width:0;}#mermaid-svg-0Ki5SKb3vApwivwv .icon-shape,#mermaid-svg-0Ki5SKb3vApwivwv .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-0Ki5SKb3vApwivwv .icon-shape p,#mermaid-svg-0Ki5SKb3vApwivwv .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-0Ki5SKb3vApwivwv .icon-shape .label rect,#mermaid-svg-0Ki5SKb3vApwivwv .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-0Ki5SKb3vApwivwv .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-0Ki5SKb3vApwivwv .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-0Ki5SKb3vApwivwv :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
是
否
是
否
焦点遍历顺序不对?
默认顺序是否满足?
无需处理
需要整组连续遍历?
加 focusGroup()

把一组打包成整体
需要精确控制方向?
加 focusProperties

指定 up/down/left/right
进入组时焦点位置不对?
focusProperties + onEnter

重定向到目标子元素
组合使用多种方案

四、焦点恢复:返回页面时,焦点应该"记得"用户看到哪

用户从列表页进详情页,再返回列表页------焦点应该回到他刚才浏览到的那一项,而不是列表的第一项。这是 TV 和键盘用户最基础的体验预期。

官方文档明确指出:focusRestorer() 可以附加到父容器上,当焦点重新进入这个容器时,自动恢复到上一次获得焦点的子元素 。对于 LazyColumn 或 LazyVerticalGrid,直接在列表上加 focusRestorer() 即可:

kotlin 复制代码
LazyColumn(
    modifier = Modifier.focusRestorer()
) {
    items(videos, key = { it.id }) { video ->
        VideoCard(video)
    }
}

但有一个必须注意的边界 :focusRestorer 在列表内容动态变化 时可能表现不稳定。一个经过验证的替代方案是:自己记住上一次聚焦项的稳定 ID 或索引 ,在返回时用 FocusRequester 请求那个位置的焦点。

kotlin 复制代码
var lastFocusedId by rememberSaveable { mutableStateOf<Long?>(null) }

LazyColumn {
    items(videos, key = { it.id }) { video ->
        VideoCard(
            video = video,
            onFocused = { lastFocusedId = video.id }
        )
    }
}

LaunchedEffect(lastFocusedId) {
    lastFocusedId?.let { id ->
        // 找到对应项的 FocusRequester 并 requestFocus
    }
}

什么时候用 focusRestorer() :列表结构稳定、项的顺序不经常变化。什么时候自己记 ID :列表会增删、排序会变、或者 focusRestorer 出现崩溃/不恢复的情况。

下面是焦点恢复的两种方案对比图:
#mermaid-svg-HtOaII2x8nyQRfzX{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-HtOaII2x8nyQRfzX .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-HtOaII2x8nyQRfzX .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-HtOaII2x8nyQRfzX .error-icon{fill:#552222;}#mermaid-svg-HtOaII2x8nyQRfzX .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-HtOaII2x8nyQRfzX .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-HtOaII2x8nyQRfzX .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-HtOaII2x8nyQRfzX .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-HtOaII2x8nyQRfzX .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-HtOaII2x8nyQRfzX .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-HtOaII2x8nyQRfzX .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-HtOaII2x8nyQRfzX .marker{fill:#333333;stroke:#333333;}#mermaid-svg-HtOaII2x8nyQRfzX .marker.cross{stroke:#333333;}#mermaid-svg-HtOaII2x8nyQRfzX svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-HtOaII2x8nyQRfzX p{margin:0;}#mermaid-svg-HtOaII2x8nyQRfzX .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-HtOaII2x8nyQRfzX .cluster-label text{fill:#333;}#mermaid-svg-HtOaII2x8nyQRfzX .cluster-label span{color:#333;}#mermaid-svg-HtOaII2x8nyQRfzX .cluster-label span p{background-color:transparent;}#mermaid-svg-HtOaII2x8nyQRfzX .label text,#mermaid-svg-HtOaII2x8nyQRfzX span{fill:#333;color:#333;}#mermaid-svg-HtOaII2x8nyQRfzX .node rect,#mermaid-svg-HtOaII2x8nyQRfzX .node circle,#mermaid-svg-HtOaII2x8nyQRfzX .node ellipse,#mermaid-svg-HtOaII2x8nyQRfzX .node polygon,#mermaid-svg-HtOaII2x8nyQRfzX .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-HtOaII2x8nyQRfzX .rough-node .label text,#mermaid-svg-HtOaII2x8nyQRfzX .node .label text,#mermaid-svg-HtOaII2x8nyQRfzX .image-shape .label,#mermaid-svg-HtOaII2x8nyQRfzX .icon-shape .label{text-anchor:middle;}#mermaid-svg-HtOaII2x8nyQRfzX .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-HtOaII2x8nyQRfzX .rough-node .label,#mermaid-svg-HtOaII2x8nyQRfzX .node .label,#mermaid-svg-HtOaII2x8nyQRfzX .image-shape .label,#mermaid-svg-HtOaII2x8nyQRfzX .icon-shape .label{text-align:center;}#mermaid-svg-HtOaII2x8nyQRfzX .node.clickable{cursor:pointer;}#mermaid-svg-HtOaII2x8nyQRfzX .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-HtOaII2x8nyQRfzX .arrowheadPath{fill:#333333;}#mermaid-svg-HtOaII2x8nyQRfzX .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-HtOaII2x8nyQRfzX .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-HtOaII2x8nyQRfzX .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HtOaII2x8nyQRfzX .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-HtOaII2x8nyQRfzX .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HtOaII2x8nyQRfzX .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-HtOaII2x8nyQRfzX .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-HtOaII2x8nyQRfzX .cluster text{fill:#333;}#mermaid-svg-HtOaII2x8nyQRfzX .cluster span{color:#333;}#mermaid-svg-HtOaII2x8nyQRfzX div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-HtOaII2x8nyQRfzX .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-HtOaII2x8nyQRfzX rect.text{fill:none;stroke-width:0;}#mermaid-svg-HtOaII2x8nyQRfzX .icon-shape,#mermaid-svg-HtOaII2x8nyQRfzX .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-HtOaII2x8nyQRfzX .icon-shape p,#mermaid-svg-HtOaII2x8nyQRfzX .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-HtOaII2x8nyQRfzX .icon-shape .label rect,#mermaid-svg-HtOaII2x8nyQRfzX .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-HtOaII2x8nyQRfzX .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-HtOaII2x8nyQRfzX .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-HtOaII2x8nyQRfzX :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
用户从列表页返回
列表结构是否稳定?
方案一:focusRestorer()

自动恢复上次焦点
方案二:自己记稳定 ID

rememberSaveable 保存
焦点回到上次浏览项
LaunchedEffect 触发

FocusRequester.requestFocus()
用户继续浏览

五、键盘输入:onKeyEvent 与 onPreviewKeyEvent

焦点决定了"谁接收事件",onKeyEvent 决定了"接收之后怎么处理"。

5.1 基本用法

kotlin 复制代码
Box(
    modifier = Modifier
        .focusable()
        .onKeyEvent { keyEvent ->
            if (keyEvent.type == KeyEventType.KeyUp && keyEvent.key == Key.S) {
                doSomething()
                true   // 已处理,停止传播
            } else {
                false  // 未处理,继续传播
            }
        }
)

三个关键点:

第一,按键有两个事件 :KeyDown(按下)和 KeyUp(松开)。大多数操作应该在 KeyUp 里触发,避免长按重复触发。

第二,返回值决定传播 :返回 true 表示"我处理了,别再往下传";返回 false 表示"我没处理,继续传给其他元素"。

第三,修饰键 :KeyEvent 提供了 isCtrlPressed、isShiftPressed、isAltPressed、isMetaPressed,可以判断组合键。

5.2 onPreviewKeyEvent:在焦点元素之前拦截

onPreviewKeyEvent 和 onKeyEvent 的区别在于调用时机 :onPreviewKeyEvent 在事件到达焦点元素之前 调用,适合做全局快捷键或拦截式处理;onKeyEvent 在焦点元素处理时调用。

kotlin 复制代码
Column(
    modifier = Modifier.onPreviewKeyEvent { event ->
        if (event.type == KeyEventType.KeyUp && event.key == Key.Spacebar) {
            togglePlayPause()
            true
        } else false
    }
) {
    // 播放器内容
}

5.3 空格键和 Enter 的"点击"语义

Button、Modifier.clickable 已经处理了空格键和 Enter 键的"点击"语义。如果你用 onKeyEvent 自己处理了空格/Enter,要注意不要和 clickable 的内置处理冲突 。官方文档明确提醒:clickable 会拦截按键事件,并在按下 Spacebar 或 Enter 时触发点击。只在确实需要自定义行为时才手动处理。

下面是键盘事件处理流程的示意图:
#mermaid-svg-vkUCkf8pcJmvRYCy{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vkUCkf8pcJmvRYCy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vkUCkf8pcJmvRYCy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vkUCkf8pcJmvRYCy .error-icon{fill:#552222;}#mermaid-svg-vkUCkf8pcJmvRYCy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vkUCkf8pcJmvRYCy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vkUCkf8pcJmvRYCy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vkUCkf8pcJmvRYCy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vkUCkf8pcJmvRYCy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vkUCkf8pcJmvRYCy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vkUCkf8pcJmvRYCy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vkUCkf8pcJmvRYCy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vkUCkf8pcJmvRYCy .marker.cross{stroke:#333333;}#mermaid-svg-vkUCkf8pcJmvRYCy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vkUCkf8pcJmvRYCy p{margin:0;}#mermaid-svg-vkUCkf8pcJmvRYCy .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-vkUCkf8pcJmvRYCy .cluster-label text{fill:#333;}#mermaid-svg-vkUCkf8pcJmvRYCy .cluster-label span{color:#333;}#mermaid-svg-vkUCkf8pcJmvRYCy .cluster-label span p{background-color:transparent;}#mermaid-svg-vkUCkf8pcJmvRYCy .label text,#mermaid-svg-vkUCkf8pcJmvRYCy span{fill:#333;color:#333;}#mermaid-svg-vkUCkf8pcJmvRYCy .node rect,#mermaid-svg-vkUCkf8pcJmvRYCy .node circle,#mermaid-svg-vkUCkf8pcJmvRYCy .node ellipse,#mermaid-svg-vkUCkf8pcJmvRYCy .node polygon,#mermaid-svg-vkUCkf8pcJmvRYCy .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-vkUCkf8pcJmvRYCy .rough-node .label text,#mermaid-svg-vkUCkf8pcJmvRYCy .node .label text,#mermaid-svg-vkUCkf8pcJmvRYCy .image-shape .label,#mermaid-svg-vkUCkf8pcJmvRYCy .icon-shape .label{text-anchor:middle;}#mermaid-svg-vkUCkf8pcJmvRYCy .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-vkUCkf8pcJmvRYCy .rough-node .label,#mermaid-svg-vkUCkf8pcJmvRYCy .node .label,#mermaid-svg-vkUCkf8pcJmvRYCy .image-shape .label,#mermaid-svg-vkUCkf8pcJmvRYCy .icon-shape .label{text-align:center;}#mermaid-svg-vkUCkf8pcJmvRYCy .node.clickable{cursor:pointer;}#mermaid-svg-vkUCkf8pcJmvRYCy .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-vkUCkf8pcJmvRYCy .arrowheadPath{fill:#333333;}#mermaid-svg-vkUCkf8pcJmvRYCy .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-vkUCkf8pcJmvRYCy .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-vkUCkf8pcJmvRYCy .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vkUCkf8pcJmvRYCy .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-vkUCkf8pcJmvRYCy .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vkUCkf8pcJmvRYCy .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-vkUCkf8pcJmvRYCy .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-vkUCkf8pcJmvRYCy .cluster text{fill:#333;}#mermaid-svg-vkUCkf8pcJmvRYCy .cluster span{color:#333;}#mermaid-svg-vkUCkf8pcJmvRYCy div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-vkUCkf8pcJmvRYCy .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-vkUCkf8pcJmvRYCy rect.text{fill:none;stroke-width:0;}#mermaid-svg-vkUCkf8pcJmvRYCy .icon-shape,#mermaid-svg-vkUCkf8pcJmvRYCy .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-vkUCkf8pcJmvRYCy .icon-shape p,#mermaid-svg-vkUCkf8pcJmvRYCy .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-vkUCkf8pcJmvRYCy .icon-shape .label rect,#mermaid-svg-vkUCkf8pcJmvRYCy .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-vkUCkf8pcJmvRYCy .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-vkUCkf8pcJmvRYCy .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-vkUCkf8pcJmvRYCy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
按键事件发生
onPreviewKeyEvent

焦点元素之前拦截
是否处理?
返回 true

停止传播
事件传给焦点元素
onKeyEvent

焦点元素处理
是否处理?
返回 true

停止传播
返回 false

继续传播
父容器继续处理

六、TV 与桌面端:D-pad 导航的特殊性

TV 应用和桌面应用是焦点管理的"主战场"。

TV 端 :用户只有遥控器的方向键和确认键。官方文档的要求很明确:必须确保焦点在可组合项之间正确移动,并且当前获得焦点的元素有清晰的视觉高亮。视觉高亮不是可选项------用户看不到鼠标指针,只能靠焦点框知道自己"在哪"。

桌面端 :键盘导航是标配。Tab 键的一维遍历和方向键的二维遍历都要符合用户预期。focusProperties 的 up/down/left/right 在桌面端尤其重要。

一个 TV 端的常见坑 :如果 LazyRow 里的项是 Box + clickable,但父级没有 focusGroup(),方向键可能在行与行之间乱跳。给每一行加 focusGroup(),让行内遍历和行间遍历的优先级符合预期。

七、输入法与焦点:TextField 的联动

TextField 获得焦点时,软件键盘(IME)通常会自动弹出。Compose 通过 KeyboardOptions 控制 IME 的行为:

kotlin 复制代码
TextField(
    value = text,
    onValueChange = { text = it },
    keyboardOptions = KeyboardOptions(
        imeAction = ImeAction.Next
    ),
    keyboardActions = KeyboardActions(
        onNext = {
            nextFocusRequester.requestFocus()
        }
    )
)

ImeAction.Next 会让软键盘的"回车"键变成"下一项",点击后焦点自动移到下一个焦点目标。配合 KeyboardActions 可以自定义跳转逻辑。

一个容易忽略的点 :KeyboardOptions 里还有 showKeyboardOnFocus 参数。默认情况下,TextField 获得焦点时软键盘会立即弹出;如果希望用户再次点击 才弹出,可以设为 false。这在 TV 或桌面端(有物理键盘)时很有用。

八、常见陷阱速查

陷阱 真实场景 后果 解法
组合期间调 requestFocus() 直接在可组合函数体里调 崩溃或焦点时机错误 放在 LaunchedEffect(Unit) 或事件回调里
忘记 focusable() 自定义 Box 想接收焦点 焦点根本进不去 加 Modifier.focusable()
焦点遍历顺序乱 标签栏、工具栏没有分组 Tab/方向键跳到别的区域 给组加 focusGroup()
进入组时焦点位置不对 对话框默认落在"取消"而不是"确认" 用户需要多按一次方向键 focusGroup() + focusProperties { onEnter = { requestFocus() } }
返回列表焦点重置到第一项 LazyColumn 没有恢复机制 用户要从头找 focusRestorer() 或自己记住稳定 ID
onKeyEvent 返回 false 但期望"已处理" 按键继续传播到其他元素 触发意外行为 处理了的事件返回 true
在 KeyDown 里做操作 长按重复触发 操作被执行多次 在 KeyUp 里处理
空格/Enter 与 clickable 冲突 手动处理空格,clickable 也处理 点击触发两次 用 clickable 的内置语义,或 onPreviewKeyEvent 拦截
TV 端焦点没有视觉高亮 用户不知道焦点在哪 完全无法操作 onFocusChanged 驱动背景/边框变化
focusRestorer 在动态列表中崩溃 列表增删后返回 崩溃或恢复错误 改用"记住稳定 ID + requestFocus"

九、思维模型:焦点的"三层管理"

第一层:谁能获得焦点。 focusable() 让普通容器可聚焦;Button/clickable 天生可聚焦;focusProperties { canFocus = false } 可以让某个元素被跳过。

第二层:焦点按什么顺序走。 默认是声明顺序和几何位置。复杂布局用 focusGroup() 把一组元素"打包"成一个遍历单元;方向不对时用 focusProperties 的 up/down/left/right 掰正;进组时用 onEnter 重定向到更合理的子元素。

第三层:焦点怎么"记住"和"恢复"。 页面返回用 focusRestorer() 或自己记稳定 ID;TV 端用 onFocusChanged 驱动视觉高亮;FocusRequester 负责在需要时"命令"焦点跳到某个元素。

贯穿始终的原则:焦点是键盘/遥控器用户的"手指"。 触摸屏用户用眼睛找目标,用指尖点;键盘用户只能靠焦点移动来"探索"界面。焦点顺序错了,等于把用户的眼睛蒙上了。focusGroup、focusProperties、focusRestorer 这三个 Modifier,就是给用户"把眼睛睁开"的工具。

十、小结与下一课预告

这一课我们把焦点管理与键盘输入讲透了。关键点回顾:

  • 焦点是当前接收键盘/遥控器事件的元素 。Button/clickable 默认可聚焦,普通容器需要 focusable()。
  • FocusRequester :焦点的"遥控器"。三步走:创建、附加、在事件回调或 LaunchedEffect(Unit) 里 requestFocus()。
  • focusGroup() :把一组元素打包成一个遍历单元,解决 Tab/方向键"跳错组"的问题。
  • focusProperties :up/down/left/right 精细控制方向键去向;onEnter 重定向进入组时的焦点位置。
  • focusRestorer() :页面返回时恢复焦点到上次的子元素。动态列表用"记住稳定 ID"更可靠。
  • onKeyEvent / onPreviewKeyEvent :处理键盘按键。在 KeyUp 里触发操作,返回 true 停止传播。
  • TV/桌面端:D-pad 导航必须保证焦点移动正确且视觉高亮清晰。
  • 输入法联动 :ImeAction.Next + KeyboardActions 让软键盘"下一项"自动跳转。
  • 常见陷阱 :组合期间 requestFocus、忘 focusable、遍历顺序乱、返回焦点重置、onKeyEvent 返回值错误、KeyDown 重复触发。

下一课,我们会讲Compose中的窗口适配与Insets处理 。状态栏、导航栏、输入法、刘海屏、折叠屏的铰链------这些"系统占用的空间"怎么正确避让?WindowInsets 怎么用?enableEdgeToEdge 之后内容为什么被状态栏挡住?imePadding 和 navigationBarsPadding 怎么配合?这是把界面从"画出来"做到"在各种屏幕上都完整可见"的最后一环。

课后练习建议:找一个你之前写的列表页面,加上 focusGroup() 和 focusRestorer(),然后用键盘的 Tab 键和方向键走一遍。你会发现,没有 focusGroup 的时候,焦点可能跳到"不该去"的地方;加上之后,遍历顺序立刻变得符合直觉。然后再给你的自定义卡片加上 focusable() 和 onFocusChanged,做一个简单的焦点高亮效果。做完之后,你的页面就同时"摸得到"和"按得到"了。

相关推荐
hai_android1 小时前
AudioTrack 深度解析:从原理到实战
android·java·kotlin
红尘伴蝶舞1 小时前
从秒开优化到连环崩溃:深入剖析 ViewPager2 在常驻 DialogFragment 中的复用陷阱与自愈方案
android·kotlin
巫山老妖6 小时前
Android 应用架构演进:MVC / MVP / MVVM / MVI (2026)
android·android jetpack
YB13758 小时前
kotlin中闭包特性
kotlin
木头左10 小时前
【架构设计】教育类 App 服务端化改造:AI 上移、题库分层与个人题库归属
android·架构设计·系统设计·后端开发·数据库设计
墨狂之逸才10 小时前
USB 的 HOST 和 DEVICE 到底有什么区别?ADB 调试应该选哪个?
android
奶油话梅糖11 小时前
苹果的内核跑上了安卓手机:2,631 Star 的 HakiOS 让 XNU 在 Adreno GPU 上画出 118 帧
android·macos·智能手机
恋猫de小郭12 小时前
Flutter THLC 的 WebView 滚动终于从 70 FPS 提升到 120 FPS
android·前端·flutter
好的佩奇1 天前
Dart 之扩展类型
android·flutter·dart