RecyclerViewでアイテムの変更(Change/Insert/Move/Remove)を行うと、変更される様子がアニメーション化されています。
これはデフォルトでアイテムの変更アニメーションが組み込まれているためです。
デフォルトは単純なアニメーションですが、「ある」と「ない」の違いは歴然で、アニメーションのある方が高価なアプリケーションに見えます。
GUI(Graphical User Interface)が主体の携帯端末にとって、利用者に対するアプリの見せ方は重要です。高価に見えた方が使ってもらえる可能性が高くなります。
上記のことから、アプリの機能に関係なくても、ちょっとした動きをアニメーション化するメリットがあります。
2回にわたりアイテムの変更アニメーションについてまとめてみました。
- アイテムの変更アニメーション(DefaultItemAnimator、デフォルト)
- アイテムの変更アニメーション(SimpleItemAnimatorの継承、カスタム)
今回は第2回目「SimpleItemAnimatorの継承、カスタム」編です。カスタム変更アニメーションの作り方を説明します。
目次
カスタム変更アニメーションの動作
作成するカスタム変更アニメーション(ScaleItemAnimator)は次に示すような動作です。
(フェードアウト⇒フェードイン)
(移動で空欄を作り⇒中央より拡大)
(同時に移動)
(中央へ縮小⇒移動で空欄を埋める)
カスタム変更アニメーションの作成(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() ...
関連記事: