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

RV如何滚动到指定索引

前言

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

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

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

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

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

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

那这又有什么区别?

比如我们要滚动到第 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 的图片演示:

我要滚动的是第 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)
        }
    }
复制代码

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

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

二、smoothScrollToPosition的使用

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

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

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

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

所以我们只能间接的通过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) {

            }
        })
复制代码

效果为:

这不就行了吗?

下面给出完整的工具类方法,如果大家想要横向的滚动或者其他 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);
        }
    }
复制代码

打印结果如下:

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

    @Override
    protected int calculateTimeForDeceleration(int dx) {
        return 5000;
    }
复制代码

打印日志:

效果就是:

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

 

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

方案二:指定滚动速度

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

比如:

    @Override
    protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {

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

效果为:

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

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);
    }

}
复制代码

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

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

其实到这里已经基本满足产品的需求了,但是我们追求细节的话,其实也可以看到从 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("慢慢滚慢慢找,找到了");

        //下面才开始滚动到真正的位置
    }
复制代码

可以看到调用的顺序:

所以如果真的要针对性的优化这一点话,我们可以绕过这些流程,直接做到另一种效果:如果需要滚动的距离大于一屏高度,我们就只滚动一屏的高度,然后直接刷新到指定的位置,比如: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);
    }
复制代码

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

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

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

那其实我们换一个思路,先刷新到离当前 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 索引:

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

总结

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

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

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

本文的部分代码可以在我的 Kotlin 测试项目中看到,【传送门】

作者:newki
链接:https://juejin.cn/post/7214854106178551863
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值