RecyclerView:アイテムの変更アニメーション(SimpleItemAnimatorの継承、カスタム)

投稿日:  更新日:

RecyclerViewでアイテムの変更(Change/Insert/Move/Remove)を行うと、変更される様子がアニメーション化されています。

これはデフォルトでアイテムの変更アニメーションが組み込まれているためです。

デフォルトは単純なアニメーションですが、「ある」と「ない」の違いは歴然で、アニメーションのある方が高価なアプリケーションに見えます。

GUI(Graphical User Interface)が主体の携帯端末にとって、利用者に対するアプリの見せ方は重要です。高価に見えた方が使ってもらえる可能性が高くなります。

上記のことから、アプリの機能に関係なくても、ちょっとした動きをアニメーション化するメリットがあります。

2回にわたりアイテムの変更アニメーションについてまとめてみました。

今回は第2回目「SimpleItemAnimatorの継承、カスタム」編です。カスタム変更アニメーションの作り方を説明します。

スポンサーリンク

カスタム変更アニメーションの動作

作成するカスタム変更アニメーション(ScaleItemAnimator)は次に示すような動作です。

【 Change 】ポジション:2を変更
(フェードアウト⇒フェードイン)

【 Insert 】ポジション:2へ追加
(移動で空欄を作り⇒中央より拡大)

【 Move 】ポジション:1から3へ移動
(同時に移動)

【 Remove 】ポジション:2を削除
(中央へ縮小⇒移動で空欄を埋める)

スポンサーリンク

カスタム変更アニメーションの作成(SimpleItemAnimatorを継承)

カスタム変更アニメーションはSimpleItemAnimator抽象クラスを継承して作ります。

次にあげる抽象メソッドの実装が必要になります。

  • animateRemove
  • animateAdd
  • animateMove
  • animateChange
  • runPendingAnimations
  • endAnimation
  • endAnimations
  • isRunning

抽象メソッドの役割ついては「RecyclerView:アイテムの変更アニメーション(DefaultItemAnimator、デフォルト)」を参照してください。

以下は全コードです。

class ScaleItemAnimator : SimpleItemAnimator() {

    // ↓ リクエストのリスト
    private val pendingAminations: MutableList<AnimationCmd> = mutableListOf()
	// ↓ 実行中のリスト
    private val runningAminations: MutableList<AnimationCmd> = mutableListOf()

    // --------------------------------------------------------------
    // アニメーションコマンド
    // --------------------------------------------------------------
    private enum class CMD {
        NONE, ADD, REMOVE, MOVE, CHANGE_O, CHANGE_N
    }
    private data class AnimationCmd(
        var cmd: CMD = CMD.NONE,
        var holder:  RecyclerView.ViewHolder,
        var fromX: Float, var fromY: Float,
        var toX: Float, var toY: Float
    )

    // --------------------------------------------------------------
    // アニメーションのリクエスト(AnimationCmd)を
    // リスト(pendingAnimation)へ登録
    // --------------------------------------------------------------
    override fun animateRemove(holder: RecyclerView.ViewHolder): Boolean {
        holder.itemView.scaleX = 1.0f   // アニメーション初期値
        holder.itemView.scaleY = 1.0f
        pendingAminations.add(AnimationCmd(
            CMD.REMOVE, holder, INVALID, INVALID, INVALID, INVALID))

        return true // runPendingAnimationsの呼出を要求
    }

    override fun animateAdd(holder: RecyclerView.ViewHolder): Boolean {
        holder.itemView.scaleX = 0.0f   // アニメーション初期値
        holder.itemView.scaleY = 0.0f
        pendingAminations.add(AnimationCmd(
            CMD.ADD, holder, INVALID, INVALID, INVALID, INVALID))

        return true // runPendingAnimationsの呼出を要求
    }

    override fun animateMove(
        holder: RecyclerView.ViewHolder,
        fromX: Int, fromY: Int, toX: Int, toY: Int
    ): Boolean {
        holder.itemView.x = fromX.toFloat() // アニメーション初期値
        holder.itemView.y = fromY.toFloat() // アニメーション初期値
        pendingAminations.add(AnimationCmd(
            CMD.MOVE, holder,
            fromX.toFloat(), fromY.toFloat(), toX.toFloat(), toY.toFloat()))

        return true // runPendingAnimationsの呼出を要求
    }

