Android RecyclerView 长列表焦点问题优化

2,824 阅读13分钟

前言

Android系统属于双交互模式系统,任何一款设备都可以支持触屏模式(支持触点、鼠标)、焦点(支持TV)两种模式,而且这两种模式是可以自动切换的,在设备支持的情况下,手机如果能接受KeyEvent的方向事件,那手机是可以变成焦点模式的,同样反过来,如果TV支持触屏,在触发MotionEvent的情况下会自动切换为触屏模式。

可能你会有这样一个疑问,那为什么很多TV apk安装到手机上或者手机apk安装到TV上不好用,实际apk在开发初期就设定了基于哪种模式,后期都是以特定模式去运行的,即便接收到KeyEvent或者MotionEvent的切换,也只会影响 到影响android.view.View#isInTouchMode 交互的问题。

造成这种问题的主要原因是,实际项目中,自动模切换模式适配需要比较大的工作量,因此很多app是不允许自动切换的,只允许静态切换,也就是触屏模式和焦点模式自app启动之后就不允许切换,防止引发各种交互和展示问题。

下面是自动切换的核心方法。 android.view.ViewRootImpl#ensureTouchModeLocally

fire_93.gif


private boolean ensureTouchModeLocally(boolean inTouchMode) {
    if (DBG) Log.d("touchmode", "ensureTouchModeLocally(" + inTouchMode + "), current "
            + "touch mode is " + mAttachInfo.mInTouchMode);

    if (mAttachInfo.mInTouchMode == inTouchMode) return false;

    mAttachInfo.mInTouchMode = inTouchMode;
    mAttachInfo.mTreeObserver.dispatchOnTouchModeChanged(inTouchMode);

    return (inTouchMode) ? enterTouchMode() : leaveTouchMode();
}

Android中提供了两种触发ViewRootImpl#ensureTouchModeLocally 的方法:

  • KeyEvent时切换为焦点模式,MotionEvent时切换为触屏模式
  • 使用Window.setLocalFocus(hasFocus,isTouchMode)

焦点监控

常见的焦点问题

  • 焦点丢失:焦点丢失是很可怕的问题,其实这里的丢失并不是说焦点没了,而是出现在了不符合视觉要素的View上了,比如被隐藏的View、0像素的View、无需聚焦的View上,轻则上下左右总能按出来,重则焦点无法移动,导致页面假死。
  • 焦点连跳:开发过程中经常期待焦点一步到位,但是在一些互相关联的View中会出现连跳现象,对于不可滑动的View问题可能不太严重,但是对于要滑动的View可能引起页面抖动。当然连跳未必也不是正确的,比如FOCUS_BEFORE_DESCENDANTS用于父View,进行二次定焦到指定View。
  • 焦点跨位:我们本想焦点连续移动,但是出现跨位就是一种不正常的逻辑
  • 焦点移出:这种问题比较多,一般和查找逻辑有关,需要做拦截和重新指定

这里简单总结下焦点查找规则

 *默认焦点查找规则
 *【1】获得焦点的View的祖先点开始搜索
 *【2】符合enable,visible,focusable是获得焦点的最基本的条件
 *【3】targetSDK >= android P时,0像素View无法聚焦
 *【4】正在layout的布局无法聚焦
 *【5】父view 设置了FOCUS_BLOCK_DESCENDANTS ,View无法获取焦点
 * 

全局焦点监听工具

跟踪将非常困难,因为单个View只能监控自身范围内的焦点变化,所以,焦点模式的UI开发显然需要全局监听。Android 系统提供了全局焦点监听,方便我们处理问题。

ViewTreeObserver.OnGlobalFocusChangeListener

焦点模式难点

