前言

在移动设备(如手机、平板)上,用户主要通过触摸屏与应用交互,手指点击哪里,焦点就在哪里。但在电视这种大屏、远距离交互的场景下,触摸不再适用,用户主要使用遥控器(方向键、确认键、返回键等)进行导航。
实际上,这种交互通过KeyEvent,而TV的焦点和KeyEvent有着非常紧密的关系,通常意义上,KeyEvent触发Focus的变化。在Android 应用开发中,焦点+KeyEvent模式的框架下,交互难度其实要超过触摸事件。
常见问题
不过,在开发过程中,最让人头疼的"很难"控制走向,不过好消息是,Android官方推出的Compose UI在这方面进步很多,使得焦点转移目标更加明确,焦点问题要比传统View开发的少很多。
作为"远古UI的app"的开发者,我们自然无法立刻转向Compose UI开发,这也是行业现状了吧。好了,进入主题,我们今天重点总结一下焦点的相关问题。
焦点分配
谈到焦点分配,我们首先要了解下KeyEvent的事件传递,其实和触摸屏相比,KeyEvent传递要相对简单一点,KeyEvent的主要传递方向是向"带焦点的View"传递,基本不存在分发逻辑,当然,【上】【下】【左】【右】方向键是除外的。
事件类型总结
- 普通事件: 直接顺着"焦点链"向带焦点的View传递
- 点击事件: 快速点击【确认】【回车】【Center】就会将事件转为Touch事件,最终触发点击事件,注意,如果是其他按键,是不会转化为Touch事件的
- 长按事件:长按【确认】【回车】【Center】就会将事件转为Touch事件,最终触发长按事件,注意,如果是其他按键,会触发多次连续点击,并不存在长按事件
- 方向按键:带方向的按键被点击后,首先顺着"焦点链"向带焦点的View,如果没有处理,则进入分发流程,除此之外,其他按键没有分发流程的。
焦点链
好吧,上面的描述中,有个【焦点链】,这是我个人的叫法,我确实不知道别人会怎么起名字,那么什么是焦点链呢?
首先,我们知道,在一个UI界面中,有且仅有一个View会有焦点,但是,按照传统的想法,让所有View去findFocus查询这个焦点的时候,要么是遍历View树,但Android是并没有,毕竟View增多会显著提升查询时间。
那么,可能有人会想,保存成单例不好么,但出于MemoryLeak的考虑,官方并不认可这种做法,反而选择了另一种做法,那就是【焦点链】。
原理是在每一个父布局中,定义一个mFocused变量,保存下一级路径View节点,最终,当某View聚焦时,就会形成下面的链条
mFocused->mFocused->mFocused->mFocused
在焦点链中,只有末端的mFocused才具备焦点,通过这种链式的链接,有效减少了查询时间,也能完美的避免MemoryLeak
下面,在回头看ViewGroup的源码,你是不是就能想得通KeyEvent的事件传递方式了
java
@Override
public boolean dispatchKeyEvent(KeyEvent event) {
if (mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onKeyEvent(event, 1);
}
if ((mPrivateFlags & (PFLAG_FOCUSED | PFLAG_HAS_BOUNDS))
== (PFLAG_FOCUSED | PFLAG_HAS_BOUNDS)) {
if (super.dispatchKeyEvent(event)) {
return true;
}
} else if (mFocused != null && (mFocused.mPrivateFlags & PFLAG_HAS_BOUNDS)
== PFLAG_HAS_BOUNDS) {
if (mFocused.dispatchKeyEvent(event)) {
return true;
}
}
if (mInputEventConsistencyVerifier != null) {
mInputEventConsistencyVerifier.onUnhandledEvent(event, 1);
}
return false;
}
本篇我们重点是方向按键的处理。
ViewRootImpl KeyEvent事件
与触摸事件不同的是,KeyEvent的方向按键可能需要两次受到ViewRootImpl的控制,第二次依赖第一次的处理逻辑,如果第一次没有处理,则由ViewRootImpl发起焦点查询。
第一次,和普通的事件一样,顺着焦点链向带焦点的传递 第二次,如果第一次没有方向键处理ACTION_DOWN事件则发起焦点搜索(方向键以外的其他事件丢弃或者继续关注ACTION_UP的回调)
这部分的具体逻辑参考ViewRootImpl相关逻辑
java
android.view.ViewRootImpl.ViewPostImeInputStage#processKeyEvent
下面我们我们了解下焦点的分发
焦点分发
实际上,与其说是焦点的分发,不如说是方向按键的分发,调度逻辑参考 ViewRootImpl源码
在这部分源码中,我们可以注意到很多焦点事件的转化,另外,我们可以看到,如果没有DecorView聚焦时,DecorView默认会调用restoreDefaultFocus给自身焦点。
java
private boolean performFocusNavigation(KeyEvent event) {
int direction = 0;
switch (event.getKeyCode()) {
case KeyEvent.KEYCODE_DPAD_LEFT:
if (event.hasNoModifiers()) {
direction = View.FOCUS_LEFT;
}
break;
case KeyEvent.KEYCODE_DPAD_RIGHT:
if (event.hasNoModifiers()) {
direction = View.FOCUS_RIGHT;
}
break;
case KeyEvent.KEYCODE_DPAD_UP:
if (event.hasNoModifiers()) {
direction = View.FOCUS_UP;
}
break;
case KeyEvent.KEYCODE_DPAD_DOWN:
if (event.hasNoModifiers()) {
direction = View.FOCUS_DOWN;
}
break;
case KeyEvent.KEYCODE_TAB:
if (event.hasNoModifiers()) {
direction = View.FOCUS_FORWARD;
} else if (event.hasModifiers(KeyEvent.META_SHIFT_ON)) {
direction = View.FOCUS_BACKWARD;
}
break;
}
if (direction != 0) {
View focused = mView.findFocus();
if (focused != null) {
View v = focused.focusSearch(direction);
if (v != null && v != focused) {
// do the math the get the interesting rect
// of previous focused into the coord system of
// newly focused view
focused.getFocusedRect(mTempRect);
if (mView instanceof ViewGroup) {
((ViewGroup) mView).offsetDescendantRectToMyCoords(
focused, mTempRect);
((ViewGroup) mView).offsetRectIntoDescendantCoords(
v, mTempRect);
}
if (v.requestFocus(direction, mTempRect)) {
playSoundEffect(SoundEffectConstants
.getContantForFocusDirection(direction));
return true;
}
}
// Give the focused view a last chance to handle the dpad key.
if (mView.dispatchUnhandledMove(focused, direction)) {
return true;
}
} else {
if (mView.restoreDefaultFocus()) {
return true;
}
}
}
return false;
}
这里我们要清楚的点是,任何事件,dispatchKeyEvent是必须要调用的,而且在View获焦之前会调用,似乎这里可以拦截焦点,其实这样想也没有问题。
但是,我这里要说的是,focusSearch其实要比dispatchKeyEvent好的多
我们知道,dispatchKeyEvent未处理的方向事件才会给focusSearch,但是,很多事件的处理中,假设我们拦截dispatchKeyEvent,可能造成很多意想不到的问题,另外,如果焦点链层级太深,一些顶部View的dispatchKeyEvent并不能明确知道具体由哪个View向其他View转换,以及焦点父View的逻辑,贸然处理,反而增加了复杂性,相比而言focusSearch跟接近子View本身。
java
@Override
public View focusSearch(View focused, int direction) {
if (isRootNamespace()) {
// root namespace means we should consider ourselves the top of the
// tree for focus searching; otherwise we could be focus searching
// into other tabs. see LocalActivityManager and TabHost for more info.
return FocusFinder.getInstance().findNextFocus(this, focused, direction);
} else if (mParent != null) {
return mParent.focusSearch(focused, direction);
}
return null;
}
因此,我们在focusSearch这里拦截和分发焦点应该作为首选。
下面是一直个转移方式
java
@Override
public View focusSearch(int direction) {
if(this.focusSearchListener != null){
View current = findFocus();
View view = this.focusSearchListener.onFocusSearch(current, direction);
if(view != null){
return view;
}
}
return super.focusSearch(direction);
}
RecyclerView焦点
ReyclerView是难度更高的焦点处理组件之一,在之前的文章中,我们说过,TV模式应该有限使用短列表,避免长列表,但实际操作中,一些条目也是非常多,需要按着遥控器不断调整方向和位置,增加了用户的工作量。
当然,这里我们实际开发中,主要需要处理RecyclerView Item丢焦问题
首先,我可能肯定要防抖,就是避免事件方向事件触发过快。 除此之外,我们需要焦点View以及相邻的View尽可能漏出,当然,最终需要定义LayoutManager
下面我们总结了两种两种常见的场景
聚焦ItemView居中
这部分相对简单,主要是将聚焦的View移动到RecyclerView中间位置,具体代码如下
java
public class CenterLayoutManager extends LinearLayoutManager {
// 监听器接口,用于在 Item 居中时进行额外操作 (可选)
public interface OnCenterItemFocusListener {
void onFocus(int position);
}
private RecyclerView recyclerView;
private OnCenterItemFocusListener onCenterItemFocusListener;
// 防止递归调用的保护标志
private volatile boolean isScrollingToCenter = false;
private static final String TAG = "CenterLayoutManager";
// 【关键修改 1】将监听器作为成员变量,以便在生命周期方法中引用和移除
private final RecyclerView.OnScrollListener scrollStateListener;
public CenterLayoutManager(Context context, int orientation, boolean reverseLayout) {
super(context, orientation, reverseLayout);
// 初始化滚动状态监听器
scrollStateListener = new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
// 仅在滚动静止时 (IDLE) 检查是否需要重置标志
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
if (isScrollingToCenter) {
Logger.d(TAG, "Scroll finished. Resetting isScrollingToCenter to false.");
isScrollingToCenter = false;
}
}
}
};
}
// 捕捉对 RecyclerView 的引用,并注册滚动监听器来管理状态
@Override
public void onAttachedToWindow(RecyclerView view) {
super.onAttachedToWindow(view);
this.recyclerView = view;
// 注册滚动监听器
view.addOnScrollListener(scrollStateListener);
}
// 【关键修改 2】在从窗口分离时移除监听器并清除引用,防止内存泄漏
@Override
public void onDetachedFromWindow(RecyclerView recyclerView, RecyclerView.Recycler recycler) {
super.onDetachedFromWindow(recyclerView, recycler);
if (this.recyclerView != null) {
// 移除监听器以防止内存泄漏
this.recyclerView.removeOnScrollListener(scrollStateListener);
// 清除对 RecyclerView 的引用
this.recyclerView = null;
}
}
// 设置外部监听器
public void setOnCenterItemFocusListener(OnCenterItemFocusListener listener) {
this.onCenterItemFocusListener = listener;
}
/**
* 重写 smoothScrollToPosition,用于 D-Pad 导航时的系统自动滚动。
*/
@Override
public void smoothScrollToPosition(RecyclerView recyclerView, @NonNull RecyclerView.State state, int position) {
CenterSmoothScroller smoothScroller = new CenterSmoothScroller(recyclerView.getContext());
smoothScroller.setTargetPosition(position);
startSmoothScroll(smoothScroller);
}
/**
* 在布局完成后,为所有可见 Item 设置包装器焦点监听器。
*/
@Override
public void onLayoutCompleted(RecyclerView.State state) {
super.onLayoutCompleted(state);
// 为所有可见 Item 设置焦点监听器
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
if (child == null) continue;
View.OnFocusChangeListener existingListener = child.getOnFocusChangeListener();
// 检查 Item 上当前的监听器是否已经是我们的包装器
if (!(existingListener instanceof WrappingFocusChangeListener)) {
// 如果不是我们的包装器,则创建新的包装器并设置
WrappingFocusChangeListener wrapper = new WrappingFocusChangeListener(this);
// 存储 Item 上可能已有的监听器 (通常由 Adapter 设置),使其不失效
wrapper.setOriginalListener(existingListener);
child.setOnFocusChangeListener(wrapper);
}
// 首次布局完成时,如果 Item 已经有焦点,也将其平滑滚动到中心
if (child.hasFocus()) {
centerView(child, true);
}
}
}
/**
* 包装器焦点监听器。
* 作用是先执行居中逻辑,然后调用原始可能存在的监听器。
*/
private static class WrappingFocusChangeListener implements View.OnFocusChangeListener {
private final CenterLayoutManager layoutManager;
private View.OnFocusChangeListener originalListener;
public WrappingFocusChangeListener(CenterLayoutManager lm) {
this.layoutManager = lm;
}
public void setOriginalListener(View.OnFocusChangeListener original) {
this.originalListener = original;
}
@Override
public void onFocusChange(View view, boolean hasFocus) {
if (hasFocus) {
// 【递归阻断】如果正在执行滚动到中心的操作,则跳过再次调用 centerView
if (layoutManager.isScrollingToCenter) {
Logger.d(TAG, "Focus change ignored due to active scrolling.");
// 允许原始监听器继续执行,然后退出
if (originalListener != null) {
originalListener.onFocusChange(view, hasFocus);
}
return;
}
// 1. 执行居中逻辑 (使用平滑滚动)
layoutManager.centerView(view, true);
// 2. 通知 LayoutManager 外部监听器 (如果存在)
int position = layoutManager.getPosition(view);
if (position != RecyclerView.NO_POSITION && layoutManager.onCenterItemFocusListener != null) {
layoutManager.onCenterItemFocusListener.onFocus(position);
}
}
// 3. 调用原始监听器 (如果存在,无论是否居中,都会调用)
if (originalListener != null) {
originalListener.onFocusChange(view, hasFocus);
}
}
}
/**
* 将给定的 View (Item) 滚动到 RecyclerView 的中心。
* @param child 获得焦点的 Item View
* @param smooth 是否平滑滚动
*/
private void centerView(View child, boolean smooth) {
if (recyclerView == null) return;
int delta = calculateScrollDelta(child);
// 如果 delta 为 0,说明 View 已经居中,无需滚动
if (delta == 0) return;
// 在发起滚动之前,设置保护标志
isScrollingToCenter = true;
Logger.d(TAG, "Starting scroll, setting isScrollingToCenter to true. Delta: " + delta);
if (smooth) {
// 平滑滚动:依赖 OnScrollListener 来重置 isScrollingToCenter
if (getOrientation() == HORIZONTAL) {
recyclerView.smoothScrollBy(delta, 0);
} else {
recyclerView.smoothScrollBy(0, delta);
}
} else {
// 立即滚动:同步操作,可以立即重置标志
if (getOrientation() == HORIZONTAL) {
recyclerView.scrollBy(delta, 0);
} else {
recyclerView.scrollBy(0, delta);
}
isScrollingToCenter = false;
}
}
/**
* 计算要将 View 滚动到中心所需的距离 (Delta)。
*/
private int calculateScrollDelta(View child) {
if (getOrientation() == HORIZONTAL) {
// 水平居中滚动距离 = Item 左侧位置 - (RecyclerView 宽度 - Item 宽度) / 2
return child.getLeft() - (getWidth() - child.getWidth()) / 2;
} else {
// 垂直居中滚动距离 = Item 顶部位置 - (RecyclerView 高度 - Item 高度) / 2
return child.getTop() - (getHeight() - child.getHeight()) / 2;
}
}
/**
* 自定义的平滑滚动器,用于确保滚动目标是中心位置 (用于D-Pad导航)。
*/
private class CenterSmoothScroller extends LinearSmoothScroller {
public CenterSmoothScroller(Context context) {
super(context);
}
// 确定滚动速度
@Override
protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
// 返回一个较小的数值,实现更慢、更平滑的滚动效果
return 80f / displayMetrics.densityDpi;
}
// 计算目标 Item 需要停靠的位置
@Override
public int calculateDxToMakeVisible(View view, int snapPreference) {
// 强制返回滚动到中心位置的距离
return calculateScrollDelta(view);
}
@Override
public int calculateDyToMakeVisible(View view, int snapPreference) {
// 强制返回滚动到中心位置的距离
return calculateScrollDelta(view);
}
// 确定滚动结束时的位置
@Override
protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
int delta = calculateScrollDelta(targetView);
int dx = (getOrientation() == HORIZONTAL) ? delta : 0;
int dy = (getOrientation() == VERTICAL) ? delta : 0;
// 执行滚动,使用 DecelerateInterpolator 使其更平滑
int time = calculateTimeForDeceleration(delta);
action.update(dx, dy, time, new DecelerateInterpolator());
}
}
}
聚焦ItemView及相邻View露出
不可能所有的需求都会让你居中,另外一种需求是,聚焦的View尽可能向上移动,漏出相邻的View,那么,我们定义了下面的LayoutManager
java
/**
* EdgeVisibilityLayoutManager: 专注于让焦点 Item 完整可见,
* 且仅当焦点 Item 是可见区域的第一个或最后一个时,确保其相邻 Item 完整露出。
* * 焦点监听器直接在 onLayoutCompleted 中设置。
*/
public class EdgeVisibilityLayoutManager extends LinearLayoutManager {
private volatile boolean isAdjustingScroll = false;
private static final String TAG = "EdgeVLM_InternalFocus";
private RecyclerView recyclerView;
private final RecyclerView.OnScrollListener scrollStateListener;
public EdgeVisibilityLayoutManager(Context context, int orientation, boolean reverseLayout) {
super(context, orientation, reverseLayout);
scrollStateListener = new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(@NonNull RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
if (isAdjustingScroll) {
Log.d(TAG, "Scroll adjustment finished. Resetting isAdjustingScroll to false.");
isAdjustingScroll = false;
}
}
}
};
}
@Override
public void onAttachedToWindow(RecyclerView view) {
super.onAttachedToWindow(view);
this.recyclerView = view;
view.addOnScrollListener(scrollStateListener);
}
@Override
public void onDetachedFromWindow(RecyclerView recyclerView, RecyclerView.Recycler recycler) {
super.onDetachedFromWindow(recyclerView, recycler);
if (this.recyclerView != null) {
this.recyclerView.removeOnScrollListener(scrollStateListener);
this.recyclerView = null;
}
}
// ------------------ 核心修改:在 LayoutManager 中设置监听器 ------------------
@Override
public void onLayoutCompleted(RecyclerView.State state) {
super.onLayoutCompleted(state);
// 遍历所有可见 Item,设置并包装监听器
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
if (child == null) continue;
View.OnFocusChangeListener existingListener = child.getOnFocusChangeListener();
// 仅当 Item View 的监听器不是我们自己的包装器时,才进行设置/包装
if (!(existingListener instanceof WrappingFocusChangeListener)) {
WrappingFocusChangeListener wrapper = new WrappingFocusChangeListener(this);
wrapper.setOriginalListener(existingListener);
child.setOnFocusChangeListener(wrapper);
Log.d(TAG, "Attached WrappingFocusChangeListener to position: " + getPosition(child));
}
// 如果 Layout 完成时 Item 已经有焦点,触发一次滚动调整
if (child.hasFocus() && !isAdjustingScroll) {
adjustScrollForVisibility(child, true);
}
}
}
// --------------------------------------------------------------------------
/**
* 包装器:用于包装原始的 OnFocusChangeListener,并在焦点获得时触发滚动调整。
*/
public static class WrappingFocusChangeListener implements View.OnFocusChangeListener {
private final EdgeVisibilityLayoutManager layoutManager;
private View.OnFocusChangeListener originalListener;
public WrappingFocusChangeListener(EdgeVisibilityLayoutManager lm) {
this.layoutManager = lm;
}
public void setOriginalListener(View.OnFocusChangeListener original) {
this.originalListener = original;
}
@Override
public void onFocusChange(View view, boolean hasFocus) {
if (hasFocus) {
if (layoutManager.isAdjustingScroll) {
Log.d(TAG, "Skipping scroll adjustment due to isAdjustingScroll=true.");
if (originalListener != null) {
originalListener.onFocusChange(view, hasFocus);
}
return;
}
layoutManager.adjustScrollForVisibility(view, true);
}
if (originalListener != null) {
originalListener.onFocusChange(view, hasFocus);
}
}
}
private void adjustScrollForVisibility(View child, boolean smooth) {
if (recyclerView == null) return;
int delta = calculateScrollDelta(child);
if (delta == 0) return;
isAdjustingScroll = true;
Log.d(TAG, "Starting scroll adjustment, setting isAdjustingScroll to true. Delta: " + delta);
if (smooth) {
if (getOrientation() == HORIZONTAL) {
recyclerView.smoothScrollBy(delta, 0);
} else {
recyclerView.smoothScrollBy(0, delta);
}
} else {
if (getOrientation() == HORIZONTAL) {
recyclerView.scrollBy(delta, 0);
} else {
recyclerView.scrollBy(0, delta);
}
isAdjustingScroll = false;
}
}
/**
* 计算将 View 滚动到边缘并露出相邻一个 Item 所需的距离 (Delta)。
*/
private int calculateScrollDelta(View focusedChild) {
int focusedPosition = getPosition(focusedChild);
if (focusedPosition == RecyclerView.NO_POSITION) return 0;
int delta = 0;
// 获取当前可见区域的 Item 位置,用于判断是否在边缘
int firstVisiblePos = findFirstVisibleItemPosition();
int lastVisiblePos = findLastVisibleItemPosition();
if (getOrientation() == HORIZONTAL) {
int parentLeft = getPaddingLeft();
int parentRight = getWidth() - getPaddingRight();
int decoratedMeasuredWidth = getDecoratedMeasuredWidth(focusedChild);
// ------------------ 焦点 Item 在左侧被裁剪 (需要向左滚动) ------------------
if (getDecoratedLeft(focusedChild) <= (decoratedMeasuredWidth + parentLeft)) {
// 如果当前焦点就是可见的第一个 Item,且不是第一项,则目标是让前一个 Item 完整可见
if (focusedPosition > 0) {
View previousView = findViewByPosition(focusedPosition - 1);
if (previousView != null) {
// 目标:将前一个 View 的左边缘对齐到父容器的左边缘
delta = getDecoratedLeft(previousView) - parentLeft;
Log.d(TAG, "H Scroll Left: Target previous view. Delta: " + delta);
} else {
// 如果前一个 View 尚未被 Layout,则只保证当前 View 可见
delta = getDecoratedLeft(focusedChild) - parentLeft - decoratedMeasuredWidth;
}
} else {
// 保证当前 View 的左边缘对齐到父容器的左边缘
delta = getDecoratedLeft(focusedChild) - parentLeft;
}
}
// ------------------ 焦点 Item 在右侧被裁剪 (需要向右滚动) ------------------
else if (getDecoratedRight(focusedChild) >= (parentRight - decoratedMeasuredWidth)) {
// 如果当前焦点就是可见的最后一个 Item,且不是最后一项,则目标是让下一个 Item 完整可见
if (focusedPosition == lastVisiblePos && focusedPosition < getItemCount() - 1) {
View nextView = findViewByPosition(focusedPosition + 1);
if (nextView != null) {
// 目标:将下一个 View 的右边缘对齐到父容器的右边缘
delta = getDecoratedRight(nextView) - parentRight;
Log.d(TAG, "H Scroll Right: Target next view. Delta: " + delta);
} else {
// 如果下一个 View 尚未被 Layout,则只保证当前 View 可见
delta = getDecoratedRight(focusedChild) - parentRight + decoratedMeasuredWidth;
}
} else {
// 保证当前 View 的右边缘对齐到父容器的右边缘
delta = getDecoratedRight(focusedChild) - parentRight;
}
}
} else {
// --- 垂直方向 ---
int parentTop = getPaddingTop();
int parentBottom = getHeight() - getPaddingBottom();
int decoratedMeasuredHeight = getDecoratedMeasuredHeight(focusedChild);
// ------------------ 焦点 Item 在顶部被裁剪 (需要向上滚动) ------------------
if (getDecoratedTop(focusedChild) <= (decoratedMeasuredHeight + parentTop)) {
// 如果当前焦点就是可见的第一个 Item,且不是第一项,则目标是让前一个 Item 完整可见
if (focusedPosition > 0) {
View previousView = findViewByPosition(focusedPosition - 1);
if (previousView != null) {
// 目标:将前一个 View 的顶部对齐到父容器的顶部
delta = getDecoratedTop(previousView) - parentTop;
Log.d(TAG, "V Scroll Up: Target previous view. Delta: " + delta);
} else {
// 如果前一个 View 尚未被 Layout,则只保证当前 View 可见
delta = getDecoratedTop(focusedChild) - decoratedMeasuredHeight - parentTop;
}
} else {
// 保证当前 View 的顶部对齐到父容器的顶部
delta = getDecoratedTop(focusedChild) - parentTop;
}
// ------------------ 焦点 Item 在底部被裁剪 (需要向下滚动) ------------------
} else if (getDecoratedBottom(focusedChild) >= (parentBottom - decoratedMeasuredHeight)) {
// 如果当前焦点就是可见的最后一个 Item,且不是最后一项,则目标是让下一个 Item 完整可见
if (focusedPosition < getItemCount() - 1) {
View nextView = findViewByPosition(focusedPosition + 1);
if (nextView != null) {
// 目标:将下一个 View 的底部对齐到父容器的底部
delta = getDecoratedBottom(nextView) - parentBottom;
Log.d(TAG, "V Scroll Down: Target next view. Delta: " + delta);
} else {
// 如果下一个 View 尚未被 Layout,则只保证当前 View 可见
delta = getDecoratedBottom(focusedChild) - parentBottom + decoratedMeasuredHeight;
}
} else {
// 保证当前 View 的底部对齐到父容器的底部
delta = getDecoratedBottom(focusedChild) - parentBottom;
}
}
}
return delta;
}
@Override
public void smoothScrollToPosition(RecyclerView recyclerView, @NonNull RecyclerView.State state, int position) {
EdgeSmoothScroller smoothScroller = new EdgeSmoothScroller(recyclerView.getContext());
smoothScroller.setTargetPosition(position);
startSmoothScroll(smoothScroller);
}
private class EdgeSmoothScroller extends LinearSmoothScroller {
public EdgeSmoothScroller(Context context) {
super(context);
}
@Override
protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
return 100f / displayMetrics.densityDpi;
}
@Override
public int calculateDxToMakeVisible(View view, int snapPreference) {
return calculateScrollDelta(view);
}
@Override
public int calculateDyToMakeVisible(View view, int snapPreference) {
return calculateScrollDelta(view);
}
@Override
protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
int delta = calculateScrollDelta(targetView);
int dx = (getOrientation() == HORIZONTAL) ? delta : 0;
int dy = (getOrientation() == VERTICAL) ? delta : 0;
int time = calculateTimeForDeceleration(Math.abs(delta));
if (time > 0) {
action.update(dx, dy, time, new DecelerateInterpolator());
}
}
}
}
未展示ItemView聚焦
以上两个LayoutManager是为了解决丢焦问题,但还有个棘手的问题是,我们知道RecyclerView的上的View只是部分View,如何让指定位置的View聚焦呢。
这里我们之前也说过,自然是先滚动到具体位置,再聚焦,下面是我们的具体逻辑
java
public void requestFocusedChild(int position) {
Log.d(TAG,"requestFocus -> " + position);
if (mRecycleView == null || dataAdapter == null || mRecycleView.getLayoutManager() == null) {
return;
}
Log.d(TAG,"requestFocusOnPosition -> " + position);
if(requestFocusOnPosition(position)){
return;
}
if(dataAdapter.getItemCount() > position && position >= 0){
scrollToTopInstantly(mRecycleView,position);
mRecycleView.postDelayed(new Runnable() {
@Override
public void run() {
Log.d(TAG,"post requestFocus -> " + position);
requestFocusOnPosition(position);
}
},100);
}
}
private boolean requestFocusOnPosition(int position) {
if(mListView == null){
return false;
}
RecyclerView.LayoutManager layoutManager = mRecycleView.getLayoutManager();
if(layoutManager == null){
return false;
}
View view = layoutManager.findViewByPosition(position);
if (view != null && view.getWindowId() != null) {
view.requestFocus();
return true;
}
return false;
}
public static void scrollToTopInstantly(RecyclerView recyclerView, int position) {
RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
if (layoutManager instanceof LinearLayoutManager) {
LinearLayoutManager lm = (LinearLayoutManager) layoutManager;
// 使用 scrollToPositionWithOffset,将 offset 设置为 0,即可保证 Item 滚动到顶部
lm.scrollToPositionWithOffset(position, 0);
Log.d(TAG, "Instantly scrolled to position " + position + " at top with offset 0.");
} else {
// 对于非线性布局(如 StaggeredGridLayoutManager),可能需要其他策略。
Log.w(TAG, "LayoutManager is not LinearLayoutManager. Using basic scrollToPosition.");
recyclerView.scrollToPosition(position);
}
}
Z字形走焦
什么是走焦点呢,正常情况下,网格展示可能需要用到这种方法。
网格中按左或者右键,如果边缘View聚焦,按左键,可能无法聚焦到其他View。于是,需求侧希望从后到前或者从前到后依次遍历ItemView,也就是Z字形遍历。
那这种我们的办法当然是获取当前Item所在RecyclerView中可以聚焦的Item,强制设置焦点即可,重点是使用下面方法。
recyclerView.addFocusables(focusableViews, direction)
下面是源码
java
this.adapter!!.setBorderFocusListener { focused, direction ->
if(direction == FOCUS_LEFT || direction == FOCUS_RIGHT){
//找到下一个元素
val focusableViews = java.util.ArrayList<View>()
recyclerView.addFocusables(focusableViews, direction)
if (focusableViews.isEmpty()) {
return@setBorderFocusListener false;
}
// 焦点规则遵循Z字型原则
for (index in focusableViews.indices) {
val focusableView = focusableViews[index]
if (focusableView === focused) {
val nextFocusedViewIndex =
if (direction == FOCUS_LEFT) index - 1 else index + 1
if (nextFocusedViewIndex >= 0 && nextFocusedViewIndex < focusableViews.size) {
val nextFocusableView = focusableViews[nextFocusedViewIndex]
nextFocusableView.requestFocus()
return@setBorderFocusListener true
}
if(nextFocusedViewIndex < 0 || nextFocusedViewIndex >= adapter!!.itemCount){
return@setBorderFocusListener true
}
break
}
}
}
false
}
Scrollbar无法聚焦
ScrollView的ScrollBar如果设置Selector Drawable,在Android 6.0上无法聚焦后变色,这种情况其实是早期Android系统的问题,包括androidx的NestScrollView也并没有解决此问题。
那,这里我们的解决方法当然是自行绘制了
java
public class ScrollbarFixScrollView extends ScrollView {
private final Paint paint;
private final RectF rect = new RectF();
// 滚动条的画笔
private static final int SCROLLBAR_WIDTH_DP = 4;
// 滚动条的最小高度(防止内容过长时太小)
private static final int MIN_THUMB_HEIGHT_DP = 16;
// 滚动条的圆角半径
private static final float SCROLLBAR_RADIUS_DP = 2f;
// dp到px的转换系数
private float mDensity;
private FocusSearchListener focusSearchListener;
public ScrollbarFixScrollView(Context context) {
super(context);
}
public ScrollbarFixScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public ScrollbarFixScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
{
this.paint = new Paint();
// 初始化画笔:设置颜色和样式
this.paint.setAntiAlias(true); // 抗锯齿
this.paint.setStyle(Paint.Style.FILL);
this.mDensity = getContext().getResources().getDisplayMetrics().density;
if(Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
setHorizontalScrollBarEnabled(false);
setVerticalScrollBarEnabled(false);
refreshScrollbarState(false);
}
}
@Override
protected void onFocusChanged(boolean gainFocus, int direction, Rect previouslyFocusedRect) {
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect);
if(Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
refreshScrollbarState(gainFocus);
}
}
private void refreshScrollbarState(boolean gainFocus) {
if(gainFocus) {
paint.setColor(0xFFF04F43);
}else{
paint.setColor(0xFFBDBDBD);
}
}
@Override
protected void dispatchDraw(Canvas canvas) {
super.dispatchDraw(canvas);
if(Build.VERSION.SDK_INT > Build.VERSION_CODES.M) {
return;
}
if (!checkScrollViewCanScroll()) {
return;
}
// 如果ScrollView没有子View,或者内容不可滚动,则无需绘制
if (getChildCount() == 0) return;
// 获取内容 View
final View content = getChildAt(0);
// 视图高度(ScrollView的可视区域高度)
final int viewportHeight = getHeight();
// 内容总高度
final int contentHeight = content.getHeight();
// 当前滚动的Y轴偏移量
final int scrollY = getScrollY();
// 如果内容比可视区域小,不绘制滚动条
if (contentHeight <= viewportHeight) return;
// ------------------ 2. 滚动条高度计算 ------------------
// 滚动条宽度 (转换为像素)
final float thumbWidth = SCROLLBAR_WIDTH_DP * mDensity;
// 滚动条的理论比例高度
float thumbHeightRatio = (float) viewportHeight / contentHeight;
float thumbHeight = thumbHeightRatio * viewportHeight;
// 限制滚动条最小高度 (转换为像素)
final float minThumbHeight = MIN_THUMB_HEIGHT_DP * mDensity;
if (thumbHeight < minThumbHeight) {
thumbHeight = minThumbHeight;
}
final int maxScrollRange = contentHeight - viewportHeight;
final float maxThumbTravel = viewportHeight - thumbHeight;
final float scrollRatio = (float) scrollY / maxScrollRange;
final float thumbY = scrollRatio * maxThumbTravel;
final float rightX = getWidth();
final float leftX = rightX - thumbWidth;
rect.set(leftX, thumbY + scrollY, rightX, thumbY + scrollY + thumbHeight);
float radius = SCROLLBAR_RADIUS_DP * mDensity;
canvas.drawRoundRect(rect, radius, radius, paint);
}
private boolean checkScrollViewCanScroll() {
ScrollView contentScrollView = this;
View child = contentScrollView.getChildAt(0);
if (child != null) {
int childHeight = child.getHeight();
return contentScrollView.getHeight() < childHeight + contentScrollView.getPaddingTop() + contentScrollView.getPaddingBottom();
}
return false;
}
public void setFocusSearchListener(FocusSearchListener focusSearchListener) {
this.focusSearchListener = focusSearchListener;
}
@Override
public View focusSearch(int direction) {
if(this.focusSearchListener != null){
View current = findFocus();
View view = this.focusSearchListener.onFocusSearch(current, direction);
if(view != null){
return view;
}
}
return super.focusSearch(direction);
}
}
这里,我们总结了事件和焦点的分发传递,也总结了常见的问题,对于未展示View聚焦这部分,目前应该还有更好的方案,我们期待的是,希望能监听布局完成之后实现焦点,但目前从RecyclerView源码来看,难度其实很大。不过,总体而言post方式也并非不好。我们也会一直研究跟进,虽然目前够用,后续如果探索到新的方案,也会更新处理。
总结
本篇就到这里。
以上是本篇所有内容,本篇主要讨论的TV焦点的一些事情,特别是焦点的分发、拦截,以及RecyclerView一系列焦点问题和解决方法,希望对你有所帮助。