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

Viewed 2831

Background

In an effort to make a nice&short overview of the items on a horizontal RecyclerView, we want to have a bounce-like animation , that starts from some position, and goes to the beginning of the RecyclerView (say, from item 3 to item 0) .

The problem

For some reason, all Interpolator classes I try (illustration available here) don't seem to allow items to go outside of the RecyclerView or bounce on it.

More specifically, I've tried OvershootInterpolator , BounceInterpolator and some other similar ones. I even tried AnticipateOvershootInterpolator. In most cases, it does a simple scrolling, without the special effect. on AnticipateOvershootInterpolator , it doesn't even scroll...

What I've tried

Here's the code of the POC I've made, to show the issue:

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 file

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'
}

And here's an animation of how it looks for BounceInterpolator , which as you can see doesn't bounce at all :

enter image description here

Sample POC project available here

The question

Why doesn't it work as expected, and how can I fix it?

Could RecyclerView work well with Interpolator for scrolling ?


EDIT: seems it's a bug, as I can't use any "interesting" interpolator for RecyclerView scrolling, so I've reported about it here .

3 Answers

I would take a look at Google's support animation package. Specifically https://developer.android.com/reference/android/support/animation/DynamicAnimation#SCROLL_X

It would look something like:

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

UPDATE:

Looks like this doesn't work either. I looked at some of the source for RecyclerView and the reason that the bounce interpolator doesn't work is because RecyclerView isn't using the interpolator correctly. There's a call to computeScrollDuration the calls to the interpolator then get the raw animation time in seconds instead of the value as a % of the total animation time. This value is also not entirely predictable I tested a few values and saw anywhere from 100ms - 250ms. Anyway, from what I'm seeing you have two options (I've tested both)

  1. User another library such as https://github.com/EverythingMe/overscroll-decor

  2. Implement your own property and use the spring animation:


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()
}

Then in your bounce, replace the call to smoothScrollBy with a variation of:

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

UPDATE:

The second solution works out-of-box with no changes to your RecyclerView and is the one I wrote and tested fully.

More about interpolators, smoothScrollBy doesn't work well with interpolators (likely a bug). When using an interpolator you basically map a 0-1 value to another which is a multiplier for the animation. Example: t=0, interp(0)=0 means that at the start of the animation the value should be the same as it started, t=.5, interp(.5)=.25 means that the element would animate 1/4 of the way, etc. Bounce interpolators basically return values > 1 at some point and oscillate about 1 until finally settling at 1 when t=1.

What solution #2 is doing is using the spring animator but needing to update scroll. The reason SCROLL_X doesn't work is that RecyclerView doesn't actually scroll (that was my mistake). It calculates where the views should be based on a different calculation which is why you need the call to computeHorizontalScrollOffset. The ScrollXProperty allows you to change the horizontal scroll of a RecyclerView as though you were specifying the scrollX property in a ScrollView, it's basically an adapter. RecyclerViews don't support scrolling to a specific pixel offset, only in smooth scrolling, but the SpringAnimation already does it smoothly for you so we don't need that. Instead we want to scroll to a discrete position. See https://android.googlesource.com/platform/frameworks/support/+/247185b98675b09c5e98c87448dd24aef4dffc9d/v7/recyclerview/src/android/support/v7/widget/RecyclerView.java#387

UPDATE:

Here's the code I used to test https://github.com/yperess/StackOverflow/tree/52148251

UPDATE:

Got the same concept working with interpolators:

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()

Demo project on GitHub was updated

I updated ScrollXProperty to include an optimization, it seems to work well on my Pixel but I haven't tested on older devices.

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)
    }
}

The GitHub project now includes demo with the following interpolators:

<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>

Like I said, I wouldn't honestly expect a Release Candidate (recyclerview:1.0.0-rc02 in your case) would work properly without causing any issues since it's already under development.

  • Using third-party libraries might work but, I don't really think so since they have just introduced androidx dependencies and they're already under development and not stable enough to use by other developers.

Update The answer from Google Developers:

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

So, better to wait for the new updates.

You need to enable overscroll bounce somehow.

The one of the possible solutions is to use https://github.com/chthai64/overscroll-bouncy-android

Include it in your project

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

And then in your POV change RecyclerView in activity_main.xml to 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"/>

After this change you will see bounce in your app.

Related