为什么 RecyclerView 的平滑滚动不能很好地与某些 Interpolator 类 一起工作?

Why doesn't smooth scroll of RecyclerView work well with some Interpolator classes?

背景

为了对水平 RecyclerView 上的项目做一个漂亮而简短的概述,我们想要一个类似弹跳的动画,它从某个位置开始,然后转到 RecyclerView 的开头(比如,从项目3 到项目 0) .

问题

出于某种原因,所有 Interpolator classes I try (illustration available here) 似乎都不允许项目超出 RecyclerView 或在其上反弹。

更具体地说,我已经尝试过 OvershootInterpolator 、 BounceInterpolator 和其他一些类似的。我什至尝试过 AnticipateOvershootInterpolator。在大多数情况下,它只是简单地滚动,没有特殊效果。在 AnticipateOvershootInterpolator 上,它甚至不滚动...

我试过的

这是我制作的 POC 代码,用于说明问题:

MainActivity.kt

class MainActivity : AppCompatActivity() {
    val handler = Handler()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val itemSize = resources.getDimensionPixelSize(R.dimen.list_item_size)
        val itemsCount = 6
        recyclerView.adapter = object : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
            override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
                val imageView = ImageView(this@MainActivity)
                imageView.setImageResource(android.R.drawable.sym_def_app_icon)
                imageView.layoutParams = RecyclerView.LayoutParams(itemSize, itemSize)
                return object : RecyclerView.ViewHolder(imageView) {}
            }

            override fun getItemCount(): Int = itemsCount

            override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
            }
        }
        val itemToGoTo = Math.min(3, itemsCount - 1)
        val scrollValue = itemSize * itemToGoTo
        recyclerView.post {
            recyclerView.scrollBy(scrollValue, 0)
            handler.postDelayed({
                recyclerView.smoothScrollBy(-scrollValue, 0, BounceInterpolator())
            }, 500L)
        }
    }
}

activity_main.xml

<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/recyclerView" xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent"
    android:layout_height="@dimen/list_item_size" android:orientation="horizontal"
    app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>

gradle 文件

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'

