RV怎么翻滚到指定索引

前语

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

确实,RV 内部提供了一系列的翻滚办法:

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

难道这些都不能完结指定到翻滚索引的逻辑?额,当然能,可是又不是那么能!

为什么这么说?或许咱们对 翻滚到指定索引 这个需求的理解有所误差。

谷歌理解的 翻滚到指定索引 是当时索引在屏幕上可见了就到达意图,而咱们需求的作用是展现到指定索引并且在顶部展现。

那这又有什么区别?

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

比方咱们要翻滚到第 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 的图片演示:

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

我要翻滚的是第 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)
        }
    }

那么咱们经过这个办法去翻滚的话,那么作用如下:

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

没错这样才是我(产品)想要的作用!

二、smoothScrollToPosition的运用

虽然能完结作用了,可是有些时分,我(产品)更喜爱用一些翻滚作用,这中选中作用太突兀了,只适合一些初始化选中的作用,当用户点击按钮或操作之后,咱们的 RV 缓缓翻滚到指定的索引位置,看起来很美!

咱们先试试原生的 smoothScrollToPosition 运用作用,还是分为上面的三种状况,那么作用便是如下:

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

还是会有相同的问题,那么咱们能不能经过像上面相同的办法来判别呢?能,又不能。

思路是一个思路,可是完结的进程不同了,由于不同的间隔的翻滚进程与翻滚时长是不同的,所以咱们至少需求在翻滚完结之后的监听中进行处理,可是咱们有翻滚完结的监听吗?没有!

所以咱们只能间接的经过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) {
            }
        })

作用为:

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

这不就行了吗?

下面给出完好的工具类办法,假如咱们想要横向的翻滚或许其他 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);
        }
    }

打印成果如下:

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

能够看到确实是翻滚的间隔越长,所需求的时刻也是越长的。假如咱们需求修正翻滚的时刻,那么还需求修正翻滚的速度,应该这个 calculateTimeForDeceleration 办法,假如想定死翻滚的时长咱们能够直接重写 calculateTimeForDeceleration 或 calculateTimeForScrolling 即可。

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

打印日志:

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

作用便是:

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

咱们改为实在的 250ms 之后感觉还行,可是假如翻滚间隔太长,而实际动画时刻太短,会导致更难看的作用:

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

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

计划二:指定翻滚速度

先说怎么改变翻滚速度,咱们只需求重写 calculateSpeedPerPixel 办法即可,内部完结滑动一个像素需求多少毫秒。

比方:

    @Override
    protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
        //滑动一个像素需求多少毫秒
        return 25f / displayMetrics.density;
    }

作用为:

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

假如想更快或更慢,就能够自己调试。那么再接上面的需求,咱们就能够修正速度不就行了吗?当间隔隔得比较远的时分咱们就设置速度快一些,当隔的比较近的时分咱们设置速度慢一些。

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

再从远处滚到到一个长间隔的索引的作用:

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

计划三:自定义翻滚与改写

其实到这儿现已基本满足产品的需求了,可是咱们寻求细节的话,其实也能够看到从 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("渐渐滚渐渐找,找到了");
        //下面才开端翻滚到实在的位置
    }

能够看到调用的次序:

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

所以假如真的要针对性的优化这一点话,咱们能够绕过这些流程,直接做到另一种作用:假如需求翻滚的间隔大于一屏高度,咱们就只翻滚一屏的高度,然后直接改写到指定的位置,比方: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);
    }

这样便是先滚着,翻滚到指定间隔之后再改写到指定的索引:

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

看似还行,可是这个计划有一点不完美,便是翻滚完结之后改写的那一下卡顿作用有一点突兀。

计划四:自定义改写与翻滚

那其实咱们换一个思路,先改写到离当时 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 索引:

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

这生成的都是什么鬼GIF 。原谅我这录制工具…由于不是录屏是MP4转的,作用欠好,咱们有条件能够去自行完结或运行Demo。

总结

看到这儿咱们应该对这些翻滚作用有所了解,怎么 scrollToPosition 并置顶,怎么 smoothScrollToPosition 并置顶。

这也是咱们常用的作用,一般来说咱们只用到上面的几种办法即可,假如要完结产品这种固定时长的翻滚的类似作用,咱们也能够参阅第三点的四种计划来完结。

由于这些翻滚作用是跟事务逻辑关联的,许多当地都是伪代码,并没有完善也没有解决索引越界之类的问题,假如咱们有需求还是需求参阅来完结的。

惯例了,我如有解说不到位或错漏的当地,期望同学们能够指出。

我知道各位大神都有各种骚操作完结这些作用,假如有更好的办法或其他办法,或许你有遇到的坑也都能够在谈论区沟通一下,咱们互相学习进步嘛。

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

Ok,这一期就此完结。

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

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