RV 的 scrollToPosition 你真的会吗?看我骚操作!

2,036 阅读8分钟

RV如何滚动到指定索引

前言

看到标题可能有同学有点疑问,这不是有手就行?😅😅 且慢,听我慢慢道来。

的确,RV 内部提供了一系列的滚动方法:

scrollTo,scrollBy,scrollToPosition ,还有一系列的 smoothScrollTo,smoothScrollBy,smoothScrollToPosition。甚至还有嵌套的 nestedScrollBy,nestedScrollByInternal等等。

难道这些都不能实现指定到滚动索引的逻辑?额,当然能,但是又不是那么能!

为什么这么说?可能我们对 滚动到指定索引 这个需求的理解有所偏差。

谷歌理解的 滚动到指定索引 是当前索引在屏幕上可见了就达到目的,而我们需要的效果是展示到指定索引并且在顶部展示。

那这又有什么区别?

image.png

比如我们要滚动到第 75 的索引,那么当这个 Item 在屏幕中间,或者在屏幕上面,或者在屏幕下面,三种情况滚动到索引的效果都是不同的。

从屏幕上滚动到 75 索引,是符合我们的预期,展示出来也是在顶部展示,但是如果从屏幕下滚动到 75 索引,就只会出现在底部,而如果 75 索引的 Item 本来就在屏幕中间,那么点击回到索引则无反应。在谷歌看来它已经是在屏幕中了。

所以为了实现 滚动到指定索引并在顶部展示 这个效果,本文才对 RV 的滚动做了一些兼容操作,尝试性的出一篇文章探讨一下。

本文并没有涉及到源码,全程轻松愉快容易理解,下面开始正文 ↓

一、scrollToPosition的使用

首先不管是 scrollToPosition 还是 smoothScrollToPosition 都是由 LayoutManager 管理与实现的。

所以关于 scrollToPosition 我们其实调用 LayoutManager 的方法也是能实现的:

layoutManager.scrollToPositionWithOffset(position, 0) layoutManager.scrollToPosition(position)

其次,scrollToPosition 与 smoothScrollToPosition 的基本是有区别的。

scrollToPosition 内部其实只是 requestLayout 重新布局而已,方法写的是scroll,但是并没有滚。可以理解为只是相当于刷新了布局而已。

而 smoothScrollToPosition 是真正的滚动了,由 RecyclerView.SmoothScroller 管理,而我们常用的 LinearLayoutManager 内部也是用的默认实现的 LinearSmoothScroller 来管理滚动的。

大部分情况下都是够我们用的了,如果想要一些特殊效果也可以自定义 LinearLayoutManager 与 LinearSmoothScroller 自己管理滚动,也可以重写部分方法达到想要的效果,比如滚动的距离控制,滚动的速度控制等。

我们先看看前言中的三种效果,到底是不是对的,下面给出简易代码:

       val datas = arrayListOf<String>()

        for (i in 0..99) {
            datas.add("Item 内容 $i")
        }

        //RV绑定Adapter
        mBinding.recyclerView.vertical()
            .bindData(datas, R.layout.item_custom_jobs) { holder, t, _ ->
                holder.setText(R.id.tv_job_text, t)
            }
            .divider(Color.BLACK)
            .scrollToPosition(50)

        mBinding.btnScollTo.click {
            mBinding.recyclerView.scrollToPosition(75)
        }

下面给出 GIF 的图片演示:

device-2023-03-24-153613 00_00_00-00_00_30.gif

我要滚动的是第 75 个索引,但是这个 Item 要么就不生效,要么就在底部展示,这并不符合我(产品)的要求。

没办法,只能对滚动效果对这三种情况分别做处理,(我知道 scrollToPositionWithOffset 好用),但是可能部分同学的RV版本并没有那么高,还是分情况判断兼容性更好一点。

修改代码如下:

    private fun rvScrollToPosition(rv: RecyclerView, layoutManager: LinearLayoutManager, position: Int) {

        val firstPos = layoutManager.findFirstVisibleItemPosition()
        val lastPos: Int = layoutManager.findLastVisibleItemPosition()

        YYLogUtils.w("firstPos:$firstPos lastPos:$lastPos position:$position")

        if (position <= firstPos) {
            //当要置顶的项在当前显示的第一个项的前面时
            rv.scrollToPosition(position)

        } else if (position <= lastPos) {
            //当要置顶的项已经在屏幕上显示时
            val childAt: View? = layoutManager.findViewByPosition(position)
            var top = childAt?.top ?: 0
            rv.scrollBy(0, top)

        } else {
            //当要置顶的项在当前显示的最后一项之后
            layoutManager.scrollToPositionWithOffset(position, 0)
        }
    }

那么我们通过这个方法去滚动的话,那么效果如下:

device-2023-03-24-155320 00_00_00-00_00_30.gif

没错这样才是我(产品)想要的效果!

二、smoothScrollToPosition的使用

虽然能实现效果了,但是有些时候,我(产品)更喜欢用一些滚动效果,这中选中效果太突兀了,只适合一些初始化选中的效果,当用户点击按钮或操作之后,我们的 RV 缓缓滚动到指定的索引位置,看起来很美!

我们先试试原生的 smoothScrollToPosition 使用效果,还是分为上面的三种情况,那么效果就是如下:

device-2023-03-24-161531 00_00_00-00_00_30.gif

还是会有相同的问题,那么我们能不能通过像上面一样的方式来判断呢?能,又不能。

思路是一个思路,但是实现的过程不同了,因为不同的距离的滚动过程与滚动时长是不同的,所以我们至少需要在滚动完成之后的监听中进行处理,但是我们有滚动完成的监听吗?没有!

所以我们只能间接的通过RV的滚动监听来实现是否已经完成滚动

 mBinding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {

            override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {

                if (smoothScrolling || newState == SCROLL_STATE_IDLE) {

                    val lastPos: Int = layoutManager.findLastVisibleItemPosition()

                    if (smoothScrollPosition >= 0 && lastPos == smoothScrollPosition) {

                        val childAt: View? = layoutManager.findViewByPosition(lastPos)
                        var top = childAt?.top ?: 0
                        recyclerView.scrollBy(0, top)

                        mBinding.recyclerView.removeOnScrollListener(this)
                        smoothScrollPosition = -1

                    }
                    smoothScrolling = false
                }
            }

            override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {

            }
        })

效果为:

device-2023-03-24-162523 00_00_00-00_00_30.gif

这不就行了吗?

下面给出完整的工具类方法,如果大家想要横向的滚动或者其他 LayoutManager 的效果,稍作修改即可:

object RVScrollUtils {

    /**
     * 缓慢滚动
     */
    fun rvSmoothScrollToPosition(recyclerView: RecyclerView, layoutManager: LinearLayoutManager, position: Int) {

        var smoothScrolling = true

        val firstPos: Int = layoutManager.findFirstVisibleItemPosition()
        val lastPos: Int = layoutManager.findLastVisibleItemPosition()

        if (position in (firstPos + 1) until lastPos) {
            val childAt: View? = layoutManager.findViewByPosition(position)
            var top = childAt?.top ?: 0
            recyclerView.smoothScrollBy(0, top)

        } else {

            recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {

                override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {

                    if (smoothScrolling || newState == RecyclerView.SCROLL_STATE_IDLE) {

                        if (position in layoutManager.findFirstVisibleItemPosition() + 1..layoutManager.findLastVisibleItemPosition()) {

                            val childAt: View? = layoutManager.findViewByPosition(position)
                            val top = childAt?.top ?: 0
                            recyclerView.scrollBy(0, top)

                            recyclerView.removeOnScrollListener(this)
                        }
                        smoothScrolling = false
                    }
                }

                override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
                }
            })

            recyclerView.smoothScrollToPosition(position)
        }

    }


    /**
     * 直接跳转刷新Layout
     */
    fun rvScrollToPosition(rv: RecyclerView, layoutManager: LinearLayoutManager, position: Int) {

        val firstPos = layoutManager.findFirstVisibleItemPosition()
        val lastPos: Int = layoutManager.findLastVisibleItemPosition()

        if (position <= firstPos) {
            //当要置顶的项在当前显示的第一个项的前面时
            rv.scrollToPosition(position)

        } else if (position <= lastPos) {
            //当要置顶的项已经在屏幕上显示时,通过LayoutManager
            val childAt: View? = layoutManager.findViewByPosition(position)
            var top = childAt?.top ?: 0
            rv.scrollBy(0, top)

        } else {
            //当要置顶的项在当前显示的最后一项之后
            layoutManager.scrollToPositionWithOffset(position, 0)
        }
    }
}

三、smoothScroll的速度控制

产品:不错,效果不错,但是还差了那么一丢丢。 开发:这不挺好的吗?滚动效果不错。 产品:你这个隔的远的滚动时间长,隔的近的滚动时间短,效果不统一,我想要的是不管远近都要滚动时间统一。 开发:你这什么鬼需求,就不符合物理学定律,牛顿的棺材... 哎哎哎,有话好好说,快把刀放下,又没说不能做,急什么...

虽然说系统的默认滚动效果以及能满足绝大部分的需求了,但是总有一些奇葩的需求需要一些定制,我们也能通过重写一些 LayoutManager 等类,可以自己控制股滚动的距离与滚动的速度。

LayoutManager 本身是负责 RV 的布局展示的,内部的 滚动 逻辑是交由LinearSmoothScroller 来实现的。

那么如何获取滚动的距离呢?我们需要重写 onTargetFound 方法,内部的参数是需要滚动到的 ItemView 对象,然后通过系统方法 calculateDyToMakeVisible 级可以计算出需要滚动的距离。

方案一:指定滚动时间

    @Override
    protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {

        final int dx = calculateDxToMakeVisible(targetView, getHorizontalSnapPreference());
        final int dy = calculateDyToMakeVisible(targetView, getVerticalSnapPreference());

        //获取滚动距离
        final int distance = (int) Math.sqrt(dx * dx + dy * dy);
        //根据滚动距离计算时间
        final int time = calculateTimeForDeceleration(distance);

        YYLogUtils.w("打印需要滚动的时间与距离,distance:"+distance + " time:"+time);

        if (time > 0) {
            action.update(-dx, -dy, time, mDecelerateInterpolator);
        }
    }

打印结果如下:

image.png

可以看到确实是滚动的距离越长,所需要的时间也是越长的。如果我们需要修改滚动的时间,那么还需要修改滚动的速度,应该这个 calculateTimeForDeceleration 方法,如果想定死滚动的时长我们可以直接重写 calculateTimeForDeceleration 或 calculateTimeForScrolling 即可。

    @Override
    protected int calculateTimeForDeceleration(int dx) {
        return 5000;
    }

打印日志:

image.png

效果就是:

device-2023-03-25-103406 00_00_00-00_00_30.gif

我们改为真实的 250ms 之后感觉还行,但是如果滚动距离太长,而实际动画时间太短,会导致更难看的效果:

device-2023-03-25-103834 00_00_00-00_00_30.gif

产品看了这个效果直拍脑门。。。这效果不太行啊。那能不能动态的改动滚动速度呢?

方案二:指定滚动速度

先说如何改变滚动速度,我们只需要重写 calculateSpeedPerPixel 方法即可,内部实现滑动一个像素需要多少毫秒。

比如:

    @Override
    protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {

        //滑动一个像素需要多少毫秒
        return 25f / displayMetrics.density;
    }

效果为:

device-2023-03-25-101014 00_00_00-00_00_30.gif

如果想更快或更慢,就可以自己调试。那么再接上面的需求,我们就可以修改速度不就行了吗?当距离隔得比较远的时候我们就设置速度快一些,当隔的比较近的时候我们设置速度慢一些。

public class SmoothLinearLayoutManager extends LinearLayoutManager {

    private float MILLISECONDS_PER_INCH = 25f;
    private Context contxt;

    public SmoothLinearLayoutManager(Context context) {
        super(context);
        this.contxt = context;
    }

    public SmoothLinearLayoutManager(Context context, int orientation, boolean reverseLayout) {
        super(context, orientation, reverseLayout);
        this.contxt = context;
    }

