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()
...
関連記事:
