AndroidTV 中焦点项被移除后,focusRestorer 为何不使用 fallback?
当前焦点项可能被删除时,应先转移焦点,再更新列表。可以保留 focusRestorer(fallback),但不能把它当作列表删除时的通用焦点兜底。
在这个示例中,如果原焦点卡片即将消失,而同一行的第一张卡片仍然存在、已完成布局且可聚焦,可以先对第一张卡片调用 requestFocus(),再替换列表。
为什么设置了 fallback 仍然会跳到其他行?
focusRestorer 主要用于在焦点离开一个焦点组、再次进入时,恢复此前聚焦的子项。如果恢复失败,fallback 用于选择替代目标。AndroidX 对应实现将恢复及 fallback 的处理放在 onEnter 中。移除正在聚焦的节点时,不一定会触发对原 LazyRow 的重新进入,因此不能推断 fallback 一定会在删除后执行。Android 官方焦点恢复说明、AndroidX FocusRestorer 实现
原问题没有提供 Compose 版本和可核验的问题报告链接,无法确认它是否对应某个已知缺陷,也无法确认哪个版本已经修复。实际方案需要明确处理焦点项即将被删除的情况。
Placeholder 切换时,保留可聚焦的卡片外壳
原代码中的 Placeholder 和实际内容共用同一个 Card 调用,只改变内部文字、颜色和数据。
建议继续保持这种结构:
- 使用独立的 UI key 表示卡片槽位,单独保存 API 返回的业务 id。
- 加载完成时更新卡片内容,尽量保留外层
Card及其焦点节点。 - 如果 Placeholder 需要保留焦点,应让外层容器继续可聚焦;加载期间的点击可以在业务回调中拦截。
稳定 key 必须对应稳定的 UI 身份。只有槽位语义明确、位置不会随插入或重排改变时,才适合按位置复用身份。原示例只在尾部增减卡片,可以按这个前提处理;如果真实列表会排序或在头部插入,则需要重新设计映射。
稳定 key 有助于保留仍然存在的项目,但无法为已经删除的项目提供焦点目标。
针对示例:先转移焦点,再缩短列表
下面的改法保留原有数据类型和卡片结构,只调整更新入口。适用前提是:每行至少保留一项、第一项的 id 不变,并且作为目标的第一项已经布局且可聚焦。
先替换 UpdatingHomeComponent.Swimlane 中原来的 reload(),增加一个在赋值前执行的回调:
override fun reload() {
reload(beforeReplace = {})
}
fun reload(beforeReplace: (List<UpdatingHomeCard>) -> Unit) {
val newSize = (1..7).random()
val nextCards = (0 until newSize).map { index ->
val existingId = cards.getOrNull(index)?.id
?: UUID.randomUUID().toString()
if (iteration % 2 == 0) {
UpdatingHomeCard.Placeholder(id = existingId)
} else {
UpdatingHomeCard.Swimlane(id = existingId)
}
}
// 此时旧列表及其焦点节点仍然存在。
beforeReplace(nextCards)
cards = nextCards
iteration++
}
然后在 UpdatingSwimlane 中记录当前真正持有焦点的卡片:
var focusedCardId by remember(swimlane.id) {
mutableStateOf<String?>(null)
}
在每张 Card 原有的 modifier 上加入监听,保留其他修饰符及第一项的 focusRequester 绑定:
.onFocusChanged { state ->
if (state.isFocused) {
focusedCardId = card.id
} else if (focusedCardId == card.id) {
focusedCardId = null
}
}
这里需要导入 androidx.compose.ui.focus.onFocusChanged。监听应位于它要观察的焦点目标之前;原示例可以直接加在传给 Card 的 modifier 链中。
为了让验证代码使用每行自己的焦点状态,可以把 UpdatingHome 中原有的定时刷新移到 UpdatingSwimlane 内:
LaunchedEffect(swimlane.id) {
while (true) {
delay(5.seconds)
swimlane.reload { nextCards ->
val currentId = focusedCardId
val focusedItemWillBeRemoved =
currentId != null &&
nextCards.none { it.id == currentId }
if (focusedItemWillBeRemoved) {
firstItemFocusRequester.requestFocus()
}
}
}
}
使用这个验证方案时,要删除 UpdatingHome 中原有的 LaunchedEffect(Unit) 刷新循环,避免重复更新。实际业务由 API 响应驱动时,把同样的判断放在提交新列表的入口即可,不需要增加定时器。
只有当前持有焦点的卡片即将被删除时,这段代码才会发出焦点请求。没有持有焦点的行不会在刷新时主动抢焦点。
第一项不在布局中时怎么办?
FocusRequester 需要关联到实际存在的焦点目标。LazyRow 的某一项即使存在于数据列表中,也可能尚未组成或已经离开布局,因此不能无条件请求第一项。
生产环境可以按以下顺序处理更新:
- 根据旧焦点项和新列表选择替代目标,例如仍然存在的邻近项。
- 如果目标尚未布局,先滚动到目标,并等待其完成布局和 FocusRequester 绑定。
- 确认焦点已转移后,再提交会删除旧焦点节点的数据变更。
可以通过布局回调等方式确认目标的布局状态。不要用固定的 delay(100) 猜测布局何时完成,也不要在所有行的数据变化后统一执行 requestFocus(),否则容易产生焦点争抢。
如果新列表可能为空,需要预先提供其他可聚焦目标,例如同一内容区内稳定存在的加载或空状态容器,并明确决定何时转移到其他行。
需要验证哪些情况?
重点检查卡片数量增加、尾部缩短、焦点项被删除、非焦点行刷新、整行清空,以及用户在请求期间主动切换焦点的情况。上面的代码是基于原示例前提的改造方案,未提供设备运行结果;接入时还应按实际 Compose 版本和 Card 实现验证焦点行为。
备注:内容仅供参考。
