前言
Android系统属于双交互模式系统,任何一款设备都可以支持触屏模式(支持触点、鼠标)、焦点(支持TV)两种模式,而且这两种模式是可以自动切换的,在设备支持的情况下,手机如果能接受KeyEvent的方向事件,那手机是可以变成焦点模式的,同样反过来,如果TV支持触屏,在触发MotionEvent的情况下会自动切换为触屏模式。
可能你会有这样一个疑问,那为什么很多TV apk安装到手机上或者手机apk安装到TV上不好用,实际apk在开发初期就设定了基于哪种模式,后期都是以特定模式去运行的,即便接收到KeyEvent或者MotionEvent的切换,也只会影响 到影响android.view.View#isInTouchMode 交互的问题。
造成这种问题的主要原因是,实际项目中,自动模切换模式适配需要比较大的工作量,因此很多app是不允许自动切换的,只允许静态切换,也就是触屏模式和焦点模式自app启动之后就不允许切换,防止引发各种交互和展示问题。
下面是自动切换的核心方法。 android.view.ViewRootImpl#ensureTouchModeLocally
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();
}