RecyclerView的smooth scroller -- 诸多案例

最近碰到好几个使用LienarSmoothScroll(下方简称为LSS)的场景, 让我对这个类的了解更加进一步, 所以分享在这, 希望对有需要的同学有所帮助. 我个人不太喜欢太理论的东西, 所以整篇文章几乎全是我做过的案例, 也方便也有类似需求的同学对号入座地取用.

案例一: 提高smooth scroll速度

SmoothScroll(下方简称SS)是最常见的一个需求. 我们一般是使用

java 复制代码
recyclerView.smoothScrollToPosition(p);

但要是你的RecyclerView(下方简称rv)很多内容, 这样你从第0页, SmoothScroll(SS)到第100多项, 可能会耗时很久. 所以产品可能就有了想提升一下这个SS速度的需求.

要提升速度, 就要重写 LienarSmoothScroll(LSS)的calculateSpeedPerPixel方法, 它的源码是:

java 复制代码
protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
    return 25f / displayMetrics.densityDpi;
} 

这个函数返回的就是"经过第一个像素所要花费的时间", 那自然是这个返回值越大, 说明同样滑过100个像素, 所花费的时间就更多. 所以这个方法的返回值越大, 那说明SS的速度越慢

那要提升速度就容易了, 就是让这个返回值变小:

kotlin 复制代码
    // 取代了原来的 rv.smoothScrollToPosition(pos)
    private fun RecyclerView.fastSmoothScrollTo(pos: Int) {
        val scroller = object : LinearSmoothScroller(this.context) {
            //经过每个pixel的时间越长(即本函数返回的float), 表示速度就越慢
            override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics): Float {
                // 默认是 return 25f / displayMetrics.densityDpi;
                return 6f / displayMetrics.densityDpi //这里值更小了, 所以速度更快了
            } //这里改25f为6f, 那速度就相当快了. 滑动距离小于两屏的, 几乎瞬时就到了
        }
        scroller.targetPosition = pos
        this.layoutManager?.startSmoothScroll(scroller)
    }

引申

要是对LSS有些了解的同学, 就会发现LSS还有两个函数, 也跟滑动时间, 也可以说滑动速度相关啦, 这两个方法是:

kotlin 复制代码
override fun calculateTimeForScrolling(dx: Int): Int 

override fun calculateTimeForDeceleration(dx: Int): Int 

这个具体的分别, 要到下面的案例中才会讲到. 现在就掰开来细讲, 就会太枯燥, 要结合下面案例来讲才能理解. 总之, 结论就是, 重写这两个函数并不能帮我们调整SS的速度

案例二: 将SS的总时间调整为一致

上面的案例一提升了SS的速度, 但有一个问题, 那就是从第0项滑到第5项时, 几乎没有SS的效果, 相当于直接到位, 类似于 scrollTo(position)的效果.

这时产品想要从第0项滑到第5项, 与从第0项滑到第25项的时间, 要基本相同, 这样免得后者要滑好久, 或者前者几乎没怎么滑就到了(少了动画的顺畅感).

在这一块需求上, 已经有博主做出相应的研究了, 如<RV 的 scrollToPosition 你真的会吗?看我骚操作!>一文中, 为了达到这个目标, 博主重写了onSeekTargetStep等诸多方法, 并给rv加上了OnScrollListener来监听滑动.

作用肯定是行的, 但我个人觉得稍有点麻烦, 所以我想到了另一个折中方案, 也就是看当前item与target position差多少, 然后根据这个差值来做速度的调整

  • 若target position离当前项很远, 那速度就快一些
  • 若是二者离得较近, 那速度就慢一点

这样不就变相地达到了需求嘛.

所以我的折中方案是:

kotlin 复制代码
    private fun RecyclerView.smoothScrollEquallyTo(pos: Int) {
        val scroller = object : LinearSmoothScroller(context) {
            override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics): Float {
                // 源码是: return 25f / displayMetrics.densityDpi;
                val layoutMgr = this.layoutManager
                if(layoutMgr !is LinearLayoutManager) return super.calculateSpeedPerPixel(displayMetrics)
                val first = layoutMgr.findFirstVisibleItemPosition()
                val diff = abs(pos - first) //来看这个远不远
                val speed = 25f / diff * 5 //diff越大, 那25f/diff就越小, 那速度就越快.  (25f/diff就太快了, 根本没有SS效果, 所以再 * 5)
                val ret = speed / displayMetrics.densityDpi
                return ret
            }
        }

        scroller.targetPosition = pos
        layoutManager?.startSmoothScroll(scroller)
    }