android {
    compileSdkVersion 28
    defaultConfig {
        applicationId "com.example.myapplication"
        minSdkVersion 15
        targetSdkVersion 28
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation 'androidx.appcompat:appcompat:1.0.0-rc02'
    implementation 'androidx.core:core-ktx:1.0.0-rc02'
    implementation 'androidx.constraintlayout:constraintlayout:1.1.2'
    implementation 'androidx.recyclerview:recyclerview:1.0.0-rc02'
}

下面是它如何查找 BounceInterpolator 的动画,如您所见,它根本没有反弹:

示例 POC 项目可用 here

问题

为什么它没有按预期工作,我该如何解决?

RecyclerView 可以与 Interpolator 一起很好地滚动吗?


编辑:这似乎是一个错误,因为我不能使用任何 "interesting" 插值器来滚动 RecyclerView,所以我已经报告了它 here

我会看一下 Google 的支持动画包。具体来说 https://developer.android.com/reference/android/support/animation/DynamicAnimation#SCROLL_X

它看起来像:

SpringAnimation(recyclerView, DynamicAnimation.SCROLL_X, 0f)
        .setStartVelocity(1000)
        .start()

更新:

看来这也不行。我查看了 RecyclerView 的一些源代码,反弹插值器不起作用的原因是 RecyclerView 没有正确使用插值器。调用 computeScrollDuration 调用插值器,然后获取以秒为单位的原始动画时间,而不是作为总动画时间百分比的值。这个值也不是完全可预测的,我测试了几个值,发现在 100 毫秒到 250 毫秒之间。无论如何,据我所知,您有两种选择(我都测试过)

  1. 使用另一个库,例如 https://github.com/EverythingMe/overscroll-decor

  2. 实现自己的属性并使用spring动画:


class ScrollXProperty : FloatPropertyCompat("scrollX") {

    override fun setValue(obj: RecyclerView, value: Float) {
        obj.scrollBy(value.roundToInt() - getValue(obj).roundToInt(), 0)
    }

    override fun getValue(obj: RecyclerView): Float =
            obj.computeHorizontalScrollOffset().toFloat()
}

然后在您的弹跳中,将对 smoothScrollBy 的调用替换为以下变体:

SpringAnimation(recyclerView, ScrollXProperty())
    .setSpring(SpringForce()
            .setFinalPosition(0f)
            .setStiffness(SpringForce.STIFFNESS_LOW)
            .setDampingRatio(SpringForce.DAMPING_RATIO_MEDIUM_BOUNCY))
    .start()

更新:

第二种解决方案开箱即用,无需更改您的 RecyclerView,是我编写并经过全面测试的解决方案。

关于插值器的更多信息,smoothScrollBy 不能很好地与插值器一起工作(可能是一个错误)。使用插值器时,您基本上将 0-1 值映射到另一个值,这是动画的乘数。示例:t=0, interp(0)=0 表示在动画开始时值应与开始时相同,t=.5, interp(.5)=.25 表示元素将动画 1 /4 等等。反弹插值器基本上 return 值在某个点 > 1 并在 1 左右振荡,直到 t=1 时最终稳定在 1。

#2 的解决方案是使用 spring 动画师但需要更新滚动。 SCROLL_X 不起作用的原因是 RecyclerView 实际上没有滚动(那是我的错误)。它根据不同的计算来计算视图的位置,这就是您需要调用 computeHorizontalScrollOffset 的原因。 ScrollXProperty 允许您更改 RecyclerView 的水平滚动,就像您在 ScrollView 中指定 scrollX 属性 一样,它基本上是一个适配器。 RecyclerViews 不支持滚动到特定的像素偏移量,仅支持平滑滚动,但 SpringAnimation 已经为您平滑地完成了,因此我们不需要它。相反,我们想滚动到一个离散的位置。参见 https://android.googlesource.com/platform/frameworks/support/+/247185b98675b09c5e98c87448dd24aef4dffc9d/v7/recyclerview/src/android/support/v7/widget/RecyclerView.java#387

更新:

这是我用来测试的代码https://github.com/yperess/Whosebug/tree/52148251

更新:

使用插值器得到相同的概念:

class ScrollXProperty : Property<RecyclerView, Int>(Int::class.java, "horozontalOffset") {
    override fun get(`object`: RecyclerView): Int =
            `object`.computeHorizontalScrollOffset()

    override fun set(`object`: RecyclerView, value: Int) {
        `object`.scrollBy(value - get(`object`), 0)
    }
}

ObjectAnimator.ofInt(recycler_view, ScrollXProperty(), 0).apply {
    interpolator = BounceInterpolator()
    duration = 500L
}.start()

GitHub 上的演示项目已更新

我更新了 ScrollXProperty 以包含优化,它似乎在我的 Pixel 上运行良好,但我没有在旧设备上测试过。

class ScrollXProperty(
        private val enableOptimizations: Boolean
) : Property<RecyclerView, Int>(Int::class.java, "horizontalOffset") {

    private var lastKnownValue: Int? = null

    override fun get(`object`: RecyclerView): Int =
            `object`.computeHorizontalScrollOffset().also {
                if (enableOptimizations) {
                    lastKnownValue = it
                }
            }

    override fun set(`object`: RecyclerView, value: Int) {
        val currentValue = lastKnownValue?.takeIf { enableOptimizations } ?: get(`object`)
        if (enableOptimizations) {
            lastKnownValue = value
        }
        `object`.scrollBy(value - currentValue, 0)
    }
}

GitHub 项目现在包含带有以下插值器的演示:

<string-array name="interpolators">
    <item>AccelerateDecelerate</item>
    <item>Accelerate</item>
    <item>Anticipate</item>
    <item>AnticipateOvershoot</item>
    <item>Bounce</item>
    <item>Cycle</item>
    <item>Decelerate</item>
    <item>Linear</item>
    <item>Overshoot</item>
</string-array>

就像我说的,老实说,我不希望 Release Candidaterecyclerview:1.0.0-rc02 在你的情况下)会正常工作而不会引起任何问题,因为它已经在开发中.

  • 使用第三方库可能行得通,但我真的不这么认为,因为它们刚刚引入了 androidx 依赖项并且它们已经在开发中并且 不够稳定,无法被其他开发者使用。

更新 Google 开发者的回答:

We have passed this to the development team and will update this issue with more information as it becomes available.

所以,最好等待新的更新。

您需要以某种方式启用滚动反弹。

可能的解决方案之一是使用 https://github.com/chthai64/overscroll-bouncy-android

将其包含在您的项目中

implementation 'com.chauthai.overscroll:overscroll-bouncy:0.1.1'

然后在您的 POV 中将 activity_main.xml 中的 RecyclerView 更改为 com.chauthai.overscroll.RecyclerViewBouncy

<?xml version="1.0" encoding="utf-8"?>
<com.chauthai.overscroll.RecyclerViewBouncy
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/recyclerView"
    android:layout_width="match_parent"
    android:layout_height="@dimen/list_item_size"
    android:orientation="horizontal"
    app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"/>

此更改后,您将在应用中看到弹跳。