物理アニメーションでRecyclerViewにオーバースクロールアニメーションを実装してみる


はじめに

「Android Studio のRecyclerViewをオーバースクロール(bouncing animation, ios風にスクロール)させたい」、こんなことを思った人は多いと思います。(このアニメーション何て言うのか分かんない...)かく言う自分もその一人です。
デフォルトのoverScrollModeとかいう色が付くスクロールアニメーションってダサくね?って思ってました。

初めてRecyclerViewを実装したときは、ios風にスクロール端のアニメーションを設定するにはどうするんだろう? なんかそういうAttributeでもあんだろ?
... そんな風に甘く考えてました。

実際にやろうとして、すぐにAttributeがないことに気づき嘘やろ?ってなりました。
何だかよくわからんが変なアダプタ作ったり、scrollリスナ使ったり、ライブラリ使ったりして3日ほど頑張ってみたけど、結局出来なくて泣く泣く諦めた記憶があります。(recyclerView触りたてのヤツがやるには難易度高すぎw)

それから少し時間がたちリベンジ。
検索していたらとても参考になる記事を発見したので、それを共有したいと思います。

Ashu Tyagi氏によるBounce effect in RecyclerView

という天才的な記事。これに全て書いてあった!

ちなみに java, kotlinだと簡単そうで実装がめんどいこのオーバースクロールアニメーション、Flutter 触ったことがある方なら知ってると思いますが1行で書けます ( ´∀` )
頼むからbouncing scroll的なAttribute 追加してくれ ...

今回やること

はい、前置きが少し長くなりましたが、タイトルにあるようにオーバースクロールアニメーションを付けたRecyclerViewをやってみます。↓
一番下にGitHubを載せますので必要な方は参照してください。

冒頭でも書いたとおり、基本的にはBounce effect in RecyclerViewをみれば実装は分かりますが、ちょこちょこ解説いれつつ見ていきたいと思います。

流れ

  • まずスクロールのアニメーションには物理アニメーションであるSpring Animationを使います。(公式ドキュメント)
  • RecylerViewのViewHolderにSpringAnimationオブジェクトをview holderとして持たせます。(recyclerViewのview holder一つ一つにアニメーションをセットします)
  • RecyclerViewにEdgeEffectFactory をセットしてアニメーションを動かす。

依存関係

Android X の dynamicanimation を追加します。(2020/8月時点)

builde.gradle
implementation 'androidx.dynamicanimation:dynamicanimation:1.0.0'

stable ver1.0.0 がリリースされたのは2019/12 みたいです。

View Holderにアニメーション追加

RecyclerViewのview holderにspringAnimationオブジェクトを持たせます。RecyclerView全体にではなくview holder単位でアニメーションを設定していきます。
なお、今回recyclerViewの実装については触れません。(必要な方は最下部掲載のgithubをご覧ください)

RecyclerAdapter.kt
    // view holder
    class CustomViewHolder(view: View) : RecyclerView.ViewHolder(view) {
        val content: TextView = view.content

        // view holderにspringAnimationオブジェクト追加
        val springAnimY: SpringAnimation = SpringAnimation(itemView, SpringAnimation.TRANSLATION_Y)
            .setSpring(SpringForce().apply {
                finalPosition = 0f          //ばねの静止位置の設定
                dampingRatio = SpringForce.DAMPING_RATIO_LOW_BOUNCY  //ばねの減衰比(0~1f)
                stiffness = SpringForce.STIFFNESS_VERY_LOW           //ばねの剛性(0~)
            })
    }

今回はTRANSLATION_Yでy軸方向のSpringとしてアニメーションさせますが、他にもROTATION, SCALE, ALPHAなどそれぞれX,Y,Z軸について存在します。(alphaのspringアニメーションの使いどころむずくねw)

ここで、SpringForceのプロパティがfinalPosition, dampingRatio, stiffness と3つほどあります。 これらのプロパティはアニメーションに使うばねの挙動を調整つするためのものです。

finalPositionはアニメーションの最終的な終了位置を示すために初期化しておく必要があるようなものだと分かると思いますが、dampingRatio, stiffnessは何?
それぞれ範囲は0~1f, 0~∞になっています。(もちろんfloatのビット数限界まで)

翻訳かけてみたら、dampingRatio : 減衰比 、stiffness : 剛性と出ました。oh、なるほど。自分と同じ工学部の方なら聞き覚えがあるかもしれません。
簡単に言うと、

dampingRatio stiffness
ばねのビヨーンとなる動きの止まりやすさに関係してきます ばねの強さのことで、引き戻すまたは反発の速さに関係します。
0だと全く振動せずすぐに止まり、1だと永遠に振動し続けます。 0だと引き戻す力が弱いのでひっぱても全然戻ってきません。大きい(100000f)と離すと一瞬で戻ってきます。

カスタムで指定もできますが、上記のコードの定数が今回は自然だと思うので考えたくない人はとりあえずコピペしてください。

物理アニメーション

今回はSpringAnimation(バネアニメーション)を使ったので減衰振動、バネ定数などによる物理アニメーションでしたが、他にもFlingAnimation(投射アニメーション)、加速度、摩擦、水平投射などの物理アニメーションもあります。(公式ドキュメント)
名前からして実世界を意識したマテリアルデザインとかと相性がよさそうですね。

edgeEffectFactoryのセット

つづいて、recyclerViewにedgeEffectFactoryセットしていきます。edgeEffectFactoryを使えば、名前の通り、recyclerViewのスクロール端のエフェクトに関してカスタムすることができます。

今回はedgeEffectFactoryを使って、recyclerViewのスクロール端で 先ほどview holderに追加したアニメーションを動作させます。

MainActivity.kt

    recyclerView.adapter = myAdapter
    recyclerView.edgeEffectFactory = object : RecyclerView.EdgeEffectFactory() {
        override fun createEdgeEffect(view: RecyclerView, direction: Int): EdgeEffect {
            return object : EdgeEffect(applicationContext) {
                //これから実装
                // HERE
            }
        }
    }

ちょっと無名オブジェクトがネストしていて分かりづらいですが、ここにEdgeEffectのメソッドを実装していきます。

以下に載せるのは上記のコードの HEREの部分に追加するコードです。

MainActivity(HEREの部分).kt
    val OVER_SCROLL_COEF = 0.4f  // リスト端にスクロールしたときにどのくらいまでリスト外までスクロールさせるかを決める比率係数
    val OVER_FLICK_COEF = 0.4f   // リスト端にフリックしたときに程度リスト外までスクロールさせるかを決める比率係数

    // リストの端に行ったときに呼び出される
    override fun onPull(deltaDistance: Float, displacement: Float) {
        super.onPull(deltaDistance, displacement)
        // deltaDistance 0~1f 前回からの変化した割合
        // displacement 0~1f タップした位置の画面上の相対位置
        val sign = if (direction == DIRECTION_BOTTOM) -1 else 1
        val deltaY = sign * view.height * deltaDistance * OVER_SCROLL_COEF
        // view holderのアニメーションを移動距離に合わせて更新
        for (i in 0 until view.childCount) {
            view.apply {
                val holder = getChildViewHolder(getChildAt(i)) as RecyclerAdapter.CustomViewHolder
                holder.springAnimY.cancel()
                holder.itemView.translationY += deltaY
            }
        }
    }


    // 指を離したとき
    override fun onRelease() {
        super.onRelease()
        // アニメーションスタート
        for (i in 0 until view.childCount) {
            view.apply {
                val holder = getChildViewHolder(getChildAt(i)) as RecyclerAdapter.CustomViewHolder
                holder.springAnimY.start()
            }
        }
    }


    // リストをフリックして画面端に行ったとき
    override fun onAbsorb(velocity: Int) {
        super.onAbsorb(velocity)
        val sign = if (direction == DIRECTION_BOTTOM) -1 else 1
        val translationVelocity = sign * velocity * OVER_FLICK_COEF
        for (i in 0 until view.childCount) {
            view.apply {
                val holder = getChildViewHolder(getChildAt(i)) as RecyclerAdapter.CustomViewHolder
                holder.springAnimY
                    .setStartVelocity(translationVelocity)
                    .start()
            }
        }
    }

onPullメソッドについて、少し注意すべきなのが、名前的にrecyclerViewがスクロールされるとコールされるように感じますが、実際にコールされるのはrecyclerViewの上端、下端などの端でさらにスクロールしようとした場合にコールされます。(普通にリストの真ん中くらいをスクロールしても呼ばれません。)
引数のdeltaDistanceはrecyclerView(画面上に表示されてるもののみ)のpixelの絶対位置です。なのでdeltaYはスクロールによる移動距離を比率係数で調整しつつ求めています。
また、スクロール中はanimationをcancelすることで、そのアニメーションが終わっていなくても(オーバースクロール中でも)再び別のアニメーションプロパティを更新してアニメーションを動作させることができます。
これがないと、オーバースクロールしたときに、通常の位置にリストが戻ってくるまでスクロールすることができなくなります。

onReleaseメソッドはタップしてスクロールをはじめ、スクロールし終わって、指を離した瞬間にコールされます。このタイミングで、view holderのアニメーションを動作させます。

onAbsrobメソッドは、recyclerViewをフリックしてrecyclerViewの上端、下端に行ったときにコールされます。引数のvelocityはその時の速さで、その単位は[pixel/sec]となっています。
translationVelocityで速度からアニメーションプロパティを更新します。

こんな感じで、普通にスクロールしてスクロール端に到達したときと、フリックしてスクロール端に到達したときで別々にアニメーションを動作させる処理を書きます。

ちなみに冒頭で述べた記事ではview hoderを取得するのに、拡張関数を書いていました。
んー、エレガント!

MainActivity.kt
    view.forEachVisibleHolder<RecyclerAdapter.CustomViewHolder> {holder->
                                holder.springAnimY
                                    .setStartVelocity(translationVelocity)
                                    .start()
                            }


    fun <reified T : RecyclerView.ViewHolder> RecyclerView.forEachVisibleHolder(
        action: (T) -> Unit
    ) {
        for (i in 0 until childCount) {
            action(getChildViewHolder(getChildAt(i)) as T)
        }
    }

個人的に拡張関数をゴリゴリ使っていく人は、経験あるプロって感じがする。自分はまだまだ...

これで完了!!
ビルドしてみてみると冒頭のようなスクロールアニメーションができてるはずです。

今回のコードはこちらからどうぞ↓
GitHub