recyclerview滚动辅助器,每次横向滚动展示完整的item

简洁:

RecyclerView在24.2.0版本中新增了SnapHelper这个辅助类,用于辅助RecyclerView在滚动结束时将Item对齐到某个位置。特别是列表横向滑动时,很多时候不会让列表滑到任意位置,而是会有一定的规则限制,这时候就可以通过SnapHelper来定义对齐规则了。

SnapHelper是一个抽象类,官方提供了一个LinearSnapHelper的子类,可以让RecyclerView滚动停止时相应的Item停留中间位置。25.1.0版本中官方又提供了一个PagerSnapHelper的子类,可以使RecyclerView像ViewPager一样的效果,一次只能滑一页,而且居中显示。

这两个子类使用方式也很简单,只需要创建对象之后调用attachToRecyclerView()附着到对应的RecyclerView对象上就可以了。

new LinearSnapHelper().attachToRecyclerView(mRecyclerView);
//或者
new PagerSnapHelper().attachToRecyclerView(mRecyclerView);

一、先看效果

我现在项目上最终实现的效果就是这样的。横向滚动,每次滚动之后,都保证左边是贴合可见的

二、自定义滚动辅助类

/**
 * @Author : 马占柱
 * Time   : 2024/1/18 11:19
 * Desc   : 自定义滚动辅助类,每次滚动之后,展示完整的item
 */
public class GallerySnapHelper extends SnapHelper {
    public static final String TAG = "GallerySnapHelper";
    private static final float INVALID_DISTANCE = 1f;
    //SnapHelper中该值为100,这里改为40
    private static final float MILLISECONDS_PER_INCH = 40f;
    private OrientationHelper mHorizontalHelper;
    private RecyclerView mRecyclerView;

    @Override
    public void attachToRecyclerView(@Nullable RecyclerView recyclerView) throws IllegalStateException {
        mRecyclerView = recyclerView;
        super.attachToRecyclerView(recyclerView);
    }

    @Override
    public 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) {
        LogUtils.ld(TAG, "distanceToStart dx1=" + helper.getDecoratedStart(targetView) + ", dx2=" + helper.getStartAfterPadding());
        return helper.getDecoratedStart(targetView) - helper.getStartAfterPadding();
    }

    @Nullable
    protected LinearSmoothScroller createSnapScroller(final RecyclerView.LayoutManager layoutManager) {
        //同样,这里也是先判断layoutManager是否实现了ScrollVectorProvider这个接口,
        //没有实现该接口就不创建SmoothScroller
        if (!(layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {
            return null;
        }
        //这里创建一个LinearSmoothScroller对象,然后返回给调用函数,
        //也就是说,最终创建出来的平滑滚动器就是这个LinearSmoothScroller
        return new LinearSmoothScroller(mRecyclerView.getContext()) {
            //该方法会在targetSnapView被layout出来的时候调用。
            //这个方法有三个参数:
            //第一个参数targetView,就是本文所讲的targetSnapView
            //第二个参数RecyclerView.State这里没用到,先不管它
            //第三个参数Action,这个是什么东西呢?它是SmoothScroller的一个静态内部类,
            //保存着SmoothScroller在平滑滚动过程中一些信息,比如滚动时间,滚动距离,差值器等
            @Override
            protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
                //得到targetSnapView当前坐标到目的坐标之间的距离
                int[] snapDistances = calculateDistanceToFinalSnap(mRecyclerView.getLayoutManager(), targetView);
                final int dx = snapDistances[0];
                final int dy = snapDistances[1];
                //通过calculateTimeForDeceleration()方法得到做减速滚动所需的时间
                final int time = calculateTimeForDeceleration(Math.max(Math.abs(dx), Math.abs(dy)));
                if (time > 0) {
                    //调用Action的update()方法,更新SmoothScroller的滚动速率,使其减速滚动到停止
                    //这里的这样做的效果是,此SmoothScroller用time这么长的时间以mDecelerateInterpolator这个差值器的滚动变化率滚动dx或者dy这么长的距离
                    action.update(dx, dy, time, mDecelerateInterpolator);
                }
            }

            //该方法是计算滚动速率的,返回值代表滚动速率,该值会影响刚刚上面提到的
            //calculateTimeForDeceleration()的方法的返回返回值,
            //MILLISECONDS_PER_INCH的值是100,也就是说该方法的返回值代表着每dpi的距离要滚动100毫秒
            @Override
            protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
                return MILLISECONDS_PER_INCH / displayMetrics.densityDpi;
            }
        };
    }

    @Override
    public 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;
        }
        //找到snapView
        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;
        // 通过ScrollVectorProvider接口中的 computeScrollVectorForPosition()方法
        // 来确定layoutManager的布局方向
        PointF vectorForEnd = vectorProvider.computeScrollVectorForPosition(itemCount - 1);
        if (vectorForEnd == null) {
            // cannot get a vector for the given position.
            return RecyclerView.NO_POSITION;
        }

        //在松手之后,列表最多只能滚多一屏的item数
        int deltaThreshold = layoutManager.getWidth() / getHorizontalHelper(layoutManager).getDecoratedMeasurement(currentView);

        int 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;
            }
            //对估算出来的位置偏移量进行阈值判断,最多只能滚动一屏的Item个数
            if (hDeltaJump > deltaThreshold) {
                hDeltaJump = deltaThreshold;
            }
            if (hDeltaJump < -deltaThreshold) {
                hDeltaJump = -deltaThreshold;
            }

            if (vectorForEnd.x < 0) {
                hDeltaJump = -hDeltaJump;
            }
        } else {
            //不能横向滚动,横向位置偏移量当然就为0
            hDeltaJump = 0;
        }

        if (hDeltaJump == 0) {
            return RecyclerView.NO_POSITION;
        }
        //当前位置加上偏移位置,就得到fling结束时的位置,这个位置就是targetPosition
        int targetPos = currentPosition + hDeltaJump;
        if (targetPos < 0) {
            targetPos = 0;
        }
        if (targetPos >= itemCount) {
            targetPos = itemCount - 1;
        }
        return targetPos;
    }

    @Override
    public 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作为snapView
            if (helper.getDecoratedEnd(firstChildView) >= helper.getDecoratedMeasurement(firstChildView) / 2 && helper.getDecoratedEnd(firstChildView) > 0) {
                return firstChildView;
            } else {
                return layoutManager.findViewByPosition(firstChildPosition + 1);
            }
        } else {
            return null;
        }
    }


    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 = distances[0];
        //distance的正负值符号表示滚动方向,数值表示滚动距离。横向布局方式,内容从右往左滚动为正;竖向布局方式,内容从下往上滚动为正
        // 滚动距离/item的长度=滚动item的个数,这里取计算结果的整数部分
        if (distance > 0) {
            return (int) Math.floor(distance / distancePerChild);
        } else {
            return (int) Math.ceil(distance / distancePerChild);
        }
    }

    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,以及对应的view
        for (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);
    }


    private OrientationHelper getHorizontalHelper(RecyclerView.LayoutManager layoutManager) {
        if (mHorizontalHelper == null) {
            mHorizontalHelper = OrientationHelper.createHorizontalHelper(layoutManager);
        }
        return mHorizontalHelper;
    }

}

使用方法如下:

new GallerySnapHelper().attachToRecyclerView(mBinding.rvNormal);

demo链接

参考文章:让你明明白白的使用RecyclerView——SnapHelper详解 - 简书

他这个文章讲解的特别清楚,可以看一下