每一种模式相当于一个app的,按业务分的话Android中目前提供了触屏和焦点模式,但是如果再加一个双屏异显,那工作量就得x2,那就是4倍的工作量,也是很多开发团队所面临的问题。另一个问题,即便我们不考虑这个问题,其实TV开发相比手机app开发难度也是要高一些的,除了没有事件传递问题外,要处理的事件、滑动等问题不比MotionEvent简单,比如经常需要处理下面问题:

  • 嵌套滑动问题 : 焦点移动过程产生冲突
  • 焦点定向问题 : 焦点搜索的View和期望的View不一致
  • 焦点恢复机制 : Fragment与Activity中的View焦点恢复
  • 焦点状态分离问题:带状态的View不一定是聚焦的View,但是会叠加,焦点丢失后要变换状态
  • RecyclerView layout时焦点丢失问题:一般出现在焦点在最上和最下Item向两边滑动时出发了requestLayout,但是新的Item还没展示出来,焦点就丢失了。
  • RecyclerView 界面外Item焦点问题: 没有AttachToWindow的ItemView无法聚焦
  • 静态焦点问题: 这是一个比较有争议的方法,在xml中我们可以指定right focusId ,left Focus Id,但是造成的风险是,这些View一旦隐藏,也是可以获取焦点的,相比动态焦点,会排除不可见、不可点击的View,显然静态在可维护性上和使用上相对很差,应该避免使用。
  • 焦点拦截问题:一些情况下,希望焦点在View内部移动,这个时候要做专门的拦截,拦截的目的是你得指定焦点,但是这个时候你还得思考给哪个View合适。所以,目前来说手机app开发最简单的一种交互模式。

RecyclerView 焦点定位问题

前面说过两大问题:

  • RecyclerView layout时焦点丢失问题:一般出现在焦点在最上和最下Item向两边滑动时触发了requestLayout。
  • RecyclerView 界面外Item焦点问题: 没有AttachToWindow的ItemView无法聚焦。

对于第一个问题,有个形象的比喻:在危险的边缘试探。 其实解决方法也是具备共识的,那就是让获得焦点的View远离边缘。

当然,google 专门开发了的库 leanback,提供了VerticalGridView和HorizotalGridView来解决此问题,功能也比较全面,支持调整焦点View远离边缘的策略。

如果没有使用Leanback,也是可以实现动态调整的,比如参考下面的方法实现,也能移动。 下面是垂直方法,其实水平方向替换表的方法调用即可。

偏移位置方法

public void scrollChildToVisibleRange(RecyclerView rv, View v){
    if(!v.hasFocus()) {
        Log.w(TAG,"View v did not have focus");
        return;
    }

    final int index = rv.getChildAdapterPosition(v); //adapter pos
    if(index == RecyclerView.NO_POSITION) {
        Log.w(TAG,"Recycler view did not have view");
        return;
    }

    int position = rv.indexOfChild(v);  // layout pos
    int lastPos = rv.getChildCount();   // layout pos
    int threshold = 2;  //距离边缘的item间隔
    RecyclerView.LayoutManager manager = rv.getLayoutManager();
    Log.d(TAG, String.format("Position: %1$d. lastPos: %2$d. threshold: %3$d", position, lastPos, threshold));

    if (position >= (lastPos - threshold)) {
        /焦点/向上移动时,列表是向下滚动
        int bottomIndex = rv.getChildAdapterPosition(rv.getChildAt(lastPos));
        if (bottomIndex < manager.getItemCount()) {
            //scroll down
            int scrollBy = v.getHeight();
            rv.smoothScrollBy(0, scrollBy);
            Log.d(TAG, String.format("Scrolling down by %d", scrollBy));
        }

    } else if (position <= threshold) {
        //scroll up if possible
        int topIndex = rv.getChildAdapterPosition(rv.getChildAt(0));
        if (topIndex > 0) {
            //scroll up
            int scrollBy = v.getHeight();
            rv.smoothScrollBy(0,-scrollBy);
            Log.d(TAG, String.format("Scrolling up by %d", -scrollBy));
        }
    }
}

LayoutManager 通用方法

不过,相较LayoutManager的实现,上面的方法其实不够通用,在写本篇之前,本来想自定义一套的,发现已经有大佬实现过了《TV端开发之焦点控件垂直居中》,因此,我们看下核心实现即可。

关键方法 - 焦点改变时触发滚动

其实这个方法被调用表示子View已经有焦点了,这个其实是解决第一种问题的

    @Override
    public boolean onRequestChildFocus(RecyclerView parent, RecyclerView.State state,View child, View focused) {
        if (!isInLayout && !isSmoothScrolling()) {
            smoothScrollToCenterInternal(parent.getContext(),getPosition(child));
        }
        return true;
    }

我们知道,RecyclerView不支持scrollTo方法,因此需要在SmoothScroller中处理滚动或者scrollToPositon去调整。

这里计算滚动到中部的偏移量,这个方法属于LinearSmoothScroller,Scroller的扮演者辅助滑动的角色,当然,LayoutManager中的Scroller和View中常用的Scroller有很多区别,这里的辅助滑动比较核心的方法是下面2个方法:

calculateDxToMakeVisible(...)
calculateDyToMakeVisible(...)

主要为滑动位置提供参考。 这里的主要问题是不知道需要滑动多久以及要滑动多远,毕竟View不在RecyclerView中,因此,这里其实采用了渐进式计算,先让View滑动出来,在计算偏移位置。

RecyclerView.SmoothScroller#start
RecyclerView.SmoothScroller#onAnimation
RecyclerView.SmoothScroller#computeScrollVectorForPosition
RecyclerView#scrollStep
RecyclerView.ViewFlinger#run

...1-N次递进...

RecyclerView.SmoothScroller#onTargetFound
LinearSmoothScroller#calculateDxToMakeVisible(...)
LinearSmoothScroller#calculateDyToMakeVisible(...)
LinearSmoothScroller#calculateDtToFit
RecyclerView.SmoothScroller.Action#runIfNecessary
recyclerView.mViewFlinger.smoothScrollBy

... stop

最终确定会把View滑动到指定的位置

//androidx.recyclerview.widget.LinearSmoothScroller#calculateDtToFit

@Override
public int calculateDtToFit(int viewStart, int viewEnd, int boxStart, int boxEnd, int snapPreference) {
            return (boxStart + (boxEnd - boxStart) / 2) - (viewStart + (viewEnd - viewStart) / 2);
        }

但是问题是需要聚焦啊,这里我们可以监听onScrollStateChanged,在回调中让目标View聚焦,这样也可以确保最终焦点正确。

知识点补充(1)-滑动终点:

在调用smoothScrollToPosition 时,默认确定最终焦点是根据此方法确定,通过SNAP_TO_START、SNAP_TO_END、SNAP_TO_ANY确定

public int calculateDtToFit(int viewStart, int viewEnd, int boxStart, int boxEnd, int
        snapPreference) {
    switch (snapPreference) {
        case SNAP_TO_START:
            return boxStart - viewStart;
        case SNAP_TO_END:
            return boxEnd - viewEnd;
        case SNAP_TO_ANY:
            final int dtStart = boxStart - viewStart;
            if (dtStart > 0) {
                return dtStart;
            }
            final int dtEnd = boxEnd - viewEnd;
            if (dtEnd < 0) {
                return dtEnd;
            }
            break;
        default:
            throw new IllegalArgumentException("snap preference should be one of the"
                    + " constants defined in SmoothScroller, starting with SNAP_");
    }
    return 0;
}

但是,此变量的方向取决于速度的方向,我们知道,速度是矢量值,带方向便于物理计算

protected int getVerticalSnapPreference() {
    return mTargetVector == null || mTargetVector.x == 0 ? SNAP_TO_ANY :
            mTargetVector.x > 0 ? SNAP_TO_END : SNAP_TO_START;
}

如果我们设置总是置顶,那么返回SNAP_TO_START即可

protected int getVerticalSnapPreference() {
    return SNAP_TO_START;
}

补充知识点(2) - smoothScrollToPosition

实际上,通过自定义LayoutManager,其实是改写了RecyclerView#smoothScrollToPosition 方法,此方法和其他众多方法一样,可以抑制requestLayout,也就是说不会触发布局的measure和layout。

长列表问题 & 自动分页问题

SmoothScroller这种方式是渐进式的,意味滑动时间存在不可控,这个时候当然是提高滑动速度,但是一些超长列表反而显得不合适了。另外在滑动过程中用户焦点移动到其他地方,那么onScrollStateChanged还需要做规避,控制逻辑显然还是比较复杂的,那有没有改进方法呢?

