最近碰到好几个使用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". 这个能帮助我们理解何时要重写哪些方法.
最后要感谢诸多给过我帮助的文章, 如: