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 测试项目中看到,【传送门】。你也可以关注我的这个Kotlin项目,我有时间都会持续更新。
Ok,这一期就此完结。
本文正在参加「金石计划」