Android RecyclerView 长列表焦点问题优化

前言

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

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

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

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

java 复制代码
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。
  • 焦点跨位:我们本想焦点连续移动,但是出现跨位就是一种不正常的逻辑
  • 焦点移出:这种问题比较多,一般和查找逻辑有关,需要做拦截和重新指定

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

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

全局焦点监听工具

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

java 复制代码
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,也是可以实现动态调整的,比如参考下面的方法实现,也能移动。 下面是垂直方法,其实水平方向替换表的方法调用即可。

偏移位置方法

java 复制代码
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已经有焦点了,这个其实是解决第一种问题的

java 复制代码
    @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个方法:

java 复制代码
calculateDxToMakeVisible(...)
calculateDyToMakeVisible(...)

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

java 复制代码
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滑动到指定的位置

java 复制代码
//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聚焦,这样也可以确保最终焦点政策。

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

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

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

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

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

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

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

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

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

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

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

使用方法

java 复制代码
mRecyclerView.scrollToPosition(position);
mRecyclerView.postDelat(new Runnable() {
    @Override
    public void run() {
        requestFocusOnPositionChild(position);
    }
},100);

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

java 复制代码
@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;
    }

}

我们优化下上面的逻辑

java 复制代码
// 重写此方法用于在数据加载完成时触发滚动到中部
@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 + CenterScrollGridLayoutManager 一起使用,scrollToPosition 能快速定位,CenterScrollGridLayoutManager 保证View在RecyclerView上时才能移动。当然,使用要调用scrollToPosition前,最好判断下View是不是在RecyclerView中,减少不必要的requestLayout.

总结

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

  • RecyclerView 不支持滑动到绝对位置
java 复制代码
@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

java 复制代码
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) {
        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 = 50f; //default is 25f (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;
        }
    }

}
相关推荐
好好好明天会更好10 分钟前
vue中的this.$nextTick如何使用
前端·vue.js
pengyu11 分钟前
【Kotlin系统化精讲:柒】 | 数据类型之复合及高级数据类型:构建复杂程序的万能钥匙
android·kotlin
我的div丢了肿么办12 分钟前
使用URLSearchParams 优雅的获取URL携带的参数
前端·javascript
XXXFIRE12 分钟前
微信小程序开发实战笔记:全流程梳理
前端·微信小程序
答案answer15 分钟前
回顾一下我的开源项目之路和Three.js 学习历程
前端·开源·three.js
ZoeLandia15 分钟前
nginx实战分析
运维·前端·nginx
张迅之啊15 分钟前
【React】MQTT + useEventBus 实现MQTT长连接以及消息分发
前端
入秋18 分钟前
【视觉震撼】我用Three.js让极光在网页里跳舞!
前端·three.js
盛夏绽放19 分钟前
Vue项目生产环境性能优化实战指南
前端·vue.js·性能优化
专注API从业者27 分钟前
Python/Node.js 调用taobao API:构建实时商品详情数据采集服务
大数据·前端·数据库·node.js