摘要 :
触摸屏时代焦点是隐形的,但换成键盘、遥控器、D-pad 后,焦点就成了用户唯一的"手指"。本文从"遥控器按了没反应"的真实场景出发,讲清 Compose 焦点管理的三个核心概念(焦点、
FocusState、FocusRequester),并逐一拆解焦点遍历(focusGroup、focusProperties、onEnter)、焦点恢复(focusRestorer与自记稳定 ID)、键盘事件(onKeyEvent/onPreviewKeyEvent)、TV 与桌面端 D-pad 导航、输入法联动等关键问题,最后给出常见陷阱速查表与"三层管理"思维模型,帮你用几行 Modifier 把焦点真正"管"起来。
目录
- 摘要
- 前言
- 一、先看一个"遥控器按了没反应"的现场
- 二、先搞清楚三个概念:焦点、FocusState、FocusRequester
- [2.1 什么是"焦点"](#2.1 什么是“焦点”)
- [2.2 FocusRequester:焦点的"遥控器"](#2.2 FocusRequester:焦点的“遥控器”)
- 三、焦点遍历:默认顺序什么时候会"犯傻"
- [3.1 默认遍历顺序](#3.1 默认遍历顺序)
- [3.2 focusGroup:告诉 Compose"这一组是一个整体"](#3.2 focusGroup:告诉 Compose“这一组是一个整体”)
- [3.3 focusProperties:精细控制"往哪走"](#3.3 focusProperties:精细控制“往哪走”)
- [3.4 onEnter:进入一组时,把焦点"重定向"到更合理的位置](#3.4 onEnter:进入一组时,把焦点“重定向”到更合理的位置)
- 四、焦点恢复:返回页面时,焦点应该"记得"用户看到哪
- [五、键盘输入:onKeyEvent 与 onPreviewKeyEvent](#五、键盘输入:onKeyEvent 与 onPreviewKeyEvent)
- [5.1 基本用法](#5.1 基本用法)
- [5.2 onPreviewKeyEvent:在焦点元素之前拦截](#5.2 onPreviewKeyEvent:在焦点元素之前拦截)
- [5.3 空格键和 Enter 的"点击"语义](#5.3 空格键和 Enter 的“点击”语义)
- [六、TV 与桌面端:D-pad 导航的特殊性](#六、TV 与桌面端:D-pad 导航的特殊性)
- [七、输入法与焦点:TextField 的联动](#七、输入法与焦点:TextField 的联动)
- 八、常见陷阱速查
- 九、思维模型:焦点的"三层管理"
- 十、小结与下一课预告
前言
先说个生活里的小场景。
你窝在沙发上,拿起电视遥控器,想点开刚看中的那部电影。手指按下"下"键,屏幕上的高亮框却"嗖"地一下跳到了旁边的推荐位;再按一下,又不知道跑哪去了。你盯着屏幕找了半天,才把那个小小的框"追"回来。
这不是你的问题,是程序没把"焦点"管好。
在手机上,我们习惯了用手指直接点------点哪儿就是哪儿,几乎不用操心"当前选中的是哪个元素"。可一旦换成键盘、遥控器、方向键,甚至游戏手柄,情况就完全不一样了:用户看不见鼠标,也摸不到屏幕,唯一能告诉他"现在在哪"的,就是那个会移动的高亮框。 这个高亮框背后,就是 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,做一个简单的焦点高亮效果。做完之后,你的页面就同时"摸得到"和"按得到"了。