上一篇SnapHelper使用及属性浅析 上主要记载了Snap的一些基础使用,基本工作流程,及滑动速度相关的计算。这一篇旨在补上扩展应用,及扩展时需要注意的一些事项。
LinearSnapHelper
也不多说,官方已经提供了两个扩展类LinearSnapHelper和PagerSnapHelper,这里拿一个出来分析分析,就知道怎么用了。
先看下大概有哪些方法(也可以看上一篇的类图):
先做好心理准备,从上图就可以发现,局面被LayoutManager和OrientationHelper把持着~~
依上一篇总结出的工作流程来依次看:
emmmm…好吧,这是忽略attachToRecyclerView的情况,还是先看一下attach吧,这里copy一下上一篇的内容:
attachToRecyclerView方法主要做了三件事:
- 通过setupCallbacks添加了OnFlingListener与OnScrollListener
- 创建了专属的Scroller
- 调用了snapToTargetExistingView
而snapToTargetExistingView方法中依次调用了findSnapView与calculateDistanceToFinalSnap。
…
1.findSnapView
没啥事先看看findSnapView吧:
//LinearSnapHepler.java
@Override
public View findSnapView(RecyclerView.LayoutManager layoutManager) {
if (layoutManager.canScrollVertically()) {
return findCenterView(layoutManager, getVerticalHelper(layoutManager));
} else if (layoutManager.canScrollHorizontally()) {
return findCenterView(layoutManager, getHorizontalHelper(layoutManager));
}
return null;
}
@NonNull
private OrientationHelper getHorizontalHelper(
@NonNull RecyclerView.LayoutManager layoutManager) {
if (mHorizontalHelper == null || mHorizontalHelper.mLayoutManager != layoutManager) {
mHorizontalHelper = OrientationHelper.createHorizontalHelper(layoutManager);
}
return mHorizontalHelper;
}
@NonNull
private OrientationHelper getVerticalHelper(@NonNull RecyclerView.LayoutManager layoutManager) {
if (mVerticalHelper == null || mVerticalHelper.mLayoutManager != layoutManager) {
mVerticalHelper = OrientationHelper.createVerticalHelper(layoutManager);
}
return mVerticalHelper;
}
这里根据列表的滚动方向选择了相应的OrientationHelper ,然后进入了findCenterView,方法名说得很清楚,找到中间那个View,所以怎样才算“中间”?继续看:
//LinearSnapHepler.java
private View findCenterView(RecyclerView.LayoutManager layoutManager,
OrientationHelper helper) {
int childCount = layoutManager.getChildCount();
if (childCount == 0) {
return null;
}
View closestChild = null;
final int center;
if (layoutManager.getClipToPadding()) {
center = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;
} else {
center = helper.getEnd() / 2;
}
int absClosest = Integer.MAX_VALUE;
for (int i = 0; i < childCount; i++) {
final View child = layoutManager.getChildAt(i);
int childCenter = helper.getDecoratedStart(child)
+ (helper.getDecoratedMeasurement(child) / 2);
int absDistance = Math.abs(childCenter - center);
/** if child center is closer than previous closest, set it as closest **/
if (absDistance < absClosest) {
absClosest = absDistance;
closestChild = child;
}
}
return closestChild;
}
看到第一行layoutManager.getChildCount(),就被惊到了!
int childCount = layoutManager.getChildCount();
难道要获取遍历所有item,通过位置计算比较得到离“中心”最近的那个View??看起来很像这么回事啊!
答案是否定的。
//LayoutManager.java
ChildHelper mChildHelper;
public int getChildCount() {
return mChildHelper != null ? mChildHelper.getChildCount() : 0;
}
//ChildHelper.java
/** * Returns the number of children that are not hidden. * * @return Number of children that are not hidden. * @see #getChildAt(int) */
int getChildCount() {
return mCallback.getChildCount() - mHiddenViews.size();
}
通过看源码,别的细节暂时不用顾及,只需要知道这里的getChildCount是指已经被展示出来的Item的数目。也就是说,只有一屏的Item数量。
确定了要列入比较的Item数目,接下来自然是开始比较了,怎么比?以什么为标准比?
final int center;
if (layoutManager.getClipToPadding()) {
center = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;
} else {
center = helper.getEnd() / 2;
}
这里的center就是比较标准,就是所谓的【参考位置】,从上面这段代码大概可以看出是在计算中心点,那么问题就来了:
- layoutManager.getClipToPadding()是什么意思?为什么要判断这个?
- helper.getStartAfterPadding() + helper.getTotalSpace() / 2计算出来的结果与helper.getEnd() / 2计算出来的结果有又什么不同?
1.1 getClipToPadding
先看这个getClipToPadding,通俗地说,这个属性可以控制RecyclerView在展示Item的时候,需不需要考虑RecyclerView本身padding对Item的影响,这里的影响是指剪切或覆盖的意思,这个属性在xml中可以设置,默认情况下是为true的,也就是通过情况下,会对Item的展示造成影响,比如这里的RecyclerView是这样的:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical">
<android.support.v7.widget.RecyclerView android:id="@+id/rv_demo" android:paddingStart="60px" android:paddingEnd="120px" android:layout_margin="10px" android:clipToPadding="true" android:background="#aa0000" android:layout_width="match_parent" android:layout_height="wrap_content">
</android.support.v7.widget.RecyclerView>
</LinearLayout>
item.xml是这样的:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="300px" android:layout_height="200px" android:padding="15px" android:layout_margin="5px" android:background="#888888" android:orientation="vertical">
<TextView android:id="@+id/tv_demo" android:layout_gravity="center" android:textSize="18sp" android:layout_width="wrap_content" android:layout_height="wrap_content"/>
</LinearLayout>
这里为了看出效果,RecyclerView设定的paddingStart和paddingEnd设置的值不同,并把背景色设置成了比较显眼的红色,另外所有的单位都是直接用的px,方便后面看值进行计算。然后代码中使用普通的LinearSnapHelper,接着就看效果,
当android:clipToPadding=”true”的时候是这样的:
很明显,RecyclerView本身的padding在阻挡Item的,也就是padding对Item进行残忍地剪切,通通都剪掉!!
而当android:clipToPadding=”false”的时候是这样的:
这样就和谐多了。
通常开发中,也是将android:clipToPadding置为false的时候居多,毕竟要美观许多。
至于为什么会产生这种效果,怎么实现的,猜都猜得出来,肯定和Canvas的translate关系甚大,顺手看下源码:
//RecyclerView.java
@Override
public void draw(Canvas c) {
super.draw(c);
final int count = mItemDecorations.size();
for (int i = 0; i < count; i++) {
mItemDecorations.get(i).onDrawOver(c, this, mState);
}
// TODO If padding is not 0 and clipChildrenToPadding is false, to draw glows properly, we
// need find children closest to edges. Not sure if it is worth the effort.
boolean needsInvalidate = false;
if (mLeftGlow != null && !mLeftGlow.isFinished()) {
final int restore = c.save();
final int padding = mClipToPadding ? getPaddingBottom() : 0;
c.rotate(270);
c.translate(-getHeight() + padding, 0);
needsInvalidate = mLeftGlow != null && mLeftGlow.draw(c);
c.restoreToCount(restore);
}
if (mTopGlow != null && !mTopGlow.isFinished()) {
final int restore = c.save();
if (mClipToPadding) {
c.translate(getPaddingLeft(), getPaddingTop());
}
needsInvalidate |= mTopGlow != null && mTopGlow.draw(c);
c.restoreToCount(restore);
}
if (mRightGlow != null && !mRightGlow.isFinished()) {
final int restore = c.save();
final int width = getWidth();
final int padding = mClipToPadding ? getPaddingTop() : 0;
c.rotate(90);
c.translate(-padding, -width);
needsInvalidate |= mRightGlow != null && mRightGlow.draw(c);
c.restoreToCount(restore);
}
if (mBottomGlow != null && !mBottomGlow.isFinished()) {
final int restore = c.save();
c.rotate(180);
if (mClipToPadding) {
c.translate(-getWidth() + getPaddingRight(), -getHeight() + getPaddingBottom());
} else {
c.translate(-getWidth(), -getHeight());
}
needsInvalidate |= mBottomGlow != null && mBottomGlow.draw(c);
c.restoreToCount(restore);
}
// If some views are animating, ItemDecorators are likely to move/change with them.
// Invalidate RecyclerView to re-draw decorators. This is still efficient because children's
// display lists are not invalidated.
if (!needsInvalidate && mItemAnimator != null && mItemDecorations.size() > 0
&& mItemAnimator.isRunning()) {
needsInvalidate = true;
}
if (needsInvalidate) {
ViewCompat.postInvalidateOnAnimation(this);
}
}
细节不论,关于mClipToPadding的想法是没有错的,果然是在translate。
至于这段代码中的mTopGlow、mLeftGlow之类的,好像方向与变量的命名有点迷?其实关系不大,这几个变量都是EdgeEffect对象,顾名思义,是对边缘效果进行定制的,改天看如果有时间去详细了解一下。
1.2 OrientationHelper
现在回到正题。
layoutManager.getClipToPadding确确实实是会对center的判断产生影响的,在clip=true的情况也就是考虑到padding对Item的剪切影响下:
center = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;
这里就得研究一下根据helper算出来的各种数值了,这里的helper是OrientationHelper,假如是横向滚动,那么我们拿到是一个这样的helper:
//LinearSnapHelper.java
public static OrientationHelper createHorizontalHelper(
RecyclerView.LayoutManager layoutManager) {
return new OrientationHelper(layoutManager) {
@Override
public int getEndAfterPadding() {
return mLayoutManager.getWidth() - mLayoutManager.getPaddingRight();
}
@Override
public int getEnd() {
return mLayoutManager.getWidth();
}
@Override
public void offsetChildren(int amount) {
mLayoutManager.offsetChildrenHorizontal(amount);
}
@Override
public int getStartAfterPadding() {
return mLayoutManager.getPaddingLeft();
}
@Override
public int getDecoratedMeasurement(View view) {
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
view.getLayoutParams();
return mLayoutManager.getDecoratedMeasuredWidth(view) + params.leftMargin
+ params.rightMargin;
}
@Override
public int getDecoratedMeasurementInOther(View view) {
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
view.getLayoutParams();
return mLayoutManager.getDecoratedMeasuredHeight(view) + params.topMargin
+ params.bottomMargin;
}
@Override
public int getDecoratedEnd(View view) {
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
view.getLayoutParams();
return mLayoutManager.getDecoratedRight(view) + params.rightMargin;
}
@Override
public int getDecoratedStart(View view) {
final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams)
view.getLayoutParams();
return mLayoutManager.getDecoratedLeft(view) - params.leftMargin;
}
@Override
public int getTransformedEndWithDecoration(View view) {
mLayoutManager.getTransformedBoundingBox(view, true, mTmpRect);
return mTmpRect.right;
}
@Override
public int getTransformedStartWithDecoration(View view) {
mLayoutManager.getTransformedBoundingBox(view, true, mTmpRect);
return mTmpRect.left;
}
@Override
public int getTotalSpace() {
return mLayoutManager.getWidth() - mLayoutManager.getPaddingLeft()
- mLayoutManager.getPaddingRight();
}
@Override
public void offsetChild(View view, int offset) {
view.offsetLeftAndRight(offset);
}
@Override
public int getEndPadding() {
return mLayoutManager.getPaddingRight();
}
@Override
public int getMode() {
return mLayoutManager.getWidthMode();
}
@Override
public int getModeInOther() {
return mLayoutManager.getHeightMode();
}
};
}
有点多,这里把后面要用到几个的方法集中说一下,为了更好懂,这里把真实情况的数据引入,因为当前是横向列表,所以竖直方向的一些属性可以先不考虑。
那么按照之前的xml看,
<android.support.v7.widget.RecyclerView android:id="@+id/rv_demo" android:paddingStart="60px" android:paddingEnd="120px" android:layout_margin="10px" android:clipToPadding="true" android:background="#aa0000" android:layout_width="match_parent" android:layout_height="wrap_content">
</android.support.v7.widget.RecyclerView>
结合真机的数据—屏幕像素宽度为1080px,高度为1920px,可以得出一些数据:
属性 | 数值(px) |
---|---|
屏幕width | 1080 |
RecyclerView.PaddingLeft | 60 |
RecyclerView.PaddingRight | 120 |
RecyclerView.Margin | 10 |
RecyclerView.Width | 屏幕Width- margin*2 = 1060 |
getTotalSpace | RV.Width – paddingLeft – paddingRight = 1060 – 60 – 120 = 880 |
getEnd | RV.Width = 1060 |
getEndAfterPadding | RV.Width – paddingRight = 1060 -120 = 940 |
getStartAfterPadding | paddingLeft = 60 |
getEndPadding | paddingRight = 120 |
这些可以不用传入View就得到的值,是只与RecyclerView相关的值,只要RecyclerView本身属性不变,上面这些值都是不会变的。
还有几个计算Item的值,需要结合item.xml来进行计算:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="300px" android:layout_height="200px" android:padding="15px" android:layout_margin="5px" android:background="#888888" android:orientation="vertical">
<TextView android:id="@+id/tv_demo" android:layout_gravity="center" android:textSize="18sp" android:layout_width="wrap_content" android:layout_height="wrap_content"/>
</LinearLayout>
属性 | 数值(px) |
---|---|
Item.Width | 300 |
Item.Padding | 15 |
Item.Margin | 5 |
Item Decorations.Width | 3 |
getDecoratedMeasurement | Item.Width + Item.Margin X 2 + Item.Decorations.Width X N = 313 |
这里其实可以不用列出padding,padding已经算在Item.Width中了,不会再额外计算。
最后一个属性getDecoratedMeasurement,很重要,可以看到源码中有一系列getDecoratedxx的计算方法,其实指的就是额外计算Decoration。上面表中getDecoratedMeasurement只计算一个Decoration的情况。
//RecyclerView.java
public abstract static class ItemDecoration {
//各种代码
}
RecyclerView提供了ItemDecoration以供开发者使用,顾名思义,是为了美化Item,为Item搞装修(…我也不知道自己在说什么),这个Decoration的层级尚在margin之外,也就是这样的:
ItemDecoration最常用的可能就是分割线了,因为不像ListView一样本身有一个divider属性,RecyclerView的分割线需要继承ItemDecoration类自行实现,然后通过方法添加进去,就像这样:
rvDemo.addItemDecoration(new QuickItemDecoration(this, QuickItemDecoration.HORIZONTAL_LIST));
当然ItemDecoration的应用远远不止于此,就像搞装修不可能只能铺个地板一样…
话说回来,所以这里所有关于View的计算都是把Decoration的大小计算在内的。
另外值得一提的是,在常规作为分割线的应用中,一个Item只对应一个Decoration,而不是像padding和margin那样可以一次设置就可以管4个方向,Decoration想要实现这样的,可能就要在ItemDecoration中多费些功夫了。
这也是上面的表中,最终算得300+5*2+3=313的原因。
那么,最后再看这两个计算方法:
属性 | 数值(px) |
---|---|
getDecoratedStart | Item.left – Item.Decoration – leftMargin = ? |
getDecoratedEnd | Item.right + Item.Decoration + rightMargin = ? |
必须要清楚,这里的两个属性,是在layout过程中才能确定的,得出的是layout相关的top/left/bottom/right属性,而不是像上面那些类似width/height的大小值;
简单的说,之前那些计算的那些值基本都是正数,而这里的值是可以为负数的。
这里根据图提供一些信息:
显示为“1”的Item的对应属性为:
属性 | 数值(px) |
---|---|
getEnd | 1060 |
getTotalSpace | 880 |
getDecoratedStart | 31 |
getDecoratedEnd | 344 |
getDecoratedMeasurement | 313 |
getEndAfterPadding | 940 |
getStartAfterPadding | 60 |
看到这里,心里大概有一个量化的认识了,现在再回头看代码中的逻辑应该就轻松一些了,重新祭出findCenterView:
//LinearSnapHepler.java
private View findCenterView(RecyclerView.LayoutManager layoutManager,
OrientationHelper helper) {
int childCount = layoutManager.getChildCount();//childCount=3
if (childCount == 0) {
return null;
}
View closestChild = null;
final int center;
if (layoutManager.getClipToPadding()) {
center = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;//center=60+880/2=500
} else {
center = helper.getEnd() / 2;//center=1060/2=530
}
int absClosest = Integer.MAX_VALUE;
//childCount=3
for (int i = 0; i < childCount; i++) {
final View child = layoutManager.getChildAt(i);
//第1个childCenter=31+313/2=187
int childCenter = helper.getDecoratedStart(child)
+ (helper.getDecoratedMeasurement(child) / 2);
//计算各个child中心位置与参考中心位置的绝对差值
int absDistance = Math.abs(childCenter - center);
//比较得出最近那一个
if (absDistance < absClosest) {
absClosest = absDistance;
closestChild = child;
}
}
return closestChild;
}
这段代码就本身逻辑而言是很简单的,主要做了三件事:
- 确定“参考位置”,这里是设定为RecyclerView的中心,当然要考虑clip的情况(外部参考位置)
- 计算每个已显示View的本身中心位置(内部参考位置)
- 计算得出最接近“参考位置”的那一个View(内部参考位置最贴近外部参考位置的View)
到这里,findSnapView的逻辑就结束了。
2.calculateDistanceToFinalSnap
不多说,放源码过来吧:
//LinearSnapHelper.java
@Override
public int[] calculateDistanceToFinalSnap(
@NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView) {
int[] out = new int[2];
if (layoutManager.canScrollHorizontally()) {
out[0] = distanceToCenter(layoutManager, targetView,
getHorizontalHelper(layoutManager));
} else {
out[0] = 0;
}
if (layoutManager.canScrollVertically()) {
out[1] = distanceToCenter(layoutManager, targetView,
getVerticalHelper(layoutManager));
} else {
out[1] = 0;
}
return out;
}
我们已经知道out[]是一个大小为2的数组,分别存储了x轴与y轴的距离数值。
接下来看在distanceToCenter中是如何计算的:
//LinearSnapHelper.java
private int distanceToCenter(@NonNull RecyclerView.LayoutManager layoutManager,
@NonNull View targetView, OrientationHelper helper) {
final int childCenter = helper.getDecoratedStart(targetView)
+ (helper.getDecoratedMeasurement(targetView) / 2);
final int containerCenter;
if (layoutManager.getClipToPadding()) {
containerCenter = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;
} else {
containerCenter = helper.getEnd() / 2;
}
return childCenter - containerCenter;
}
没有什么意外,这里和之前findCenterView有一点相似,还是把“参考位置”的坐标计算出来,然后得出targetView与“参考位置”距离。
…
转念一想,有没有什么方法可以复用findCenterView算出的“参考位置”坐标啊?也省得这里又写一次?本来这个参考位置在相同的RecyclerView中,也应该一直是固定的,不会有变动。
再一想,岂止省了一次…
将来每次滚动都要调用一次,每次都要重新计算…
所以官方给的也并非是完美的,比如这里,还是存在优化空间的,这也给了广大开发者一条活路…要是官方把什么工具类辅助类都写完了,什么都封装好了,其他开发者将会失去很大的乐趣…
等等,似乎有什么事情不对…
这里!居然可以把值算出来!
现在是处于attachRecyclerView的逻辑中,那么下一步根据snapToTargetExistingView的逻辑,那岂不是马上就要开始滚动了?
哈?
smoothScrollBy?
void snapToTargetExistingView() {
//略过
int[] snapDistance = calculateDistanceToFinalSnap(layoutManager, snapView);
if (snapDistance[0] != 0 || snapDistance[1] != 0) {
mRecyclerView.smoothScrollBy(snapDistance[0], snapDistance[1]);
}
}
经过调试,发现问题还是出现在findSnapView的过程中,
//LinearSnapHelper.java
private View findCenterView(RecyclerView.LayoutManager layoutManager,
OrientationHelper helper) {
int childCount = layoutManager.getChildCount();
if (childCount == 0) {
return null;
}
//略过
}
这时的layoutManager.getChildCount得出的值为0,
public int getChildCount() {
return mChildHelper != null ? mChildHelper.getChildCount() : 0;
}
调试也看到,并不是mChildHelper为空,而是mChildHelper此时的childCount也为0,是它没有加载Item…
至于为什么layoutManager还没有加载Item,应该和RecyclerView的初始化与加载Item的流程有关了,留待后来吧。
所以attachRecyclerView虽然会调用findSnapView,但却在findSnapView的中途就中断了,并没有运行之后的逻辑。
不管怎么样,findSnapView与calculateDistanceToFinalSnap的逻辑与细节都已经理清楚了,一个在找符合标准的targetView,一个在算targetView离参考位置还有多远。
接下来看看第三个方法吧。
3.findTargetSnapPosition
按照上面的结论,其实在三个抽象方法中,最先调用并起到作用的应该是findTargetSnapPosition,因为attachRecyclerView并没有真正运行其他两个方法的逻辑,那么一切的起始还是像上面那张时序图一样,从onFling开始。
还是来看代码吧:
//LinearSnapHelper.java
@Override
public int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX,
int velocityY) {
if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {
return RecyclerView.NO_POSITION;
}
final int itemCount = layoutManager.getItemCount();
if (itemCount == 0) {
return RecyclerView.NO_POSITION;
}
//这里手动调用了一次findSnapView
final View currentView = findSnapView(layoutManager);
if (currentView == null) {
return RecyclerView.NO_POSITION;
}
final int currentPosition = layoutManager.getPosition(currentView);
if (currentPosition == RecyclerView.NO_POSITION) {
return RecyclerView.NO_POSITION;
}
RecyclerView.SmoothScroller.ScrollVectorProvider vectorProvider =
(RecyclerView.SmoothScroller.ScrollVectorProvider) layoutManager;
// 一个向量标记,只是为了标明方向
PointF vectorForEnd = vectorProvider.computeScrollVectorForPosition(itemCount - 1);
if (vectorForEnd == null) {
// cannot get a vector for the given position.
return RecyclerView.NO_POSITION;
}
//计算需要跳跃的Item数量差值
int vDeltaJump, hDeltaJump;
if (layoutManager.canScrollHorizontally()) {
hDeltaJump = estimateNextPositionDiffForFling(layoutManager,
getHorizontalHelper(layoutManager), velocityX, 0);
if (vectorForEnd.x < 0) {
hDeltaJump = -hDeltaJump;
}
} else {
hDeltaJump = 0;
}
if (layoutManager.canScrollVertically()) {
vDeltaJump = estimateNextPositionDiffForFling(layoutManager,
getVerticalHelper(layoutManager), 0, velocityY);
if (vectorForEnd.y < 0) {
vDeltaJump = -vDeltaJump;
}
} else {
vDeltaJump = 0;
}
//根据当前的pos加上需要jump的pos数量差值,得出将要滑动到的pos
int deltaJump = layoutManager.canScrollVertically() ? vDeltaJump : hDeltaJump;
if (deltaJump == 0) {
return RecyclerView.NO_POSITION;
}
int targetPos = currentPosition + deltaJump;
if (targetPos < 0) {
targetPos = 0;
}
if (targetPos >= itemCount) {
targetPos = itemCount - 1;
}
return targetPos;
}
先概述一下这里的逻辑:
- 指定currentPosition,这个值理论上可以任意指定
- 计算出在当前速度下,需要滑动的Item数量deltaJump,为正数表明正向滑动,为负数表明反向滑动,这个值理论上也是可以任意指定的
- 根据前两步得到的数值,得出最后预计到达的位置targetPosition
可以看到,LinearSnapHelper这里手动调用了一次findSnapView,也就是说这里会得到与”参考位置“距离最近的那个View。
但是千万别认为就是根据当前静止状态下的数据计算出的结果。
请一定记住,findTargetSnapPosition方法是在snapFromFling中调用的,而snapFromFling的执行是有条件的,回顾一下:
@Override
public boolean onFling(int velocityX, int velocityY) {
//略过
int minFlingVelocity = mRecyclerView.getMinFlingVelocity();
return (Math.abs(velocityY) > minFlingVelocity || Math.abs(velocityX) > minFlingVelocity)
&& snapFromFling(layoutManager, velocityX, velocityY);
}
用户滑动屏幕的速度必须大于一个下限值minFlingVelocity才能如愿进入此方法中,默认是50dp,在DPI为480的手机上换算出来就是1秒必须滑动150像素的速度才能触发。
这意味着,通过findSnapView得到的View不一定是用户手指开始滑动时最接近“参考位置”的Item。
在速度不够快时,要么不能进入snapFromFling,要么有可能等用户手指加速到下限值以后,得到的View已经变为本来最接近”参考位置“所在的Item的下一个,或者下下一个,理论上都有可能;
而在速度够快,甚至快“过头”了的时候,快到findSnapView调用时,原来那个最接近“参考位置”的Item已经被滑动出“选择范围”内的时候,得到Item依旧有可能是下一个…
而在LinearSnapHelper中,手动调用findSnapView得到的View是具*“起始”意义的,后面根据一定的算法,算出要自动滑动过多少个Item,然后与findSnapView得到的View的pos相加,才能确定最终的targetPosition,那么在findSnapView不能确定的情况下,targetPosition始终是不确定的。
好,接着findSnapView的逻辑往下,通过layoutManager得到snapView所在的位置currentPosition,然后,有这么一行代码:
PointF vectorForEnd = vectorProvider.computeScrollVectorForPosition(itemCount - 1);
这是在干什么?
虽然不起眼,但其实影响相当大,只是一般情况下,这个值都是固定为1,这个值是在具体实现的LayoutManager中实现的,本文到这里也一直用的是LinearLayoutManager,所以直接看:
//LinearLayoutManager.java
@Override
public PointF computeScrollVectorForPosition(int targetPosition) {
if (getChildCount() == 0) {
return null;
}
final int firstChildPos = getPosition(getChildAt(0));
final int direction = targetPosition < firstChildPos != mShouldReverseLayout ? -1 : 1;
if (mOrientation == HORIZONTAL) {
return new PointF(direction, 0);
} else {
return new PointF(0, direction);
}
}
mShouldReverseLayout没有意外是可以在xml中设置的,代表着是否反转,默认为false,即不反转,
<android.support.v7.widget.RecyclerView android:id="@+id/rv_demo" android:paddingStart="60px" android:paddingEnd="120px" android:layout_margin="10px" app:reverseLayout="false" android:background="#aa0000" android:layout_width="match_parent" android:layout_height="wrap_content">
</android.support.v7.widget.RecyclerView>
那么可以看到这个值只会返回-1或1,1代表着正向,-1代表着反向,在绝大多数情况下,传入的targetPosition都为正值,那么返回的大多数也是1。
而后的逻辑也体现其作用了,如果为正数,那么一切好说,deltaJump不变,即朝原有的方向滑动;如果为负数,就代表这个滑动必须朝原有的方向反向操作。是以deltaJump的方向为基础,并不是绝对值。
在方向确定的情况下,接下来就是通过estimateNextPositionDiffForFling估算以当前的速度,会滑动过多少个Item。这个算法和View所在位置坐标有关,和当前速度有关,也和很久很久以前在attachRecyclerView初始化的Scroller有关:
//SnapHelper.java
public void attachToRecyclerView(@Nullable RecyclerView recyclerView)
throws IllegalStateException {
//略过
if (mRecyclerView != null) {
setupCallbacks();
mGravityScroller = new Scroller(mRecyclerView.getContext(),
new DecelerateInterpolator());
snapToTargetExistingView();
}
}
所以在SnapHelper就声明了是一个DecelerateInterpolator的Scroller,是会慢慢减速的,如果设定成匀速或加速…
就不用估算了,最后铁定滑到最后一个Item。美滋滋。
好了,至此,所有方法已经就位,就等反复调用了。
调用情况
真实的调用情况,就如一开始那张时序图一样,先通过OnFling的监听调用snapFromFling,而在snapFromFling中,其实就已经启动了自动滑动的过程,
//SnapHelper.java
private boolean snapFromFling(@NonNull LayoutManager layoutManager, int velocityX,
int velocityY) {
if (!(layoutManager instanceof ScrollVectorProvider)) {
return false;
}
SmoothScroller smoothScroller = createScroller(layoutManager);
if (smoothScroller == null) {
return false;
}
int targetPosition = findTargetSnapPosition(layoutManager, velocityX, velocityY);
if (targetPosition == RecyclerView.NO_POSITION) {
return false;
}
//此时已经开始自行滑动
smoothScroller.setTargetPosition(targetPosition);
layoutManager.startSmoothScroll(smoothScroller);
return true;
}
而layoutManager.startSmoothScroll(smoothScroller)则是启动自行滑动的关键方法,它会让smoothScroller开始工作,并回调onTargetFound方法通过calculateDistanceToFinalSnap得到需要滑动的值,并算出需要滑动的时间,接着通过action.update更新数值,然后开始滑动,具体流程可看上一篇的时序图。
这里放出createSnapScroller的源码:
protected LinearSmoothScroller createSnapScroller(LayoutManager layoutManager) {
if (!(layoutManager instanceof ScrollVectorProvider)) {
return null;
}
return new LinearSmoothScroller(mRecyclerView.getContext()) {
@Override
protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
int[] snapDistances = calculateDistanceToFinalSnap(mRecyclerView.getLayoutManager(),
targetView);
final int dx = snapDistances[0];
final int dy = snapDistances[1];
final int time = calculateTimeForDeceleration(Math.max(Math.abs(dx), Math.abs(dy)));
if (time > 0) {
action.update(dx, dy, time, mDecelerateInterpolator);
}
}
@Override
protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
return MILLISECONDS_PER_INCH / displayMetrics.densityDpi;
}
};
}
这也意味着触发OnFling监听后,调用了snapFromFling方法,就已经可以让RecyclerView滑动到相应的位置了。
那么在onScrollStateChanged中监听停止滚动,再调用snapToTargetExistingView时,会发现findSnapView所得到targetView,已经整整齐齐对准了“参考位置”了,那么第二次调用calculateDistanceToFinalSnap所计算出的结果必将为0,于是不再滚动。
private final RecyclerView.OnScrollListener mScrollListener =
new RecyclerView.OnScrollListener() {
boolean mScrolled = false;
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
if (newState == RecyclerView.SCROLL_STATE_IDLE && mScrolled) {
mScrolled = false;
snapToTargetExistingView();
}
}
//略过
};
void snapToTargetExistingView() {
//略过
View snapView = findSnapView(layoutManager);
if (snapView == null) {
return;
}
int[] snapDistance = calculateDistanceToFinalSnap(layoutManager, snapView);
//此时计算出的距离已经是0了
if (snapDistance[0] != 0 || snapDistance[1] != 0) {
mRecyclerView.smoothScrollBy(snapDistance[0], snapDistance[1]);
}
}
滑动停止时,虽然通过findSnapView仍然可以拿到targetView,但此时的targetView已经滚动到相应的参考位置了,所以再通过calculateDistanceToFinalSnap计算,得出结果只能是0了。
一句话,如果findTargetSnapPosition/findSnapView/calculateDistanceToFinalSnap三个方法合作默契的话,是不需要OnScrollListener的。
至少在LinearSnapHelper中是比较默契的。
关于LinearSnapHelper的东西,就记载到这里了。
扩展应用
说是扩展,其实也没有几点可以扩展,目前想到比较容易实现的扩展点:
- 自定“参考位置”
- 自定滑动速度(时间)
- 自定一次Fling可滑动的最大Item数目
自定“参考位置”
如LinearSnapHelper中的findCenterView一样,可以自定findxxxView,设定一个位置坐标作为参考位置,比如左对齐,findSnapView就要这么写:
private View findStartView(RecyclerView.LayoutManager layoutManager, OrientationHelper helper) {
int childCount = layoutManager.getChildCount();
if (childCount == 0) {
return null;
}
View closestChild = null;
final int start;
if (layoutManager.getClipToPadding()) {
start = helper.getStartAfterPadding();
} else {
start = 0;
}
int absClosest = Integer.MAX_VALUE;
for (int i = 0; i < childCount; i++) {
final View child = layoutManager.getChildAt(i);
int childStart = helper.getDecoratedStart(child);
int absDistance = Math.abs(childStart - start);
if (absDistance < absClosest) {
absClosest = absDistance;
closestChild = child;
}
}
return closestChild;
}
calculateDistanceToFinalSnap相应地就要计算:
private int distanceToStart(RecyclerView.LayoutManager layoutManager, View targetView, OrientationHelper helper) {
if (layoutManager.getClipToPadding()) {
return helper.getDecoratedStart(targetView) - helper.getStartAfterPadding();
} else {
return helper.getDecoratedStart(targetView);
}
}
只需要改动View本身的参考位置,与RecyclerView的参考位置就行了。
比如右对齐:
private View findEndView(RecyclerView.LayoutManager layoutManager, OrientationHelper helper) {
//略过,同上
final int end;
if (layoutManager.getClipToPadding()) {
end = helper.getEndAfterPadding();
} else {
end = helper.getEnd();
}
int absClosest = Integer.MAX_VALUE;
for (int i = 0; i < childCount; i++) {
final View child = layoutManager.getChildAt(i);
int childEnd = helper.getDecoratedEnd(child);
int absDistance = Math.abs(childEnd - end);
//略过,同上
}
return closestChild;
}
相应地距离计算则是:
private int distanceToEnd(RecyclerView.LayoutManager layoutManager, View targetView, OrientationHelper helper) {
if (layoutManager.getClipToPadding()) {
return helper.getDecoratedEnd(targetView) - helper.getEndAfterPadding();
} else {
return helper.getDecoratedEnd(targetView) - helper.getEnd();
}
}
和LinearSnapHelper一样,同样考虑getClipToPadding的情况。
左对齐、居中对齐、右对齐应该是三种最常见的情况,理论上是可以有无限种对齐方式的;
对齐方式(参考位置)可以分为两大类。
- 第一种是参考位置比例相同的,比如左对齐是指targetView的左边界对齐RecyclerView的左边界,比如使targetView的1/4处对齐RecyclerView的1/4处
- 第二种是参考位置比例不同的,比如偏要targetView的左边界对齐RecyclerView的中心
第二种不要以为行不通,代码逻辑很通顺的:
private View findSpecialView(RecyclerView.LayoutManager layoutManager, OrientationHelper helper) {
//略过,同上
final int center;
if (layoutManager.getClipToPadding()) {
center = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;
} else {
center = helper.getEnd() / 2;
}
int absClosest = Integer.MAX_VALUE;
for (int i = 0; i < childCount; i++) {
final View child = layoutManager.getChildAt(i);
int childStart = helper.getDecoratedStart(child);
int absDistance = Math.abs(childStart - center);
//略过,同上
}
return closestChild;
}
private int distanceToSpecial(RecyclerView.LayoutManager layoutManager, View targetView, OrientationHelper helper) {
final int childStart = helper.getDecoratedStart(targetView);
final int containerCenter;
if (layoutManager.getClipToPadding()) {
containerCenter = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;
} else {
containerCenter = helper.getEnd() / 2;
}
return childStart - containerCenter;
}
运行效果是这样的:
虽然看起来确实有些沙雕就是了…
但至少说明效果是完全可以实现的,所以“参考位置”是可以自由定制的。
另外就是这两个参考位置是可以复用的,所以不必像上面的代码也不必像LinearSnapHelper中那样每次都去重新计算,而是可以考虑在初始化SnapHelper的时候,就初始化这些值。只要注意这些值的计算都需要用到LayoutManager,所以一定要在LayoutManager本身初始化完成后再对这些值进行赋值就可以了。这些都是常规化操作,就不多说了。
自定滑动速度(时间)
要明白其实是并不能精确地自定速度的,因为Scroller肯定是一个DecelerateInterpolator构造而成的,是需要减速的,否则滑个不停或者来个判定急刹都不合适,所以严格来说,只能自定滑动时间。
自定滑动时间旨在覆写createSnapScroller方法,新建一个SmoothScroller:
protected LinearSmoothScroller createSnapScroller(final RecyclerView.LayoutManager layoutManager) {
if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {
return null;
}
return new LinearSmoothScroller(context) {
@Override
protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
int[] snapDistances = calculateDistanceToFinalSnap(layoutManager, targetView);
final int dx = snapDistances[0];
final int dy = snapDistances[1];
//这里的时间就是从当前滑动到targetView所用的总时间
final int time = calculateTimeForDeceleration(Math.max(Math.abs(dx), Math.abs(dy)));
if (time > 0) {
action.update(dx, dy, time, mDecelerateInterpolator);
}
}
@Override
protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
return speedFactor / displayMetrics.densityDpi;
}
};
}
onTargetFound方法中的time(单位为ms),以及calculateSpeedPerPixel的返回值(单位为每像素耗时多少ms),都是可以自行控制的。
虽然直接把time写成固定值确定有一点沙雕,但不能否认其效果…
当然写成固定值以后的效果,就是无论要滑动多长或多短的距离,时间都成固定的了,在DecelerateInterpolator的控制下,可能会产生一些奇怪的效果…就和没有冲刺的跑步一样,最后的一丁点距离需要很长的时间才能跑完这样子。
至于修改calculateSpeedPerPixel,一般也只是修改speedFactor,这个值在SnapHelper中默认为25,在LinearSnapHelper中默认为100。
当speedFactor=100时,根据上一篇文章计算的结果,在一个DPI为480的屏幕上,1080像素的距离将在减速器的作用下使用670ms滑过。
具体效果可自行体验。
自定一次Fling可滑动的最大Item数目
其实就是控制findTargetSnapPosition的deltaJump:
@Override
public int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX, int velocityY) {
//略过部分代码
int targetPos = currentPosition + deltaJump;
if (targetPos < 0) {
targetPos = 0;
}
if (targetPos >= itemCount) {
targetPos = itemCount - 1;
}
return targetPos;
}
把deltaJump定为常量,自然就定下了一次可滑动的Item数目,比如如果想把这个最大值定为一个屏幕能展示的最大Item数目,就应该:
deltaJump = orientationHelper.getTotalSpace() / orientationHelper.getDecoratedMeasurement(currentView)
这个值修改起来相对简单,不再多说。
另外,固定currentPosition也具有一定的实际意义,因为前面说到的LinearSnapHelper中使用findSnapView得到的View作为currentPosition其实是一个不确定值,就算deltaJump能确定,也只能把targetPos确定到某个范围之内,想要完全控制,固定currentPosition就变成必然的操作了。
比如把currentPosition永远定为当前屏幕展示的第一个Item,则应该:
currentPosition = ((LinearLayoutManager) recyclerView.getLayoutManager()).findFirstVisibleItemPosition();
当然,必须在OnFling触发之前调用,否则还是会受影响,这里可以利用RecycleView可以添加多个OnScrollListener的特性为其另添一个监听器,监听在滑动开始时就把currentPosition固定下来,就像这样:
@Override
public void attachToRecyclerView(@Nullable RecyclerView recyclerView) throws IllegalStateException {
super.attachToRecyclerView(recyclerView);
if (recyclerView != null) {
recyclerView.addOnScrollListener(scrollListener);
}
}
public RecyclerView.OnScrollListener scrollListener = new RecyclerView.OnScrollListener() {
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
//滚动开始时,此时界面中可见的Item是最可靠的参考标准
if (newState == SCROLL_STATE_DRAGGING) {
currentPosition = ((LinearLayoutManager) recyclerView.getLayoutManager()).findFirstVisibleItemPosition();
}
}
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
}
};
岂不是美滋滋?
这样什么都固定下来,滑动的目标都是可以预见。
当然理论上来说,如果能知道用户滑动的手速的话,通过计算也是可以算出最终滑动的结果的,毕竟代码里就是这么算出来的。
但是人脑哪有CPU转得快…
所以想控制滑动过程还是好好写成固定值吧。
最后,这里有一只SnapHelper可以享用。基本实现了上面说的三个自定义功能,并且优化了部分计算流程,有兴趣可以看看。
https://github.com/ifmylove2011/SlimIdea/blob/master/app/src/main/java/com/xter/slimidea/presentation/widget/HorizontalLinearSnapHelper.java
以上。