Android TV中如何为TalkBack设置正确的无障碍焦点导航方向?
问题场景
垂直RecyclerView嵌套多个水平RecyclerView,仅默认显示单个水平RV,原本通过监听onKeyDown捕获DPAD按键实现RV切换。但启用TalkBack后,遥控器按键事件被AccessibilityService接管,应用无法在onKeyDown/onKeyUp中接收事件,且TalkBack仅能识别当前可见的水平RV,无法完成跨RV的切换。此前尝试AccessibilityTraversalAfter/Before无效(仅适配触摸设备),onInitializeAccessibilityEvent也无法识别具体按键类型。
解决方案
1. 自定义AccessibilityDelegate拦截遍历动作
TalkBack会将DPAD按键转化为对应的辅助功能动作,我们可以给垂直RecyclerView设置自定义AccessibilityDelegate,重写performAccessibilityAction拦截这些动作,手动处理RV切换逻辑:
// 给垂直RecyclerView绑定自定义AccessibilityDelegate verticalRecyclerView.setAccessibilityDelegate(object : View.AccessibilityDelegate() { override fun performAccessibilityAction(host: View?, action: Int, args: Bundle?): Boolean { when (action) { // 处理DPAD_DOWN:切换到下一个水平RV AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY -> { val layoutManager = verticalRecyclerView.layoutManager as LinearLayoutManager val currentPos = layoutManager.findFirstCompletelyVisibleItemPosition() val totalCount = verticalRecyclerView.adapter?.itemCount ?: 0 if (currentPos < totalCount - 1) { // 滚动到目标位置 verticalRecyclerView.smoothScrollToPosition(currentPos + 1) // 延迟聚焦新RV的首个item,确保布局完成 verticalRecyclerView.post { verticalRecyclerView.findViewHolderForAdapterPosition(currentPos + 1)?.let { holder -> val targetHorizontalRV = holder.itemView.findViewById<RecyclerView>(R.id.horizontal_rv) targetHorizontalRV.requestFocus() targetHorizontalRV.findViewHolderForAdapterPosition(0)?.itemView?.requestFocus() } } return true // 告知TalkBack动作已处理,阻止默认行为 } } // 处理DPAD_UP:切换到上一个水平RV AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY -> { val layoutManager = verticalRecyclerView.layoutManager as LinearLayoutManager val currentPos = layoutManager.findFirstCompletelyVisibleItemPosition() if (currentPos > 0) { verticalRecyclerView.smoothScrollToPosition(currentPos - 1) verticalRecyclerView.post { verticalRecyclerView.findViewHolderForAdapterPosition(currentPos - 1)?.let { holder -> val targetHorizontalRV = holder.itemView.findViewById<RecyclerView>(R.id.horizontal_rv) targetHorizontalRV.requestFocus() targetHorizontalRV.findViewHolderForAdapterPosition(0)?.itemView?.requestFocus() } } return true } } // 左右方向动作交由水平RV自行处理,不拦截 AccessibilityNodeInfo.ACTION_SCROLL_FORWARD, AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD -> { return super.performAccessibilityAction(host, action, args) } } return super.performAccessibilityAction(host, action, args) } })
2. 完善AccessibilityNodeInfo的遍历关系
给垂直RV的每个item(水平RV容器)设置自定义AccessibilityDelegate,重写onInitializeAccessibilityNodeInfo明确上下方向的遍历目标,让TalkBack能识别合法的切换路径:
// 在垂直RecyclerView的ViewHolder中配置 class VerticalViewHolder(itemView: View, private val verticalRV: RecyclerView) : RecyclerView.ViewHolder(itemView) { init { itemView.accessibilityDelegate = object : View.AccessibilityDelegate() { override fun onInitializeAccessibilityNodeInfo(host: View?, info: AccessibilityNodeInfo?) { super.onInitializeAccessibilityNodeInfo(host, info) // 设置DPAD_UP的遍历目标:上一个水平RV容器 val prevPos = adapterPosition - 1 if (prevPos >= 0) { verticalRV.findViewHolderForAdapterPosition(prevPos)?.itemView?.let { prevItem -> info?.setTraversalBefore(prevItem) } } // 设置DPAD_DOWN的遍历目标:下一个水平RV容器 val nextPos = adapterPosition + 1 val totalCount = verticalRV.adapter?.itemCount ?: 0 if (nextPos < totalCount) { verticalRV.findViewHolderForAdapterPosition(nextPos)?.itemView?.let { nextItem -> info?.setTraversalAfter(nextItem) } } // 声明节点支持上下遍历动作 info?.addAction(AccessibilityNodeInfo.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY) info?.addAction(AccessibilityNodeInfo.ACTION_NEXT_AT_MOVEMENT_GRANULARITY) // 左右遍历交由内部水平RV处理 info?.setMovementGranularities(AccessibilityNodeInfo.MOVEMENT_GRANULARITY_ITEM) } } } }
3. 同步焦点状态
切换水平RV后,必须通过post延迟执行焦点请求,确保RV完成滚动和布局后再聚焦,避免TalkBack识别不到焦点位置。如果水平RV的item本身不可聚焦,需给item设置android:focusable="true"和android:focusableInTouchMode="true"。
关键注意事项
- 处理完动作后返回
true,避免TalkBack执行默认的无效遍历逻辑。 - 遍历目标需要动态刷新,可在垂直RV的
onBindViewHolder中重新配置AccessibilityNodeInfo,防止位置失效。 - 不要拦截DPAD左右方向的动作,保留给水平RV内部处理焦点遍历。
内容的提问来源于stack exchange,提问作者Tgeodude