    override fun animateChange(
        oldHolder: RecyclerView.ViewHolder,
        newHolder: RecyclerView.ViewHolder,
        fromLeft: Int, fromTop: Int, toLeft: Int, toTop: Int
    ): Boolean {
        oldHolder.itemView.alpha = 1.0f // アニメーション初期値
        pendingAminations.add(AnimationCmd(
            CMD.CHANGE_O, oldHolder,
            fromLeft.toFloat(), fromTop.toFloat(), 
			toLeft.toFloat(), toTop.toFloat()))
        newHolder.itemView.alpha = 0.0f // アニメーション初期値
        pendingAminations.add(AnimationCmd(
            CMD.CHANGE_N, newHolder,
            fromLeft.toFloat(), fromTop.toFloat(), 
			toLeft.toFloat(), toTop.toFloat()))

        return true // runPendingAnimationsの呼出を要求
    }

    // --------------------------------------------------------------
    // アニメーションの実行
    //   画面のリフレッシュタイミングで実行される
    //
    // コマンドの発行      ※後半のアニメはdelayを設ける
    //   アイテムの削除:CMD.REMOVE   -> CMD.MOVE
    //   アイテムの追加:CMD.MOVE     -> CMD.ADD
    //   アイテムの更新:CMD.CHANGE_O -> CHANGE_N
    //   アイテムの移動:CMD.MOVE
    // --------------------------------------------------------------
    override fun runPendingAnimations() {
        var _delayOn = false

        // REMOVE
        var _pendingRemoveOn = false
        pendingAminations.forEach {
            if(it.cmd == CMD.REMOVE) {
                exeRemoveAnimation(it)
                it.cmd = CMD.NONE
                _pendingRemoveOn = true
            }
        }
        _delayOn = _pendingRemoveOn

        // MOVE
        var _pendingMoveOn = false
        pendingAminations.forEach {
            if(it.cmd == CMD.MOVE) {
                exeMoveAnimation(it, _delayOn)
                it.cmd = CMD.NONE
                _pendingMoveOn = true
            }
        }
        _delayOn = _pendingMoveOn

        // ADD
        pendingAminations.forEach {
            if(it.cmd == CMD.ADD) {
                exeAddAnimation(it, _delayOn)
                it.cmd = CMD.NONE
            }
        }

        // CHANGE
        pendingAminations.forEach {
            if(it.cmd == CMD.CHANGE_O) {
                exeChangeOldAnimation(it)
                it.cmd = CMD.NONE
            }
            if(it.cmd == CMD.CHANGE_N) {
                exeChangeNewAnimation(it, true)
                it.cmd = CMD.NONE
            }
        }

        pendingAminations.clear()
    }
    private fun exeRemoveAnimation(cmd: AnimationCmd) {
        runningAminations.add(cmd)
        val _view = cmd.holder.itemView
        _view.animate().apply {
            setStartDelay(0) // 前回のDelayを初期化
            setDuration(removeDuration)
            scaleX(0.0f)
            scaleY(0.0f)
            setListener(object: AnimatorListenerAdapter() {
                override fun onAnimationStart(animation: Animator?) {
                    dispatchRemoveStarting(cmd.holder)
                }
                override fun onAnimationEnd(animation: Animator?) {
                    _view.scaleX = 1.0f // リサイクルでscale=0.0になる事を防止
                    _view.scaleY = 1.0f
                    dispatchRemoveFinished(cmd.holder)
                    runningAminations.remove(cmd)
                    if(!isRunning) dispatchAnimationsFinished()
                }
            })
        }.start()
    }
    private fun exeMoveAnimation(cmd: AnimationCmd, delayOn: Boolean) {
        runningAminations.add(cmd)
        val _view = cmd.holder.itemView
        _view.animate().apply {
            setStartDelay(if(delayOn) removeDuration else 0)
            setDuration(moveDuration)
            x(cmd.toX)
            y(cmd.toY)
            setListener(object : AnimatorListenerAdapter() {
                override fun onAnimationStart(animation: Animator?) {
                    dispatchMoveStarting(cmd.holder)
                }
                override fun onAnimationEnd(animation: Animator?) {
                    _view.x = cmd.toX
                    _view.y = cmd.toY
                    dispatchMoveFinished(cmd.holder)
                    runningAminations.remove(cmd)
                    if(!isRunning) dispatchAnimationsFinished()
                }
            })
        }.start()
    }
    private fun exeAddAnimation(cmd: AnimationCmd, delayOn: Boolean) {
        runningAminations.add(cmd)
        val _view = cmd.holder.itemView
        _view.animate().apply {
            setStartDelay(if(delayOn) moveDuration else 0)
            setDuration(addDuration)
            scaleX(1.0f)
            scaleY(1.0f)
            setListener(object : AnimatorListenerAdapter() {
                override fun onAnimationStart(animation: Animator?) {
                    dispatchAddStarting(cmd.holder)
                }
                override fun onAnimationEnd(animation: Animator?) {
                    _view.scaleX = 1.0f
                    _view.scaleY = 1.0f
                    dispatchAddFinished(cmd.holder)
                    runningAminations.remove(cmd)
                    if(!isRunning) dispatchAnimationsFinished()
                }
            })
        }.start()
    }
    private fun exeChangeOldAnimation(cmd: AnimationCmd) {
        runningAminations.add(cmd)
        val _view = cmd.holder.itemView
        _view.animate().apply {
            setStartDelay(0) // 前回のDelayを初期化
            setDuration(changeDuration)
            alpha(0.0f)
            setListener(object: AnimatorListenerAdapter() {
                override fun onAnimationStart(animation: Animator?) {
                    dispatchChangeStarting(cmd.holder, true)
                }
                override fun onAnimationEnd(animation: Animator?) {
                    _view.alpha = 1.0f // リサイクルでalpha=0.0になる事を防止
                    dispatchChangeFinished(cmd.holder, true)
                    runningAminations.remove(cmd)
                    if(!isRunning) dispatchAnimationsFinished()
                }
            })
        }.start()
    }
    private fun exeChangeNewAnimation(cmd: AnimationCmd, delayOn: Boolean) {
        runningAminations.add(cmd)
        val _view = cmd.holder.itemView
        _view.animate().apply {
            setStartDelay(if(delayOn) changeDuration else 0)
            setDuration(changeDuration)
            alpha(1.0f)
            setListener(object: AnimatorListenerAdapter() {
                override fun onAnimationStart(animation: Animator?) {
                    dispatchChangeStarting(cmd.holder, false)
                }
                override fun onAnimationEnd(animation: Animator?) {
                    _view.alpha = 1.0f
                    dispatchChangeFinished(cmd.holder, false)
                    runningAminations.remove(cmd)
                    if(!isRunning) dispatchAnimationsFinished()
                }
            })
        }.start()
    }