我实测了一下, 发现确实比较合适. 但考虑到每个RV的item可能不一样, 有些item很短, 有些item很长, 所以这个不能速度不能订死了, 所以我把这个速度参数(speedFactor)给提取出来, 这样不同的rv可以指定不同的速度参数.

kotlin 复制代码
    // speedFactor越大, 那速度越慢
    private fun RecyclerView.smoothScrollEquallyTo(pos: Int, speedFactor: Int = 5) {
        val scroller = object : LinearSmoothScroller(context) {
            override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics): Float {
                // 源码是: return 25f / displayMetrics.densityDpi;
                val layoutMgr = this.layoutManager
                if(layoutMgr !is LinearLayoutManager) return super.calculateSpeedPerPixel(displayMetrics)
                val first = layoutMgr.findFirstVisibleItemPosition()
                val diff = abs(pos - first) //来看这个远不远
                val speed = 25f / diff * speedFactor //diff越大, 那25f/diff就越小, 那速度就越快. 
                val ret = speed / displayMetrics.densityDpi
                return ret
            }
        }

        scroller.targetPosition = pos
        layoutManager?.startSmoothScroll(scroller)
    }

案例三: 想让SS到的targetPosition最终是居于顶端

这个需求也常见吧, 让用户想要跳到的那项被滚动到顶端. 但是rv.smoothScrollToPosition(pos)却不能完全做到这一点. 原因是这个方法在以下三种情况下, 具体的SS行为是不一样的:

lua 复制代码
1).  当前页面展示0-3项; 这时SS到5项, 那就是滑动到第5项刚好完可见 
(这时自然5项是在最底下).
--> PO一般都是想说要滑到最开头

2). 当前页面展示的是7-10项, 这时SS到5, 结果也是滑动到第5项刚好可见
这时项5自然在最开头
--> 综合1)与2), 发现就是表相是滑动方向不同
(一个往下滑到第5项, 一个往上滑到第5项)导致了这个问题.
里子却是一一个"最少滑动量, 导致target项完全显示就停了, 不再SS了"的原则

3). 当前页面展示3-7项时,  这时SS到5, 结果是: 什么都没发生!
是的, 若已完全可见, 那调用smoothScroll都不会有任何事发现.

--> PO一般是想把项5放到最开头去

总结下, 那就是rv.smoothScrollToPosition(pos)遵循的是滑动最少的原则, 按不同的滑动方向, 只要target position那项item已经完全可见了, 就马上停止滑动; 要是target position已经可见了, 那根本不滑动.

这一块需求, 也有不少博客做出了解决方案. 如:

他们的方案都是针对上面的三种做法, 分别调用smoothScroll或是scrollBy来强制变更SS行为, 甚至还要添加OnScrollListener来保证滑动的持续性. (代码可见上面链接; 这里的贴图只是给出个大概的意思)

这样肯定能成功, 但我仍是在想有没有更简单的方案.

经过我的查找, 果然找到了更好的方案, 而且代码只有简单的方案. 其实就是要指定LSS中的snap mode, 我们只要指定其为snap_to_start, 那SS就会自动强制地将target position那项item给放顶端.

具体代码如下:

kotlin 复制代码
    private fun RecyclerView.smoothScrollAndSnapStartTo(pos: Int) {
        val scroller = object : LinearSmoothScroller(context) {
            // 若不指定这个SNAP_TO_START, 那默认就是 rv.smoothScrollTo(position)的效果, 即仍有RvScrollToPosition_Issue_Page.kt中所说的几个问题
            override fun getVerticalSnapPreference() = LinearSmoothScroller.SNAP_TO_START
        }
        scroller.targetPosition = pos
        layoutManager?.startSmoothScroll(scroller)
    }

备注: 要是你的rv是一个水平rv, 那就请重写getHorizontalSnapPreference() = SNAP_TO_START

案例四: snap_to_start还有一点点偏移量

好吧, 现在我们的UX给的设计稿是, RV上方(Z轴上)还有一层导航条. 这个导航条占住了rv的top一些部分

xml 复制代码
<FrameLayout>
     <RecyclerView top = 0/>
     <Buttons top = 0/>

类似这样的效果(即4个button代表的导航条, 位于rv之上):

那这时的SS, 如跳到第5项就不太如意, 因为被遮住了一部分. UX想让target position项能完全展示出来:

SS的原理

这时其实就是要看RV是如何SS的了.

  • 当我们指定要SS到target position去, 那RV就会一直滑动
  • 直到当target position出现了, 这时就进入了减速期
  • 在减速期里, 时间跟刚刚的滑动不一样, 而且要滑动多少能让target position正好出现, 这些都在LSS的onTargetFound方法里