    @Override
    public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state, int position) {

        LinearSmoothScroller linearSmoothScroller = new LinearSmoothScroller(recyclerView.getContext()) {
            private int distance = 0;

            @Override
            protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {

                final int dx = calculateDxToMakeVisible(targetView, getHorizontalSnapPreference());
                final int dy = calculateDyToMakeVisible(targetView, getVerticalSnapPreference());

                //获取滚动距离
                distance = (int) Math.sqrt(dx * dx + dy * dy);
                //根据滚动距离计算时间
                final int time = calculateTimeForDeceleration(distance);

                if (time > 0) {
                    action.update(-dx, -dy, time, mDecelerateInterpolator);
                }
            }

            @Override
            public PointF computeScrollVectorForPosition(int targetPosition) {
                return super.computeScrollVectorForPosition(targetPosition);
            }

            @Override
            protected int calculateTimeForDeceleration(int dx) {
                return 250;
            }
            
            @Override
            protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
                return 15f / displayMetrics.densityDpi;
            }
        };

        linearSmoothScroller.setTargetPosition(position);
        startSmoothScroll(linearSmoothScroller);
    }

}

再从远处滚到到一个长距离的索引的效果:

device-2023-03-25-112854 00_00_00-00_00_30.gif

方案三:自定义滚动与刷新

其实到这里已经基本满足产品的需求了,但是我们追求细节的话,其实也可以看到从 0 到 75 的索引是稍微大于 250ms 的。

为什么呢?这就要看源码...,好吧直接讲结论。

其实 RV 的滚动原理就是从第一帧的动画回调开始就开始找 View ,查看当前 Position 是否在屏幕上了。如果指定的 View 没有在屏幕上,那么就执行 onSeekTargetStep 继续找,如果不在就继续找,一直到找到View在屏幕上了才会调用 onTargetFound 方法。所以我们上面的方式直接从 onTargetFound 拿参数就已经是晚了。已经执行了N次 onTargetFound 和动画方法了。只是我们设置了动画时间短显得比较快而已。

    //太远了,没有找到View
    @Override
    protected void onSeekTargetStep(int dx, int dy, RecyclerView.State state, Action action) {
        YYLogUtils.w("太远了,没有找到View  dy:"+dy);
        super.onSeekTargetStep(dx, dy, state, action);
    }

    //慢慢滚慢慢找,找到了!
    @Override
    protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
        YYLogUtils.w("慢慢滚慢慢找,找到了");

        //下面才开始滚动到真正的位置
    }

可以看到调用的顺序:

image.png

所以如果真的要针对性的优化这一点话,我们可以绕过这些流程,直接做到另一种效果:如果需要滚动的距离大于一屏高度,我们就只滚动一屏的高度,然后直接刷新到指定的位置,比如:scrollToPositionWithOffset 。

我们修改代码如下:

        LinearSmoothScroller linearSmoothScroller = new LinearSmoothScroller(recyclerView.getContext()) {

            boolean startScrolling = false;

            //太远了,没有找到View
            @Override
            protected void onSeekTargetStep(int dx, int dy, RecyclerView.State state, Action action) {
             
                if (!startScrolling) {
                    startScrolling = true;

                    int height = recyclerView.getMeasuredHeight();
                    recyclerView.smoothScrollBy(0, height);

                    recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {

                        @Override
                        public void onScrollStateChanged(@NonNull RecyclerView rv, int newState) {
                            if (startScrolling && newState == RecyclerView.SCROLL_STATE_IDLE) {
                                scrollToPositionWithOffset(position, 0);
                                recyclerView.removeOnScrollListener(this);
                                startScrolling = false;
                            }
                        }

                        @Override
                        public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
                        }
                    });
                }

            }

            //慢慢滚慢慢找,找到了!
            @Override
            protected void onTargetFound(View targetView, RecyclerView.State state, Action action) {
        
                final int dx = calculateDxToMakeVisible(targetView, getHorizontalSnapPreference());
                final int dy = calculateDyToMakeVisible(targetView, getVerticalSnapPreference());

                //获取滚动距离
                final int distance = (int) Math.sqrt(dx * dx + dy * dy);
                //根据滚动距离计算时间
                final int time = calculateTimeForDeceleration(distance);

                if (time > 0) {
                    action.update(-dx, -dy, time, mDecelerateInterpolator);
                }
            }

            @Override
            public PointF computeScrollVectorForPosition(int targetPosition) {
                return super.computeScrollVectorForPosition(targetPosition);
            }

            @Override
            protected int calculateTimeForDeceleration(int dx) {
                return 250;
            }

            @Override
            protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
                return 15f / displayMetrics.densityDpi;
            }


        };

        linearSmoothScroller.setTargetPosition(position);
        startSmoothScroll(linearSmoothScroller);
    }

