Android:让你明明白白的使用 RecyclerView—,retrofit 优点
从图中可以看到,很多时候 targetSnapView 被 layout 的时候(onTargetFound()
方法被调用)并不是紧挨着界面上的 Item,而是会有一定的提前,这是由于 RecyclerView 为了优化性能,提高流畅度,在滑动滚动的时候会有一个预加载的过程,提前将 Item 给 layout 出来了,这个知识点涉及到的内容很多,这里做个理解就可以了,不详细细展开了,以后有时间会专门讲下 RecyclerView 的相关原理机制。
到了这里,整理一下前面的思路:SnapHelper 实现了 OnFlingListener 这个接口,该接口中的onFling()
方法会在 RecyclerView 触发 Fling 操作时调用。在onFling()
方法中判断当前方向上的速率是否足够做滚动操作,如果速率足够大就调用snapFromFling()
方法实现滚动相关的逻辑。在snapFromFling()
方法中会创建一个 SmoothScroller,并且根据速率计算出滚动停止时的位置,将该位置设置给 SmoothScroller 并启动滚动。而滚动的操作都是由 SmoothScroller 全权负责,它可以控制 Item 的滚动速度(刚开始是匀速),并且在滚动到 targetSnapView 被 layout 时变换滚动速度(转换成减速),以让滚动效果更加真实。
所以,SnapHelper 辅助 RecyclerView 实现滚动对齐就是通过给 RecyclerView 设置 OnScrollerListenerh 和 OnFlingListener 这两个监听器实现的。
LinearSnapHelper
SnapHelper 辅助 RecyclerView 滚动对齐的框架已经搭好了,子类只要根据对齐方式实现那三个抽象方法就可以了。以 LinearSnapHelper 为例,看它到底怎么实现 SnapHelper 的三个抽象方法,从而让 ItemView 滚动居中对齐:
calculateDistanceToFinalSnap()
@Overridepublic int[] calculateDistanceToFinalSnap(@NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView) {int[] out = new int[2];//水平方向滚动,则计算水平方向需要滚动的距离,否则水平方向的滚动距离为 0if (layoutManager.canScrollHorizontally()) {out[0] = distanceToCenter(layoutManager, targetView,getHorizontalHelper(layoutManager));} else {out[0] = 0;}
//竖直方向滚动,则计算竖直方向需要滚动的距离,否则水平方向的滚动距离为 0if (layoutManager.canScrollVertically()) {out[1] = distanceToCenter(layoutManager, targetView,getVerticalHelper(layoutManager));} else {out[1] = 0;}return out;}
该方法是返回第二个传参对应的 view 到 RecyclerView 中间位置的距离,可以支持水平方向滚动和竖直方向滚动两个方向的计算。最主要的计算距离的这个方法distanceToCenter()
:
private int distanceToCenter(@NonNull RecyclerView.LayoutManager layoutManager,@NonNull View targetView, OrientationHelper helper) {//找到 targetView 的中心坐标 final int childCenter = helper.getDecoratedStart(targetView) +(helper.getDecoratedMeasurement(targetView) / 2);final int containerCenter;//找到容器(RecyclerView)的中心坐标 if (layoutManager.getClipToPadding()) {containerCenter = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;} else {containerCenter = helper.getEnd() / 2;}//两个中心坐标的差值就是 targetView 需要滚动的距离 return childCenter - containerCenter;}
可以看到,就是计算对应的 view 的中心坐标到 RecyclerView 中心坐标之间的距离,该距离就是此 view 需要滚动的距离。
findSnapView()
@Overridepublic View findSnapView(RecyclerView.LayoutManager layoutManager) {if (layoutManager.canScrollVertically()) {return findCenterView(layoutManager, getVerticalHelper(layoutManager));} else if (layoutManager.canScrollHorizontally()) {return findCenterView(layoutManager, getHorizontalHelper(layoutManager));}return null;}
寻找 SnapView,这里的目的坐标就是 RecyclerView 中间位置坐标,可以看到会根据 layoutManager 的布局方式(水平布局方式或者竖向布局方式)区分计算,但最终都是通过findCenterView()
方法来找 snapView 的。
private View findCenterView(RecyclerView.LayoutManager layoutManager,OrientationHelper helper) {int childCount = layoutManager.getChildCount();if (childCount == 0) {return null;}
View closestChild = null;//找
到 RecyclerView 的中心坐标 final int center;if (layoutManager.getClipToPadding()) {center = helper.getStartAfterPadding() + helper.getTotalSpace() / 2;} else {center = helper.getEnd() / 2;}int absClosest = Integer.MAX_VALUE;
//遍历当前 layoutManager 中所有的 ItemViewfor (int i = 0; i < childCount; i++) {final View child = layoutManager.getChildAt(i);//ItemView 的中心坐标 int childCenter = helper.getDecoratedStart(child) +(helper.getDecoratedMeasurement(child) / 2);//计算此 ItemView 与 RecyclerView 中心坐标的距离 int absDistance = Math.abs(childCenter - center);
//对比每个 ItemView 距离到 RecyclerView 中心点的距离,找到那个最靠近中心的 ItemView 然后返回 if (absDistance < absClosest) {absClosest = absDistance;closestChild = child;}}return closestChild;}
注释解释得很清楚,就不重复了。
findTargetSnapPosition()
@Overridepublic int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX,int velocityY) {//判断 layoutManager 是否实现了 RecyclerView.SmoothScroller.ScrollVectorProvider 这个接口 if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {return RecyclerView.NO_POSITION;}
final int itemCount = layoutManager.getItemCount();if (itemCount == 0) {return RecyclerView.NO_POSITION;}
//找到 snapViewfinal 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;// 通过 ScrollVectorProvider 接口中的 computeScrollVectorForPosition()方法// 来确定 layoutManager 的布局方向 PointF vectorForEnd = vectorProvider.computeScrollVectorForPosition(itemCount - 1);if (vectorForEnd == null) {return RecyclerView.NO_POSITION;}
int vDeltaJump, hDeltaJump;if (layoutManager.canScrollHorizontally()) {//layoutManager 是横向布局,并且内容超出一屏,canScrollHorizontally()才返回 true//估算 fling 结束时相对于当前 snapView 位置的横向位置偏移量 hDeltaJump = estimateNextPositionDiffForFling(layoutManager,getHorizontalHelper(layoutManager), velocityX, 0);//vectorForEnd.x < 0 代表 layoutManager 是反向布局的,就把偏移量取反 if (vectorForEnd.x < 0) {hDeltaJump = -hDeltaJump;}} else {//不能横向滚动,横向位置偏移量当然就为 0hDeltaJump = 0;}
//竖向的原理同上 if (layoutManager.canScrollVertically()) {vDeltaJump = estimateNextPositionDiffForFling(layoutManager,getVerticalHelper(layoutManager), 0, velocityY);if (vectorForEnd.y < 0) {vDeltaJump = -vDeltaJump;}} else {vDeltaJump = 0;}
//根据 layoutManager 的横竖向布局方式,最终横向位置偏移量和竖向位置偏移量二选一,作为 fling 的位置偏移量 int deltaJump = layoutManager.canScrollVertically() ? vDeltaJump : hDeltaJump;if (deltaJump == 0) {return RecyclerView.NO_POSITION;}//当前位置加上偏移位置,就得到 fling 结束时的位置,这个位置就是 targetPositionint targetPos = currentPosition + deltaJump;if (targetPos < 0) {targetPos = 0;}if (targetPos >= itemCount) {targetPos = itemCount - 1;}return targetPos;}
RecyclerView 的 layoutManager 很灵活,有两种布局方式(横向布局和纵向布局),每种布局方式有两种布局方向(正向布局和反向布局)。这个方法在计算 targetPosition 的时候把布局方式和布局方向都考虑进去了。布局方式可以通过layoutManager.canScrollHorizontally()
/layoutManager.canScrollVertically()
来判断,布局方向就通过RecyclerView.SmoothScroller.ScrollVectorProvider
这个接口中的computeScrollVectorForPosition()
方法来判断。
所以 SnapHelper 为了适配 layoutManager 的各种情况,特意要求**只有实现了RecyclerView.SmoothScroller.ScrollVectorProvider
接口的 layoutManager 才能使用 SnapHelper 进行辅助滚动对齐。**官方提供的 LinearLayoutManager、GridLayoutManager 和 StaggeredGridLayoutManager 都实现了这个接口,所以都支持 SnapHelper。
这几个方法在计算位置的时候用的是 OrientationHelper 这个工具类,它是 LayoutManager 用于测量 child 的一个辅助类,可以根据 Layoutmanager 的布局方式和布局方向来计算得到 ItemView 的大小位置等信息。
从源码中可以看到findTargetSnapPosition()
会先找到 fling 操作被触发时界面上的 snapView(因为findTargetSnapPosition()
方法是在onFling()
方法中被调用的),得到对应的 snapPosition,然后通过estimateNextPositionDiffForFling()
方法估算位置偏移量,snapPosition 加上位置偏移量就得到最终滚动结束时的位置,也就是 targetSnapPosition。
这里有一个点需要注意一下,就是在找 targetSnapPosition 之前是需要先找一个参考位置的,该参考位置就是 snapPosition 了。这是因为当前界面上不同的 ItemView 位置相差比较大,用 snapPosition 作参考位置,会使得参考位置加上位置偏移量得到的 targetSnapPosition 最接近目的坐标位置,从而让后续的坐标对齐调整更加自然。
看下estimateNextPositionDiffForFling()
方法怎么估算位置偏移量的:
private int estimateNextPositionDiffForFling(RecyclerView.LayoutManager layoutManager,OrientationHelper helper, int velocityX, int velocityY) {//计算滚动的总距离,这个距离受到触发 fling 时的速度的影响 int[] distances = calculateScrollDistance(velocityX, velocityY);//计算每个 ItemView 的长度 float distancePerChild = computeDistancePerChild(layoutManager, helper);if (distancePerChild <= 0) {return 0;}//这里其实就是根据是横向布局还是纵向布局,来取对应布局方向上的滚动距离 int distance =Math.abs(distances[0]) > Math.abs(distances[1]) ? distances[0] : distances[1];//distance 的正负值符号表示滚动方向,数值表示滚动距离。横向布局方式,内容从右往左滚动为正;竖向布局方式,内容从下往上滚动为正// 滚动距离/item 的长度=滚动 item 的个数,这里取计算结果的整数部分 if (distance > 0) {return (int) Math.floor(distance / distancePerChild);} else {return (int) Math.ceil(distance / distancePerChild);}}
可以看到就是用滚动总距离除以 itemview 的长度,从而估算得到需要滚动的 item 数量,此数值就是位置偏移量。而滚动距离是通过 SnapHelper 的calculateScrollDistance()
方法得到的,ItemView 的长度是通过computeDistancePerChild()
方法计算出来。
看下这两个方法:
private float computeDistancePerChild(RecyclerView.LayoutManager layoutManager,OrientationHelper helper) {View minPosView = null;View maxPosView = null;int minPos = Integer.MAX_VALUE;int maxPos = Integer.MIN_VALUE;int childCount = layoutManager.getChildCount();if (childCount == 0) {return INVALID_DISTANCE;}
//循环遍历 layoutManager 的 itemView,得到最小 position 和最大 position,以及对应的 viewfor (int i = 0; i < childCount; i++) {View child = layoutManager.getChildAt(i);final int pos = layoutManager.getPosition(child);if (pos == RecyclerView.NO_POSITION) {continue;}if (pos < minPos) {minPos = pos;minPosView = child;}if (pos > maxPos) {maxPos = pos;maxPosView = child;}}if (minPosView == null || maxPosView == null) {return INVALID_DISTANCE;}//最小位置和最大位置肯定就是分布在 layoutManager 的两端,但是无法直接确定哪个在起点哪个在终点(因为有正反向布局)//所以取两者中起点坐标小的那个作为起点坐标//终点坐标的取值一样的道理 int start = Math.min(helper.getDecoratedStart(minPosView),helper.getDecoratedStart(maxPosView));int end = Math.max(helper.getDecoratedEnd(minPosView),helper.getDecoratedEnd(maxPosView));//终点坐标减去起点坐标得到这些 itemview 的总长度 int distance = end - start;if (distance == 0) {return INVALID_DISTANCE;}// 总长度 / itemview 个数 = itemview 平均长度 return 1f * distance / ((maxPos - minPos) + 1);}
可以发现computeDistancePerChild()
方法也用总长度除以 ItemView 个数的方式来得到 ItemView 平均长度,并且也支持了 layoutManager 不同的布局方式和布局方向。
public int[] calculateScrollDistance(int velocityX, int velocityY) {int[] outDist = new int[2];//mGravityScroller 是一个 Scroller,通过 fling()方法模拟 fling 操作,通过将起点位置都置为 0,此时得到的终点位置就是滚动的距离 mGravityScroller.fling(0, 0, velocityX, velocityY,Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);outDist[0] = mGravityScroller.getFinalX();outDist[1] = mGravityScroller.getFinalY();return outDist;}
calculateScrollDistance()
是 SnapHelper 中的方法,它使用到的 mGravityScroller 是一个在attachToRecyclerView()
中初始化的 Scroller 对象,通过Scroller.fling()
方法模拟 fling 操作,将 fling 的起点位置为设置为 0,此时得到的终点位置就是 fling 的距离。这个距离会有正负符号之分,表示滚动的方向。
现在明白了吧,LinearSnapHelper 的主要功能就是通过实现 SnapHelper 的三个抽象方法,从而实现辅助 RecyclerView 滚动 Item 对齐中心位置。
自定义 SnapHelper
经过了以上分析,了解了 SnapHelper 的工作原理之后,自定义 SnapHelper 也就更加自如了。现在来看下 Google Play 主界面的效果。
可以看到该效果是一个类似 Gallery 的横向列表滑动控件,很明显可以用 RecyclerView 来实现,而滚动后的 ItemView 是对齐 RecyclerView 的左边缘位置,这种对齐效果当仍不让就使用了 SnapHelper 来实现了。这里就主要讲下这个 SnapHelper 怎么实现的。
创建一个 GallerySnapHelper 继承 SnapHelper 实现它的三个抽象方法:
calculateDistanceToFinalSnap()
:计算 SnapView 当前位置与目标位置的距离
@Overridepublic int[] calculateDistanceToFinalSnap(@NonNull RecyclerView.LayoutManager layoutManager, @NonNull View targetView) {int[] out = new int[2];if (layoutManager.canScrollHorizontally()) {out[0] = distanceToStart(targetView, getHorizontalHelper(layoutManager));} else {out[0] = 0;}return out;}//targetView 的 start 坐标与 RecyclerView 的 paddingStart 之间的差值//就是需要滚动调整的距离 private int distanceToStart(View targetView, OrientationHelper helper) {return helper.getDecoratedStart(targetView) - helper.getStartAfterPadding();}
findSnapView()
:找到当前时刻的 SnapView。
@Overridepublic View findSnapView(RecyclerView.LayoutManager layoutManager) {return findStartView(layoutManager, getHorizontalHelper(layoutManager));}
private View findStartView(RecyclerView.LayoutManager layoutManager, OrientationHelper helper) {if (layoutManager instanceof LinearLayoutManager) {//找出第一个可见的 ItemView 的位置 int firstChildPosition = ((LinearLayoutManager) layoutManager).findFirstVisibleItemPosition();if (firstChildPosition == RecyclerView.NO_POSITION) {return null;}//找到最后一个完全显示的 ItemView,如果该 ItemView 是列表中的最后一个//就说明列表已经滑动最后了,这时候就不应该根据第一个 ItemView 来对齐了//要不然由于需要跟第一个 ItemView 对齐最后一个 ItemView 可能就一直无法完全显示,//所以这时候直接返回 null 表示不需要对齐 if (((LinearLayoutManager) layoutManager).findLastCompletelyVisibleItemPosition() == layoutManager.getItemCount() - 1) {return null;}
View firstChildView = layoutManager.findViewByPosition(firstChildPosition);//如果第一个 ItemView 被遮住的长度没有超过一半,就取该 ItemView 作为 snapView//超过一半,就把下一个 ItemView 作为 snapViewif (helper.getDecoratedEnd(firstChildView) >= helper.getDecoratedMeasurement(firstChildView) / 2 && helper.getDecoratedEnd(firstChildView) > 0) {return firstChildView;} else {return layoutManager.findViewByPosition(firstChildPosition + 1);}} else {return null;}}
findTargetSnapPosition()
: 在触发 fling 时找到 targetSnapPosition。
@Overridepublic 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;}
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) {return RecyclerView.NO_POSITION;}
int deltaJump;if (layoutManager.canScrollHorizontally()) {deltaJump = estimateNextPositionDiffForFling(layoutManager,getHorizontalHelper(layoutManager), velocityX, 0);if (vectorForEnd.x < 0) {deltaJump = -deltaJump;}} else {deltaJump = 0;}
if (deltaJump == 0) {return RecyclerView.NO_POSITION;}int targetPos = currentPosition + deltaJump;if (targetPos < 0) {targetPos = 0;}if (targetPos >= itemCount) {targetPos = itemCount - 1;}return targetPos;}
这个方法跟 LinearSnapHelper 的实现基本是一样的。
就这样实现三个抽象方法之后看下效果:
发现基本能像 Google Play 那样进行对齐左侧边缘。但作为一个有理想有文化有追求的程序员,怎么可以那么容易满足呢?!极致才是最终的目标!没时间解释了,快上车!
目前的效果跟 Google Play 中的效果主要还有两个差异:
滚动速度明显慢于 Google Play 的横向列表滚动速度,导致滚动起来感觉比较拖沓,看起来不是很干脆的样子。
Google Play 那个横向列表一次滚动的个数最多就是一页的 Item 个数,而目前的效果滑得比较快时会滚得很远。
其实这两个问题如果你理解了我上面所讲的 SnapHelper 的原理,解决起来就很容易了。
对于滚动速度偏慢的问题,由于这个 fling 过程是通过 SnapHelper 的 SmoothScroller 控制的,我们在分析创建 SmoothScroller 对象的时候就提到 SmoothScroller 的calculateSpeedPerPixel()
方法是在定义滚动速度的,那复写 SnapHelper 的createSnapScroller()
方法重新定义一个 SmoothScroller 不就可以了么?!
//SnapHelper 中该值为 100,这里改为 40private static final float MILLISECONDS_PER_INCH = 40f;@Nullableprotected LinearSmoothScroller createSnapScroller(final RecyclerView.LayoutManager layoutManager) {if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {return null;}return new LinearSmoothScroller(mRecyclerView.getContext()) {@Overrideprotected 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);}}
@Overrideprotected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {return MILLISECONDS_PER_INCH / displayMetrics.densityDpi;}};}
可以看到,代码跟 SnapHelper 里是一模一样的,就只是改了 MILLISECONDS_PER_INCH 这个数值而已,使得calculateSpeedPerPixel()
返回值变小,从而让 SmoothScroller 的滚动速度更快。
对于一次滚动太多个 Item 的问题,就需要对他滚动的个数做下限制了。那在哪里对滚动的数量做限制呢?findTargetSnapPosition()
方法里! 该方法的作用就是在寻找需要滚动到哪个位置的,不在这里还能在哪里?!直接看代码:
@Overridepublic int findTargetSnapPosition(RecyclerView.LayoutManager layoutManager, int velocityX, int velocityY) {...
//计算一屏的 item 数 int deltaThreshold = layoutManager.getWidth() / getHorizontalHelper(layoutManager).getDecoratedMeasurement(currentView);
int deltaJump;if (layoutManager.canScrollHorizontally()) {deltaJump = estimateNextPositionDiffForFling(layoutManager,getHorizontalHelper(layoutManager), velocityX, 0);//对估算出来的位置偏移量进行阈值判断,最多只能滚动一屏的 Item 个数 if (deltaJump > deltaThreshold) {deltaJump = deltaThreshold;}if (deltaJump < -deltaThreshold) {deltaJump = -deltaThreshold;
评论