    // --------------------------------------------------------------
    override fun endAnimation(item: RecyclerView.ViewHolder) {
        item.itemView.animation?.cancel()
        for(_cmd: AnimationCmd in runningAminations) {
            if(_cmd.holder === item)  // 同一であれば
                runningAminations.remove(_cmd)
        }
        for(_cmd: AnimationCmd in pendingAminations) {
            if(_cmd.holder === item)  // 同一であれば
                pendingAminations.remove(_cmd)
        }
    }

    override fun endAnimations() {
        for(_cmd: AnimationCmd in runningAminations)
            _cmd.holder.itemView.animation?.cancel()
        runningAminations.clear()
        pendingAminations.clear()
    }

    override fun isRunning(): Boolean {
        return runningAminations.size > 0
    }
}
スポンサーリンク

注意点(アニメーションの順番)

図はポジション2を削除する場合のアニメーションです。

変更内容をアニメーションへ分解

このように、アニメーションの実行に消去(Remove)⇒移動(Move)といった順番を設けたい場合は、startDelayを用いてアニメーションの開始にオフセットを付けます。

    ...
    private fun exeMoveAnimation(cmd: AnimationCmd, delayOn: Boolean) {
        runningAminations.add(cmd)
        val _view = cmd.holder.itemView
        _view.animate().apply {
            setStartDelay(if(delayOn) removeDuration else 0) // オフセット/初期化
            setDuration(moveDuration)
            x(cmd.toX)
            y(cmd.toY)
            ...
        }.start()
    }
	...

ただし、startDelayの値はリサイクルされたViewHolderへ受け継がれてしまいます。

これを回避するために、アニメーションの開始毎にstartDelayの初期化が必要になります。

スポンサーリンク

注意点(dispathXXXFinished()の実行)

アニメーションの実行中、アイテムのViewHolderはリサイクル禁止になっています。

アニメーションの終了でリサイクル禁止を解除する必要があります。