java 复制代码
@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);
    }
}

也就是说, 在上面第二步中, target出现了时, 这时就马上计算距离最终益还要滑动多少, 以及要减速所花的时间. 而这个"要滑动多少", 则在于calculateDxToMakeVisible, calculateDyToMakeVisible两个方法里

计算减速期的滑动距离

calculateDxToMakeVisible, calculateDyToMakeVisible两个方法里其实都是在调用calculateDtToFit方法, 只不过参数不同而已.

其实就可以理解calculateDtToFit方法就是一个计算在SS减速期, 到底还要滑动多少距离的函数.

java 复制代码
// 下面的view参数, 就是指rv中某一item

    public int calculateDxToMakeVisible(View view, int snapPreference) {
        ...
        return calculateDtToFit(viewLeft, viewRight, rvLeft, rvRight, snapPreference);
    }
    
    public int calculateDyToMakeVisible(View view, int snapPreference) {
        ...
        return calculateDtToFit(viewTop, viewBottom, rvTop, rvBottom, snapPreference);
    }    

解决方案

现在看完这些源码就知道了, 我们只要在snap_to_start的基础上, 再在calculateDtToFit中提供一定的offset偏移量就行了.

kotlin 复制代码
   private fun RecyclerView.smoothScrollWithOffsetTo(pos: Int) {
        val topButtonsHeight = topButtonsLayout.height //=> 126

        val scroller = object : LinearSmoothScroller(context) {

            override fun getVerticalSnapPreference() = LinearSmoothScroller.SNAP_TO_START
            
            override fun calculateDtToFit(viewStart: Int, viewEnd: Int, boxStart: Int, boxEnd: Int, snapPreference: Int): Int {
                val computed = super.calculateDtToFit(viewStart, viewEnd, boxStart, boxEnd, snapPreference)
                if (snapPreference == SNAP_TO_START) {
                    return computed + topButtonsHeight
                }
                return computed
            }
        }
        scroller.targetPosition = pos
        layoutManager?.startSmoothScroll(scroller)
    }

这样当调用rv.smoothScrollWithOffsetTo(7)时, 结果就是:

案例五: 始终让SS的target item出现在页面中间

有时我们确实有这种需求, 比如说你用RV做一个WheelSelection时, 需求就是让selected项居于中间:

这时也不麻烦, 同样地只要重写calculateDtToFit即可:

kotlin 复制代码
    private fun RecyclerView.smoothScrollInCenterTo(pos: Int) {
        val scroller = object : LinearSmoothScroller(context) {
            override fun calculateDtToFit(viewStart: Int, viewEnd: Int, boxStart: Int, boxEnd: Int, snapPreference: Int): Int {
                return (boxStart + (boxEnd - boxStart) / 2) - (viewStart + (viewEnd - viewStart) / 2)
            }
        }
        scroller.targetPosition = pos
        layoutManager?.startSmoothScroll(scroller)
    }

这里的参数就是找到中间点的位置, 也就是rv与item的位置一比较, 即得出了中间点.

总结

通过上面四个案例, 我们了解了如何调整Smooth Scroll的速度, 最终位置等. 主要就是重写LinearSmoothScroll的这几个方法:

  • calculateSpeedPerPixel()
  • getVerticalSnapPreference()
  • getHorizontalSnapPreference()
  • calculateDtToFit()

以及了解了LinearSmoothScroll到底是如何进行smooth scroll的, 即 "普通smooth scroll + 当发现了target时就开始减速smooth". 这个能帮助我们理解何时要重写哪些方法.

最后要感谢诸多给过我帮助的文章, 如:

相关推荐
幻雨様5 小时前
UE5多人MOBA+GAS 45、制作冲刺技能
android·ue5
Jerry说前后端6 小时前
Android 数据可视化开发:从技术选型到性能优化
android·信息可视化·性能优化
Meteors.7 小时前
Android约束布局(ConstraintLayout)常用属性
android
alexhilton8 小时前
玩转Shader之学会如何变形画布
android·kotlin·android jetpack
whysqwhw12 小时前
安卓图片性能优化技巧
android
风往哪边走12 小时前
自定义底部筛选弹框
android
Yyyy48213 小时前
MyCAT基础概念
android
Android轮子哥13 小时前
尝试解决 Android 适配最后一公里
android
雨白14 小时前
OkHttp 源码解析:enqueue 非同步流程与 Dispatcher 调度
android
风往哪边走15 小时前
自定义仿日历组件弹框
android