LinearSmoothScroller 使用教程

1,566 阅读6分钟

在掘金,CSDN和StackOverFlow中没有看到一篇好的、全面的LinearSmoothScroller使用教程,兹写下此文。

I. 滚动的RecyclerView

要让RecyclerView开始滚动,我们有以下几种方法(不全):

  1. RecyclerView / LayoutManager 类 中的 scrollTo 方法 (滚动至任意位置)
  1. RecyclerView / LayoutManager 类 中的 scrollToPosition 方法 (滚动至特定Item位置)
  1. RecyclerView / LayoutManager 类 中的 smoothScrollBy 方法 (平滑滚动至任意位置)
  1. RecyclerView / LayoutManager 类 中的 smoothScrollTo 方法 (平滑滚动至特定Item位置)
  1. LayoutManager 类 中的 startSmoothScroll 方法 (使用外部创建的 RecyclerView.SmoothScroller,平滑滚动至特定Item位置)

现在我们可以来分析一下现有的实现滚动的方法了 ——

1, 2 都是非平滑滚动,也就是瞬间滚动至指定位置。3, 4 都是平滑滚动,且 4 可以特定滚动插值器。5 调用了外部的 SmoothScroller 实现平滑滚动。

1, 3 相比,2, 4, 5 的滚动至Item位置更加省事,但是可能就有朋友发现了,这几种方法都不能自定义插值器以及滚动时长,而唯一能指定插值器的 3 却无法帮助我们方便的滚动到特定Item位置、自定义时间,甚至如果滚动路程过长,3 的动画就会显得又臭又长

那么,难道我们就没法方便的滚动到特定的Item位置吗?难道我们非要绞尽脑汁自己写出定位位置,自己实现动画过度,写出一个可以用的滚动实现么?不————

诸君,且留步,听我娓娓道来——

II. LinearSmoothScroller,堂堂登场!

LinearSmoothScroller,继承 RecyclerView.SmoothScroller,是一个现成的滚动工具类。 它已经帮我们写好了位置定位,动画过渡,那么我们来看看怎么使用它吧。

构建 LinearSmoothScroller

val mLinearSmoothScroller = object : LinearSmoothScroller(context) {}
mLinearSmoothScroller.targetPosition = seekPosition                       // 滚动目标在 adapter 中的位置
mLinearLayoutManager.startSmoothScroll(mLinearSmoothScroller)

调教 LinearSmoothScroller

好,那么我们先写一个实例应用试试吧!

5个 ViewHolder,15 个 ViewHolder,滚动顺利...... 不对!出问题了!在 ViewHolder 过长的时候, smoothScroller 的动画会显得拖沓!

没有关系,让我们来复写滚动时间吧,让动画看起来更顺畅一点。

private fun createSmoothScroller(): RecyclerView.SmoothScroller {
        return object : CustomSmoothScroller(context) {
​
            override fun calculateTimeForDeceleration(dx: Int): Int {
                return 500
            }
            
        }
    }

再次实验,是不是顺畅多了?

这个时候,有的朋友又有新问题了:我如何让指定的 ViewHolder 出现在屏幕顶端?底端?或者是我想要的任意位置?

别着急,这个也简单,让我们再来复写几个 method 就是了:

override fun getVerticalSnapPreference(): Int {
    return SNAP_TO_START
}

返回值可以有这几种:

public static final int SNAP_TO_START = -1;
public static final int SNAP_TO_END = 1;
public static final int SNAP_TO_ANY = 0;

第一种返回值 SNAP_TO_START,表示一直滚动,直到目标在屏幕顶端为止。 第二种返回值 SNAP_TO_END,表示一直滚动,直到目标在屏幕底端为止。 第三种返回值 SNAP_TO_ANY,表示一直滚动,直到目标出现在屏幕里为止。

如果我们想要滚动直到目标出现在距屏幕顶端 72px 的地方,怎么处理?

override fun calculateDtToFit(
    viewStart: Int,
    viewEnd: Int,
    boxStart: Int,
    boxEnd: Int,
    snapPreference: Int
): Int {
    return super.calculateDtToFit(viewStart, viewEnd, boxStart, boxEnd, snapPreference) + 72
}

好,近乎完美,现在你已经学会了如何使用基础的 LinearSmoothScroller,而网上的教程大多到此为止——下面只剩下我夹带的干货了。

III. LinearSmoothScroller 到底是如何工作的?