这样就是先滚着,滚动到指定距离之后再刷新到指定的索引:

device-2023-03-25-135316 00_00_00-00_00_30.gif

看似还行,但是这个方案有一点不完美,就是滚动完成之后刷新的那一下卡顿效果有一点突兀。

方案四:自定义刷新与滚动

那其实我们换一个思路,先刷新到离当前 Position 的一屏幕距离然后再滚过去不就行了吗?

听起来就比较靠谱,这里分为索引的实现方式与距离的实现方式:


         @Override
        protected void onSeekTargetStep(int dx, int dy, RecyclerView.State state, Action action) {
            YYLogUtils.w("太远了,没有找到View  dy:" + dy + " action-dy:" + action.getDy() + " action-du:" + action.getDuration());

            //真实场景需要判断索引与方向
            if (!startScrolling) {
                startScrolling = true;

                int firstPos = findFirstVisibleItemPosition();

                //根据真实场景判断是否超过索引边界与展示边界
                if (firstPos < position) {
                    scrollToPositionWithOffset(position - 10, 0);
                } else {
                    scrollToPositionWithOffset(position + 10, 0);
                }

                recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {

                    @Override
                    public void onScrollStateChanged(@NonNull RecyclerView rv, int newState) {
                        if (startScrolling && newState == RecyclerView.SCROLL_STATE_IDLE) {
                            recyclerView.removeOnScrollListener(this);
                            startScrolling = false;
                        }
                    }

                    @Override
                    public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
                        }
                });

                recyclerView.smoothScrollToPosition(position);
            }

        }

下面一种是根据距离来实现:

       @Override
        protected void onSeekTargetStep(int dx, int dy, RecyclerView.State state, Action action) {
            YYLogUtils.w("太远了,没有找到View  dy:" + dy + " action-dy:" + action.getDy() + " action-du:" + action.getDuration());

            //真实场景需要判断索引与方向
            int firstPos = findFirstVisibleItemPosition();
            int lastPos = findLastVisibleItemPosition();
            PointF pointF = computeScrollVectorForPosition(position);
            int height = recyclerView.getMeasuredHeight();

            float distance = Math.abs((position - firstPos) * getDecoratedMeasuredHeight(getChildAt(0))) / pointF.y;

            if (distance > 0) {
                recyclerView.scrollBy(0, (int) distance - height);
            } else {
                recyclerView.scrollBy(0, (int) distance + height);
            }

            recyclerView.smoothScrollToPosition(position);
        }

效果,从0 滚动到 75 索引:

device-2023-03-25-145806 00_00_00-00_00_30.gif

这生成的都是什么鬼GIF 。原谅我这录制工具...因为不是录屏是MP4转的,效果不好,大家有条件可以去自行实现或运行Demo。

总结

看到这里大家应该对这些滚动效果有所了解,如何 scrollToPosition 并置顶,如何 smoothScrollToPosition 并置顶。

这也是我们常用的效果,一般来说我们只用到上面的几种方法即可,如果要实现产品这种固定时长的滚动的类似效果,大家也可以参考第三点的四种方案来实现。

由于这些滚动效果是跟业务逻辑关联的,很多地方都是伪代码,并没有完善也没有解决索引越界之类的问题,如果大家有需要还是需要参考来实现的。

惯例了,我如有讲解不到位或错漏的地方,希望同学们可以指出。

我知道各位大神都有各种骚操作实现这些效果,如果有更好的方式或其他方式,或者你有遇到的坑也都可以在评论区交流一下,大家互相学习进步嘛。

本文的部分代码可以在我的 Kotlin 测试项目中看到,【传送门】。你也可以关注我的这个Kotlin项目,我有时间都会持续更新。

Ok,这一期就此完结。

本文正在参加「金石计划」