16

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 .

android developer
  • 114,585
  • 152
  • 739
  • 1,270
  • The Interpolators you listed might end up scrolling beyond the final element in the RecyclerView. Does `smoothScrollby` work as expected if you use an Interpolator like `AccelerateInterpolator` or `DeccelerateInterpolator`, or another that doesn't have the option of scrolling past its destination? – prfarlow Sep 11 '18 at 22:02
  • They seem to work, but that's the problem... – android developer Sep 12 '18 at 05:25
  • 1
    As you know, `recyclerview:1.0.0-rc02` means `Release Candidate` so I'd say this is a bug which can be compared with the other versions to check... – ʍѳђઽ૯ท Sep 12 '18 at 06:52
  • 1
    @ʍѳђઽ૯ท The problem is that the more interesting interpolators don't work as expected. – android developer Sep 12 '18 at 19:23

3 Answers3

8

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>
TheHebrewHammer
  • 3,018
  • 3
  • 28
  • 45
  • 1
    Sadly, this would animate the `recyclerView` itself, and not the scrolling of its items. – android developer Sep 12 '18 at 05:22
  • Please explain. How could each of those solutions work with `smoothScrollBy` , or as an alternative to it? I want to try any kind of interpolation. – android developer Sep 12 '18 at 19:10
  • Please show code about the solution. I don't understand it at all. How could you test it? Can you please use the sample I've used? – android developer Sep 13 '18 at 12:20
  • 1
    I'll upload to GitHub and post the link here – TheHebrewHammer Sep 13 '18 at 13:09
  • Seems this works only for bouncing effect, and not Interpolators in general. Not only that, but it doesn't seem natural like on the examples I've shown (see video here: https://github.com/yperess/BouncyRecyclerViewDemo/issues/1 ) . Better than nothing, but not what I asked about. I don't get why all the upvotes, if it's not about the question. Thanks for the effort though. I give you +1 on comments, but not on answer, because at least you tried, as opposed to others. – android developer Sep 13 '18 at 14:47
  • Sorry it didn't help. It just looks like interpolation can't work because of how the recycler view uses them. They're supposed to be hosting AndroidX on an open Gerrit soon if not already. I'll upload a fix and see if Google will accept it. – TheHebrewHammer Sep 13 '18 at 15:02
  • You know exactly how to override it and let interpolator work? If so, please publish this solution instead. Much better ... – android developer Sep 13 '18 at 16:28
  • 1
    I'll have to download the aosp branch but I think I'll give it a shot tonight and post the CL here. For now I think the last workaround I posted should do the trick for you – TheHebrewHammer Sep 13 '18 at 20:28
  • The "spring" still looks with same un-natural motion, but the "bounce" one looks much better. I think you got some nice workaround here. It doesn't work well if there are a lot of items, though. Can you make it work in a generic way for all Interpolators? Or do you have to make one of your own for each of them? – android developer Sep 14 '18 at 07:45
  • Yeah, I left the spring one just as a contrast, it didn't change. The main issue is that we have to calculate the offset over and over which is probably why it doesn't work well with a lot of items. This should work with any interpolation, just swap out the BounceInterpolator(). Let me think and see if I can optimise the call to calculate horizontal offset... – TheHebrewHammer Sep 14 '18 at 11:35
  • Is there a way to generalize it though? To use any kind of interpolator without implementing a new class for each? – android developer Sep 14 '18 at 11:36
  • 1
    Well, you need an instance of ScrollXProperty but it should work regardless of the interpolation used, you can just use the same one for all of them. Maybe I'm not understanding, which class are you trying to avoid implementing? If you want vertical scrolling you'll need another property, but that's about it. – TheHebrewHammer Sep 14 '18 at 11:41
  • Oh I feel bad for not giving the bounty then. Sorry for this. If I had more time to test it, I would have granted , but you've shown the best answer too near the bounty grant due time. All I can do is give other things instead. – android developer Sep 14 '18 at 15:01
  • No worries, honestly I started on this question for the bounty, but you found a super super interesting issue with RecyclerView. I was happy to work on it and looking forward to submitting a fix to aosp when I get around to it. – TheHebrewHammer Sep 14 '18 at 15:13
  • I have an idea. I had a question that nobody succeeded answering yet, and I've set a bounty multiple times. If you succeed on it, I will set a new bounty and give to you: https://stackoverflow.com/q/50091878/878126 . 500 points. – android developer Sep 15 '18 at 07:32
  • Looks fun, I'll give it a go first thing next week. I've got a big release that's due Sunday so I'm a bit slammed this weekend. – TheHebrewHammer Sep 15 '18 at 08:04
  • I've set a bounty. – android developer Sep 17 '18 at 08:07
0

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.

ʍѳђઽ૯ท
  • 16,646
  • 7
  • 53
  • 108
0

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.

dilix
  • 3,761
  • 3
  • 31
  • 55
  • Won't this also cause normal scrolling by the user to be bouncy, like on IOS ? I don't want to change good behavior... Maybe it's possible to disable it after the initial scrolling has finished? – android developer Sep 13 '18 at 11:19
  • @androiddeveloper with this particular library I doubt it's possible but it's opensource and you could implement your own logic inside. As I've checked this library just add header and footer view to let user drag over the 'first' element. So you just need to reveal bounceAdapter to your code and delete first and last items and also need to have property inside to say if item is overscrollable because as you can see in RecyclerViewBouncy smoothScrollToPosition is overrided to super.smoothScrollToPosition(position + 1); – dilix Sep 13 '18 at 11:29
  • That's too bad. Really wish there could be a nice workaround, and not just for bouncing. – android developer Sep 13 '18 at 12:18