通过分析源码,我们可以得知,LinearSmoothScroller 会先计算目标 ViewHolder 距离目的地的位置,然后再做出情况判断:

  • 第一种情况:如果目标在屏幕上可视,调用 onTargetFound ,利用 mDecelerateInterpolator 这个插值器来进行平滑的减速。
  • 第二种情况:如果目标在屏幕上不可视,调用 updateActionForInterimTarget,利用 mLinearInterpolator 线性滚动直到目标在屏幕中可见,再利用 onTargetFound 以及 mDecelerateInterpolator 来平滑减速到目的地。

在上一段里,我们提到了 在 ViewHolder 过长的时候, smoothScroller 的动画会显得拖沓 的问题,那么我们再次深入源码来分析看看:

/**
 * Calculates the time it should take to scroll the given distance (in pixels)
 *
 * @param dx Distance in pixels that we want to scroll
 * @return Time in milliseconds
 * @see #calculateSpeedPerPixel(android.util.DisplayMetrics)
 */
protected int calculateTimeForScrolling(int dx) {
    // In a case where dx is very small, rounding may return 0 although dx > 0.
    // To avoid that issue, ceil the result so that if dx > 0, we'll always return positive
    // time.
    return (int) Math.ceil(Math.abs(dx) * getSpeedPerPixel());
}
​
private float getSpeedPerPixel() {
    if (!mHasCalculatedMillisPerPixel) {
        mMillisPerPixel = calculateSpeedPerPixel(mDisplayMetrics);
        mHasCalculatedMillisPerPixel = true;
    }
    return mMillisPerPixel;
}
​
/**
 * Calculates the scroll speed.
 *
 * <p>By default, LinearSmoothScroller assumes this method always returns the same value and
 * caches the result of calling it.
 *
 * @param displayMetrics DisplayMetrics to be used for real dimension calculations
 * @return The time (in ms) it should take for each pixel. For instance, if returned value is
 * 2 ms, it means scrolling 1000 pixels with LinearInterpolation should take 2 seconds.
 */
@SuppressLint("UnknownNullness") // b/240775049: Cannot annotate properly
protected float calculateSpeedPerPixel(DisplayMetrics displayMetrics) {
    return MILLISECONDS_PER_INCH / displayMetrics.densityDpi;
}

好了,那么现在我们知道为何会出问题了:MILLISECONDS_PER_INCH 永远是一个定值,calculateSpeedPerPixel 虽然是按照 DPI 来计算滚动时间了,但并没有将滚动长度考虑在内,因为 LinearSmoothScroller 会假设 calculateSpeedPerPixel 的返回值永远不会变,缓存这个值。

网上很多教程都会叫你去复写 calculateSpeedPerPixel ,来将速度提高,实际上我测试的效果也不理想,在 RecyclerView 项目过多的时候还是会出现卡顿的现象,而最理想,最简单的 Workaround 便是直接将 calculateTimeForScrolling 设置为一个定值,这样滚动的速度会按照滚动时间来动态计算。

IV. 美化 LinearSmoothScroller 的滚动过程

什么?你说 LinearSmoothScroller 的滚动过程不动感,不美观?你在做 LyricView,想要让滚动过程和你的 View 动画过程变得统一?

小菜一碟~

前面我们说了,在进行滚动的时候 LinearSmoothScroller 会调用两个插值器,那么修改这两个插值器就可以了。mLinearInterpolator 其实如果没有特别的需求不用修改,因为在长时间线性滚动的时候用插值器和不用插值器其实区别不大。我们只需要修改 mDecelerateInterpolator 便能达到很好的效果。

当然,LinearSmoothScroller 不允许你直接复写这两个插值器。幸运的是 LinearSmoothScroller 不会调用什么奇怪的内部组件和受限的 API,我们只需复制粘贴 LinearSmoothScroller 的内容,改个名字就可以了。

在这里推荐使用 material-componentsmotionEasingStandardInterpolator

protected TimeInterpolator mDecelerateInterpolator;
​
@SuppressLint("UnknownNullness") // b/240775049: Cannot annotate properly
public CustomSmoothScroller(Context context) {
    mDisplayMetrics = context.getResources().getDisplayMetrics();
    mDecelerateInterpolator = MotionUtils.resolveThemeInterpolator(
        context,
        R.attr.motionEasingStandardInterpolator,  // interpolator theme attribute
        FastOutSlowInInterpolator()  // default fallback interpolator
    );
}

Voila!

V.结尾

本篇教程就到此结束了,如果有帮助,不妨给我点个赞,也可以给我的 Github 点个关注,谢谢你。

如果你想要一个现有的示例程序,欢迎到这里看看

下一次我们将讨论如何在 RecyclerView 中使用纯数学方法(三次贝塞尔曲线拟合)来实现 Apple Music 中的延迟歌词效果,如果感兴趣请持续关注。