其实说到改进方法,最好能在产品上消除手机UI设计的思想,作为TV设备,焦点移动从上到下要连续点击按键多次,如果是1000个item的商品,且用户想买的在最底部,显然要点1000次左右才能移动到指定的位置。换句话说这种手机UI设计思想在TV上是不正常的,交互体验上甚至比不上一些网站后台的表格分页功能。

如何改善这个问题呢? 其实就是手动分页+网格展示,实际上手机上的触底分页在TV上反而影响焦点的移动,因此应该改成手动分页和网格展示,这方面比较做的好的就是几家主流的视频app了。当然,这里要遵循的原则如下:

  • TV上不太适合长列表
  • TV上不适合列表底部自动加载更多

既然不适合唱列表适合什么?

答案是: 网格 + 手动分页。

屏幕外View获取焦点流程改进

CenterScrollGridLayoutManager 其实并不适合长列表,尤其是从0 - 10000次的滚动,存在很多时间不可靠性。还有个比较简单的方法,利用scrollToPosition + 延迟聚焦。在RecyclerView中,scrollToPosition是不会滚动的,而是调用requestLayout重新布局,将目标View直接布局在上面。

@Override
public void scrollToPosition(int position) {
    mPendingScrollPosition = position;
    mPendingScrollPositionOffset = INVALID_OFFSET;
    if (mPendingSavedState != null) {
        mPendingSavedState.invalidateAnchor();
    }
    requestLayout();
}

requestLayout 会直接触发onLayoutChildren,我们可以在布局完成之后再次获取焦点

使用方法

  mRecyclerView.scrollToPosition(position);
  mRecyclerView.postDelayed(new Runnable() {
  @Override
 public void run() {
       requestFocusOnPositionChild(position);
    }
 },300);

但是,CenterScrollGridLayoutManager 依然存在问题,因为scrollToPosition 会调用的requestLayout,将会以最快的速度让View处于顶部或者底部,但是CenterScrollGridLayoutManager中的实现onLayoutChildren在布局结束后主动滚动了,这个就造成了严重的不稳定性

@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    isInLayout = true;
    try {
        super.onLayoutChildren(recycler, state);
        View focusedChild = getFocusedChild();
        if (focusedChild != null &&  !isSmoothScrolling()) {
            if (getChildCount() - getPosition(focusedChild) >= getSpanCount()) {
                smoothScrollToCenterInternal(focusedChild.getContext(),getPosition(focusedChild));
            }
        }
    } catch (IndexOutOfBoundsException ignored) {

    }finally {
        isInLayout = false;
    }

}

我们优化下上面的逻辑

// 重写此方法用于在数据加载完成时触发滚动到中部
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    try {
        isInLayout = true;
        super.onLayoutChildren(recycler, state);
        //这里布局完成时移除了滚动代码,因为与scrollToPosition 冲突
    } catch (IndexOutOfBoundsException ignored) {
        ignored.printStackTrace();
    }finally {
        isInLayout = false;
    }

}

要点

以上的改动都是基于对scrollToPosition 和 smoothScrollToPosition实现的改造,各有各的优点,也有自身的缺点。

集合以上的优化点,推荐scrollToPosition + CenterScrollGridLayoutManager 一起使用,scrollToPosition 能快速定位,CenterScrollGridLayoutManager 保证View在RecyclerView上时才能移动。当然,使用要调用scrollToPosition前,最好判断下View是不是在RecyclerView中,减少不必要的requestLayout.

总结

本篇主要是处理焦点模式下RecyclerView焦点定位问题,实际上本篇的方法也是可以使用到触屏模式的,为什么这么说呢?

  • RecyclerView 不支持滑动到绝对位置
@Override
public void scrollTo(int x, int y) {
    Log.w(TAG, "RecyclerView does not support scrolling to an absolute position. "
            + "Use scrollToPosition instead");
}
  • RecyclerView 滚动到特定Item有不确定性 RecyclerView 的scrollToPosition 方法调用时,如果View已经在页面上了,可能存在不再往中心滚动的情况,当然有可能在底部,有可能在顶部,存在位置不确定性。

改造后的代码

CenterScrollGridLayoutManager

public class CenterScrollGridLayoutManager extends GridLayoutManager {
    private static final String TAG = "CenterScrollGridLayoutManager";
    private boolean isInLayout;
    public CenterScrollGridLayoutManager(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
    }
    public CenterScrollGridLayoutManager(Context context, int spanCount) {
        super(context, spanCount);
    }
    public CenterScrollGridLayoutManager(Context context, int spanCount, int orientation, boolean reverseLayout) {
        super(context, spanCount, orientation, reverseLayout);
    }
    private void smoothScrollToCenterInternal(Context context, int position) {
        if(position < 0) return;
        RecyclerView.SmoothScroller smoothScroller = new CenterScroller(context);
        smoothScroller.setTargetPosition(position);
        startSmoothScroll(smoothScroller);
    }

    @Override
    public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state, int position) {
        smoothScrollToCenterInternal(recyclerView.getContext(),position);
    }

    // 关键方法 - 焦点改变时触发滚动
    @Override
    public boolean onRequestChildFocus(RecyclerView parent, RecyclerView.State state,View child, View focused) {
        if (!isInLayout && !isSmoothScrolling()) {
            smoothScrollToCenterInternal(parent.getContext(),getPosition(child));
        }
        return true;
    }

    // 重写此方法用于在数据加载完成时触发滚动到中部
    @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        try {
            isInLayout = true;
            super.onLayoutChildren(recycler, state);
            //这里布局完成时移除了滚动代码,因为与scrollToPosition 冲突,当然几乎和所有的能触发requestLayout的方法冲突
        } catch (IndexOutOfBoundsException ignored) {
            ignored.printStackTrace();
        }finally {
            isInLayout = false;
        }

    }
    // 自定义滚动效果的Scroller
    private class CenterScroller extends LinearSmoothScroller {

        private static final float MILLISECONDS_PER_INCH = 250f; //default is 250f (bigger = slower)

        public CenterScroller(Context context) {
            super(context);
        }
        // 这里计算滚动到中部的偏移量
        @Override
        public int calculateDtToFit(int viewStart, int viewEnd, int boxStart, int boxEnd, int snapPreference) {
            return (boxStart + (boxEnd - boxStart) / 2) - (viewStart + (viewEnd - viewStart) / 2);
        }
        // 滚动速度控制
        @Override
        protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
            return MILLISECONDS_PER_INCH / displayMetrics.densityDpi;
        }
    }

}

为了更好的配合业务使用,我们封装一下方法


public void setFocusedPosition(int position) {
    if (layoutManager == null || position < 0) {
       return;
    }
    if(requestFocusOnPositionChild(position)){
        return;
    }
     if(mPlayAdapter.getItemCount() > position){
         mRecyclerView.scrollToPosition(position);
         mRecyclerView.postDelayed(new Runnable() {
             @Override
             public void run() {
                 requestFocusOnPositionChild(position);
             }
         },300);

    }
}

private boolean requestFocusOnPositionChild(int position) {
    int realPosition = mListAdapter.getRealPosition(position);  
    //这里如果没有映射的话,那么position 应该和realPosition 是一样的
    View view = mLayoutManager.findViewByPosition(realPosition);
    if (view != null && view.getWindowId() != null) {
        view.requestFocus();
        return true;
    }
    return  false;
}

后续调用

setFocusedPosition(int position)

另一种可行的方法

AndroidX 种提供了 LinearLayoutManager#scrollToPositionWithOffset方法,此方法可以一步到位的将View移动到指定的位置,但是需要计算offset。

当然,如果是顶部和底部,完全不需要计算,传入0或者recyclerViewHeight - itemHeight 即可,这样View一定是可以完全展现的,至于如果要居中,还需要一些计算。

public void scrollToPositionWithOffset(int position, int offset) {
    mPendingScrollPosition = position;
    mPendingScrollPositionOffset = offset;
    if (mPendingSavedState != null) {
        mPendingSavedState.invalidateAnchor();
    }
    requestLayout();
}