解除はdispatchXXXFinishedメソッドを実行すると行われます。

    ...
    private fun exeRemoveAnimation(cmd: AnimationCmd) {
        runningAminations.add(cmd)
        val _view = cmd.holder.itemView
        _view.animate().apply {
            setStartDelay(0) // 前回のDelayを初期化
            setDuration(removeDuration)
            scaleX(0.0f)
            scaleY(0.0f)
            setListener(object: AnimatorListenerAdapter() {
                override fun onAnimationStart(animation: Animator?) {
                    dispatchRemoveStarting(cmd.holder)
                }
                override fun onAnimationEnd(animation: Animator?) {
                    _view.scaleX = 1.0f // scale=1.0に戻す、リサイクルで0.0になる事を防止
                    _view.scaleY = 1.0f
                    dispatchRemoveFinished(cmd.holder)
                    runningAminations.remove(cmd)
                    if(!isRunning) dispatchAnimationsFinished()
                }
            })
        }.start()
    }
	...

ちなみに、解除を行わなかった場合、リサイクルを行おうとしても出来ないので、アイテムが空欄になってしまいます。

注意点(リサイクルされるViewHolder)

Removeするアニメーションを実行すると、そのアイテムは未使用になるので、ViewHolderは直ちにリサイクルされます。

その時、リサイクル前のアニメーション終了時の状態を、リサイクル先のアイテムへ引き継いでしまいます。

これを回避するために、アニメーションの終了で、アニメーション前の状態へ戻す処理が必要です。

    ...
    private fun exeRemoveAnimation(cmd: AnimationCmd) {
        runningAminations.add(cmd)
        val _view = cmd.holder.itemView
        _view.animate().apply {
            setStartDelay(0) // 前回のDelayを初期化
            setDuration(removeDuration)
            scaleX(0.0f)
            scaleY(0.0f)
            setListener(object: AnimatorListenerAdapter() {
                override fun onAnimationStart(animation: Animator?) {
                    dispatchRemoveStarting(cmd.holder)
                }
                override fun onAnimationEnd(animation: Animator?) {
                    _view.scaleX = 1.0f // scale=1.0に戻す、リサイクルで0.0になる事を防止
                    _view.scaleY = 1.0f
                    dispatchRemoveFinished(cmd.holder)
                    runningAminations.remove(cmd)
                    if(!isRunning) dispatchAnimationsFinished()
                }
            })
        }.start()
    }
	...
スポンサーリンク

カスタム変更アニメーションの組み込み

カスタムの変更アニメーションを指定します。

これにより、DefaultItemAnimator(デフォルト)からScaleItemAnimator(カスタム)に切り替わります。

    ...
    rcySample.itemAnimator = ScaleItemAnimator()
	...
スポンサーリンク

関連記事:

RecyclerViewは子Viewを並べて表示するコンテナタイプ(ConstraintLayoutと同じ)のViewです。 複数のデータをスクリーン上に一覧表示したい時、例えば電話帳のような「氏名+住所+電話番号」の一覧を表示する場合などに最適です。 アプリを開発していると一覧表示したいデータが多いことに気付きます。 なのでRecyclerViewはとても重要で重宝するViewです。 しかし、思い通りの表示を行わせるためのテクニックが多すぎて、使いこなしが難しいです。 今まで調べたテクニックを忘れないように、整理して書き残そうと思います。 今回は基本の「RecyclerViewの実装」です。 ...
RecyclerViewでアイテムのクリックイベントを取得し、処理を実行する方法を紹介します。 ...
RecyclerViewはアイテムのレイアウトをアイテム毎に変更できます。その時に使う値がViewTypeです。 ViewTypeでアイテムのレイアウトを変更する方法を紹介します。 ...
RecyclerViewは表示が変更される(アイテムの更新、スクロール)時、アイテムのViewをリサイクル(再生利用)します。 これにより余分なViewの作成が行われなくなり、メモリーの節約とパフォーマンスの向上が望めます。 リサイクルはCachedViewsとRecyclerPoolという2つのキャッシュで行われます。 このキャッシュを使ったリサイクルの動作を調べたので紹介します。 ...
RecyclerViewのリサイクル動作で使われるキャッシュは、サイズを大きくすれば多くのViewHolderが保持できます。その分、多くのメモリを消費します。 ViewHolderを多く保持できたとしても、サイクル動作で効率よく使われなければ、メモリの浪費です。 キャッシュのサイズはRecyclerViewの使われ方よって適切なサイズがあります。 そのため、RecyclerViewはキャッシュのサイズを変更できるようになっています。 ...
RecyclerViewはアイテムを一覧表示してくれます。 ただ一覧表示するだけではなく、「追加・削除・移動・切り替え」といったアイテムの表示を効率よく変更する仕組み持っています。 今回はこの仕組みを使ったアイテムの変更方法を紹介します。 ...
RecyclerViewのリサイクル動作で使われるキャッシュは、サイズを大きくすれば多くのViewHolderが保持できます。その分、多くのメモリを消費します。 ViewHolderを多く保持できたとしても、サイクル動作で効率よく使われなければ、メモリの浪費です。 キャッシュのサイズはRecyclerViewの使われ方よって適切なサイズがあります。 そのため、RecyclerViewはキャッシュのサイズを変更できるようになっています。 ...
RecyclerViewが空(アイテムが無い)の時、EmptyViewを表示する実装を行ったので紹介します。 ...
RecyclerView上のアイテムを選択する方法を紹介します。 外部ライブラリー(AndroidX)で提供されるrecyclerview-selection APIを用いた方法です。 ...
RecyclerViewはListView(RecyclerViewの前身)の時に存在していたChoiceModeがありません。 同様な機能が欲しければプログラマ側で実装しなければなりません。 RecyclerView.AdapterをカスタマイズしてChoiceModeを実装してみたので紹介します。 ...
RecyclerViewはアイテムへ装飾を付けることが出来るようになっています。 装飾とは、例えばアイテムの区切り線などです。 今回はアイテムへ装飾を付ける方法を紹介します。 ...
RecyclerViewに表示しきれなかったアイテムはスクロールを行うことで表示されるようになっています。 スクロールは「外部入力(指でスクリーン上をタッチしてスライド)によるスクロール」の他に、「プログラムによるスクロール」をすることも出来ます。 今回はこのアイテムのスクロールについてまとめてみました。 ...
RecyclerViewはアイテムをスクロールさせて隠れたアイテムを表示できます。 スクロールを止める位置は任意です。 任意であるがゆえに、止めた位置によってはアイテムの一部が欠けてしまうこともあります。 携帯端末の画面は狭いので、効率よくコンテンツの表示を行いたいとアプリ開発者は考えます。 アイテムの一部が欠けてしまうことは、効率が良いとは言えません。 このような問題を解決するために、アイテムのスナップをRecyclerViewへ追加できます。 アイテムのスナップを追加する方法を紹介します。 ...
RecyclerViewでアイテムの変更(Change/Insert/Move/Remove)を行うと、変更される様子がアニメーション化されています。 これはデフォルトでアイテムの変更アニメーションが組み込まれているためです。 デフォルトは単純なアニメーションですが、「ある」と「ない」の違いは歴然で、アニメーションのある方が高価なアプリケーションに見えます。 GUI(Graphical User Interface)が主体の携帯端末にとって、利用者に対するアプリの見せ方は重要です。高価に見えた方が使ってもらえる可能性が高くなります。 上記のことから、アプリの機能に関係なくても、ちょっとした動きをアニメーション化するメリットがあります。 2回にわたりアイテムの変更アニメーションについてまとめてみました。 アイテムの変更アニメーション(DefaultItemAnimator、デフォルト) アイテムの変更アニメーション(SimpleItemAnimatorの継承、カスタム) 今回は第1回目「ItemAnimator、デフォルト」編です。デフォルト変更アニメーションの動作について説明します。 ...
RecyclerViewへアイテムが表示されるとき、アニメーションはありません。。一瞬で表示されて終わりです。 「RecyclerViewへアイテムが表示される」ことを、ここでは「アイテムの出現」と言い表すことにします。 このアイテムの出現にアニメーションを付ける方法を紹介します。 アイテムの出現をアニメーションで演出することで、RecyclerViewに表示したい内容が際立つと思います。 ...
RecyclerViewで「画像ファイルをグリッド表示」する方法を、まとめます。 スマートフォンのアプリを作っていると、何度も遭遇するテクニックです。 ※環境:Android Studio Ladybug | 2024.2.1 Patch 1     Kotlin 2.0.0     Compose Compilerプラグイン 2.0.0     androidx.recyclerview:recyclerview:1.1.0 ...
RecyclerViewで「グリッド表示をスムーズにスクロール」する方法について、まとめます。 画像ファイルをグリッド表示する場合は、スムーズなスクロールを行うための工夫が必要です。 その工夫について紹介します。 ※環境:Android Studio Ladybug | 2024.2.1 Patch 1     Kotlin 2.0.0     Compose Compilerプラグイン 2.0.0     androidx.recyclerview:recyclerview:1.1.0 ...
スポンサーリンク