Android KeyEvent传递与焦点拦截

前言

在移动设备(如手机、平板)上,用户主要通过触摸屏与应用交互,手指点击哪里,焦点就在哪里。但在电视这种大屏、远距离交互的场景下,触摸不再适用,用户主要使用遥控器(方向键、确认键、返回键等)进行导航。

实际上,这种交互通过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一系列焦点问题和解决方法,希望对你有所帮助。

相关推荐
踢球的打工仔1 天前
typescript-引用和const常量
前端·javascript·typescript
OEC小胖胖1 天前
03|从 `ensureRootIsScheduled` 到 `commitRoot`:React 工作循环(WorkLoop)全景
前端·react.js·前端框架
时光少年1 天前
ExoPlayer MediaCodec视频解码Buffer模式GPU渲染加速
前端
hxjhnct1 天前
Vue 自定义滑块组件
前端·javascript·vue.js
华仔啊1 天前
JavaScript 中如何正确判断 null 和 undefined?
前端·javascript
weibkreuz1 天前
函数柯里化@11
前端·javascript·react.js
king王一帅1 天前
Incremark 0.3.0 发布:双引擎架构 + 完整插件生态,AI 流式渲染的终极方案
前端·人工智能·开源
转转技术团队1 天前
HLS 流媒体技术:畅享高清视频,忘却 MP4 卡顿的烦恼!
前端
程序员的程1 天前
我做了一个前端股票行情 SDK:stock-sdk(浏览器和 Node 都能跑)
前端·npm·github