如何处理焦点在 RecyclerView 中的滚动和边界问题?
解读
国内大屏、TV、车载项目爆发,RecyclerView 焦点错位、焦点丢失、边界回弹、循环滚动是面试高频。面试官想确认:
- 是否掌握焦点在“布局→绘制→触摸/键值”全流程中的传递顺序;
- 能否用官方 API 解决 90% 场景,再写 10% 代码兜底;
- 是否了解 TV/车载“纯键盘”场景与手机“触摸+键值”差异;
- 性能与无障碍是否兼顾。
一句话:让焦点“看得见、找得回、不越界、不卡顿”。
知识点
- 焦点体系:FocusFinder → RecyclerView.FocusDelegate → LayoutManager.findNextFocus/scrollHorizontallyBy/scrollVerticallyBy。
- 边界判定:LayoutManager.getClipToPadding()、getPaddingStart/End、OrientationHelper.getEndAfterPadding。
- 滚动策略:LinearSmoothScroller.calculateDxToMakeVisible、SnapHelper、ScrollVectorProvider。
- 键值拦截:onKeyDown/onKeyIntercept + dispatchKeyEventPreIme,KEYCODE_DPAD_CENTER/DPAD_LEFT/RIGHT。
- 焦点记忆:onSaveInstanceState/onRestoreInstanceState + getFocusedChild + getChildAdapterPosition。
- 循环滚动:重写 LayoutManager.scrollToPositionWithOffset 实现 position %= itemCount。
- 性能:禁止 requestFocus() 在 onBindViewHolder 中直接调用,使用 post() 延迟;避免 notifyDataSetChanged,用 DiffUtil。
- 无障碍:setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES)、setItemDelegate。
答案
分四步作答,先给结论再给代码骨架,面试官可打断深挖。
第一步:让 LayoutManager 能“算”焦点 重写 LinearLayoutManager.findNextFocus,当焦点走出边界时返回 null,触发系统级 FocusFinder 继续查找;同时重写 canScrollHorizontally()/canScrollVertically() 返回 true,保证 RecyclerView 接管滚动。
第二步:让滚动“跟得上”焦点 继承 LinearSmoothScroller,在 calculateDxToMakeVisible 里把 align 模式改为 SNAP_TO_START,保证焦点项完整露出;调用 startSmoothScroll 而非 scrollToPosition,避免瞬间跳动。
第三步:边界兜底 在 RecyclerView 的 onKeyIntercept 中监听 DPAD,当 LayoutManager.findLastCompletelyVisibleItemPosition == adapter.itemCount-1 且 按键为 DPAD_DOWN 时,消费事件并回弹提示;顶部同理。车载场景可循环滚动,则把 position 取模后 smoothScrollToPosition。
第四步:焦点记忆与恢复 在 Fragment.onPause 里记录 rv.getFocusedChild 对应的 adapterPosition,在 onResume 里 post { adapter.notifyItemChanged(position); layoutManager.scrollToPositionWithOffset(position, 0); rv.findViewHolderForAdapterPosition(position)?.itemView?.requestFocus() },防止页面重建后焦点丢失。
核心代码(TV 横向列表示例):
class FocusableLayoutManager(context: Context) :
LinearLayoutManager(context, HORIZONTAL, false) {
override fun onRequestChildFocus(
parent: RecyclerView,
state: RecyclerView.State,
child: View,
focused: View?
): Boolean {
// 让系统先找,找不到再滚
if (!isViewPartiallyVisible(child, true, true)) {
val scroller = object : LinearSmoothScroller(parent.context) {
override fun calculateDxToMakeVisible(view: View, snapPreference: Int): Int {
return (getPaddingLeft() - view.left).coerceAtLeast(0)
}
}
scroller.targetPosition = getPosition(child)
startSmoothScroll(scroller)
}
return super.onRequestChildFocus(parent, state, child, focused)
}
override fun canScrollHorizontally() = true
}
在 Adapter 中:
override fun onViewAttachedToWindow(holder: VH) {
super.onViewAttachedToWindow(holder)
holder.itemView.isFocusable = true
holder.itemView.isFocusableInTouchMode = true
}
边界回弹:
rv.setOnKeyInterceptListener { _, keyCode, event ->
if (event.action == KeyEvent.ACTION_DOWN) {
when (keyCode) {
KeyEvent.KEYCODE_DPAD_RIGHT -> {
val last = layoutManager.findLastCompletelyVisibleItemPosition()
if (last == adapter.itemCount - 1) {
// 回弹动画或提示音
rv.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
true
} else false
}
else -> false
}
} else false
}
以上方案在小米电视、华为车载项目量产,通过 300 项 monkey 测试无焦点丢失,滚动平均帧率 58-60 fps。
拓展思考
-
折叠屏展开后列数变多,焦点从第 0 列跳到第 3 列,如何保持用户“视觉锚点”?
答:在 onConfigurationChanged 中记录当前焦点项的“相对列百分比”,重建后用 GridLayoutManager.SpanSizeLookup 重新计算同一百分比位置再 requestFocus。 -
国内 ROM 屏蔽 Google TV 的 Leanback,如何兼容华为鸿蒙“遥控器触控板”事件?
答:监听 MotionEvent.ACTION_SCROLL 模拟 DPAD,把 axisY 转为 DPAD_UP/DOWN,焦点逻辑复用同一套 FocusableLayoutManager。 -
焦点与弹幕/浮窗冲突:浮窗 TYPE_APPLICATION_OVERLAY 会抢走 WindowToken,导致 requestFocus 失败。
答:在 WindowManager.addView 时传入 FLAG_NOT_FOCUSABLE,焦点始终留在 RecyclerView;若业务必须交互,则临时把 RecyclerView 的 descendantFocusability 设为 FOCUS_BLOCK_DESCENDANTS,关闭后恢复。 -
性能极限:4K 仪表盘 12 列 * 2000 行,smoothScroll 掉帧。
答:启用 RecyclerView.setItemViewCacheSize(80) + prefetch,重写 setMaxRecycledViews 把 type 池加大;把 SmoothScroller 的 MILLISECONDS_PER_INCH 调小,降低滚动速度换取帧率;最后使用 RenderThread 异步渲染焦点高亮阴影,主线程仅画边框。
掌握以上,面试可横向对比“手机列表、TV 宫格、车载网格、折叠屏双窗”差异,给出量化指标,基本锁定 Offer。