LazyColumn でアイテムが削除されたときに、アニメーションを行う

# はじめに

ある日 [LazyColumn](https://developer.android.com/develop/ui/compose/lists#lazy) に表示されているアイテムに対して、特定の操作を行うとアイテムを削除する実装を行う機会がありました。

今回は、自作した Apple 公式サンプル「[Handling user input](https://developer.apple.com/tutorials/swiftui/handling-user-input)」と同じ仕様の Android アプリを用いて解説します。

https://github.com/tfandkusu/ga913-android

`Favorites only` スイッチを ON にすると、「★」が付いてるアイテムのみを表示します。言い換えると、「★」が付いていないアイテムは削除されます。同様にスイッチを ON にするとアイテムの追加が行われます。

![lazy1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/95013b0e-1358-f735-072b-3f153d622d67.gif)

この動画では、アイテムの削除と追加、それ以外のアイテムの移動がカットインで行われています。

ここで私は思いました。[Jetpack Compose](https://developer.android.com/compose) 登場前の [RecyclerView](https://developer.android.com/develop/ui/views/layout/recyclerview) による実装ではアニメーションしていました。アニメーションがあることで、ユーザから見たときに「こだわりのあるアプリ」に感じられ、より良い体験につながりそうです。

よってこの記事では LazyColumn でアイテムの削除および追加時にアニメーションを行う実装方法を紹介します。また、RecyclerView の時代はどのように該当アニメーションを実装していたかについても、簡単に紹介します。

# Lazy リストのアイテムアニメーションを設定する

公式ドキュメントの[アイテム アニメーション](https://developer.android.com/develop/ui/compose/lists#item-animations)にやり方が載っていました。必要な手順は以下の2つだけです。

- [アイテムにキーを設定する](https://developer.android.com/develop/ui/compose/lists?hl=ja#item-keys)
- アイテムの Composable 関数に [animateItem](https://developer.android.com/reference/kotlin/androidx/compose/foundation/lazy/LazyItemScope?_gl=1*nwpjl9*_up*MQ..*_ga*MTI3NjM5ODU5NC4xNzI5MzE4ODAz*_ga_6HH9YJMN9M*MTcyOTMxODgwMi4xLjAuMTcyOTMxODgwMi4wLjAuMTA1ODI5MTUxMg..#(androidx.compose.ui.Modifier).animateItem(androidx.compose.animation.core.FiniteAnimationSpec,androidx.compose.animation.core.FiniteAnimationSpec,androidx.compose.animation.core.FiniteAnimationSpec)) Modifier を設定する

アイテムにキーが設定されていないと、どの要素が削除または追加されたのか Compose から判別できなくなります。

よって今回のサンプルアプリの改修内容は、このようになります。

```kotlin
LazyColumn(
    modifier =
        Modifier
            .fillMaxSize(),
) {
    // 省略
    items(
        // state は ViewModel より提供された画面の状態
        // landmarks は Lazy リストを構成するためのデータ
        items = state.landmarks,
        key = { landmark ->
            // 【追加】アイテムにキーを設定
            landmark.id
        },
    ) { landmark ->
        // 1アイテム分のデータを表示する Composable 関数
        LandmarkListItem(
            // 【追加】
            modifier = Modifier.animateItem(),
            landmark = landmark,
            onClick = { /* 省略 */},
        )
    }
}
```

```kotlin
@Composable
fun LandmarkListItem(
    landmark: Landmark,
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    // 呼び出し元から渡された Modifier を適用する
    Column(
        modifier =
            modifier.fillMaxWidth(),
    ) { /* 省略 */ }
}
```

その結果、アイテムの削除および追加時にアニメーションが行われるようになりました。

![lazy2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/f33a980b-b475-69eb-05f1-5eaf427d2da0.gif)

## animateItem Modifier が追加されたのは比較的最近

意外にも animateItem Modifier が追加されたのは [Compose 1.7.0](https://developer.android.com/jetpack/androidx/releases/compose-foundation?_gl=1*1ft2dxa*_up*MQ..*_ga*MTUxNDQ4OTYxLjE3MjkzMjExNTk.*_ga_6HH9YJMN9M*MTcyOTMyMTE1OC4xLjAuMTcyOTMyMTE1OC4wLjAuMjE0MjA2NTk4Nw..&hl=ja#1.7.0) (2024年9月4日)、アルファ版を含めても [Compose 1.7.0-alpha06](https://developer.android.com/jetpack/androidx/releases/compose-foundation?hl=ja#1.7.0-alpha06)(2024年4月3日)と、比較的最近のアップデートでした。

## 以前の animateItemPlacement Modifier

バージョン 1.7.0-alpha06 以前では animateItemPlacement Modifier が似たようなアニメーションを提供していました。しかし、これは削除および追加されるアイテム以外の移動にのみ対応しており、削除および追加されるアイテムについてはカットインとなっていたため、物足りないアニメーションに感じられました。

![lazy3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334798/788bb618-8b0d-fea4-57ce-bedcbb0311d2.gif)

そのことは、こちらの記事でも紹介されています。

https://qiita.com/yasukotelin/items/c4333c9baa5dd02acfca

https://medium.com/@gregkorossy/animating-lazylist-items-in-jetpack-compose-6b40f94aaa1a

# RecyclerView 時代のアイテム削除および追加のアニメーション実装方法

冒頭で Jetpack Compose 登場前の [RecyclerView](https://developer.android.com/develop/ui/views/layout/recyclerview) による実装ではアニメーションが適用されていたと述べましたが、ここではその具体的な実装方法について簡単に紹介します。

まず RecyclerView は、[RecyclerView.Adapter](https://developer.android.com/reference/androidx/recyclerview/widget/RecyclerView.Adapter), [ViewHolder](https://developer.android.com/reference/androidx/recyclerview/widget/RecyclerView.ViewHolder), [ListAdapter](https://developer.android.com/reference/androidx/recyclerview/widget/ListAdapter), [DiffUtil.ItemCallback](https://developer.android.com/reference/androidx/recyclerview/widget/DiffUtil.ItemCallback) など多くのクラスが登場し、難解な部分が多いです。そのため、[Groupie](https://github.com/lisawray/groupie) や [Epoxy](https://github.com/airbnb/epoxy) といった直感的に使える補助ライブラリがよく使われていました。

Groupie では1アイテムに相当する View を提供するためのクラスを BindableItem クラスを継承して作成します。例として、[4年前の Android Advent Calendar](https://qiita.com/advent-calendar/2020/android) 向けに作成したこの記事のために作成したコードを以下に紹介します。

https://qiita.com/tfandkusu/items/b7681169c374a40dfb6e

```kotlin
// メッセージアプリの1メッセージを表すクラス
data class Message(
    val id: Long,
    val name: String,
    @DrawableRes val iconResId: Int,
    val time: Long,
    val body: String
)
```

```kotlin
class MessageBindableItem(private val message: Message) : BindableItem<ListItemMessageBinding>() {

    private val sdf = SimpleDateFormat("HH:mm", Locale.JAPAN)

    override fun bind(viewBinding: ListItemMessageBinding, position: Int) {
        // コンストラクタで渡された message から View を設定する
        viewBinding.icon.setImageResource(message.iconResId)
        viewBinding.name.text = message.name
        viewBinding.time.text = sdf.format(Date(message.time))
        viewBinding.body.text = message.body
    }

    // 1アイテム分の XML レイアウト
    override fun getLayout() = R.layout.list_item_message

    override fun initializeViewBinding(view: View): ListItemMessageBinding {
        return ListItemMessageBinding.bind(view)
    }

    override fun isSameAs(other: Item<*>): Boolean {
        // Item クラスは BindableItem クラスの親クラス
        // 別の Item である other と自分を比較して
        // 同じものを表している場合は true を返却する
        return if (other is MessageBindableItem) {
            message.id == other.message.id
        } else {
            false
        }
    }

    override fun hasSameContentAs(other: Item<*>): Boolean {
        // さらにコンテンツも同じ場合はこちらで true を返却する
        // ItemAnimator の設定次第で、コンテンツの変更に伴うアニメーションもできる
        return if (other is MessageBindableItem) {
            message == other.message
        } else {
            false
        }
    }
}
```

重要なのは `isSameAs` メソッドの実装です。[Compose のアイテムのキー](https://developer.android.com/develop/ui/compose/lists#item-keys)に相当する部分です。これを適切に実装することで、リストが更新されたとき、削除や追加されたアイテムがあるときは、自動でアニメーションが行われます。[^1]

[^1]: デフォルトで [DefaultItemAnimator](https://developer.android.com/reference/androidx/recyclerview/widget/DefaultItemAnimator) が使われます

リストの更新は GroupieAdapter.update メソッドに BindableItem のリストを渡すことで行えます。


```kotlin
val adapter = GroupieAdapter()
recyclerView.adapter = adapter
// ViewModel が管理する画面の状態 state を更新監視している。
// 現代だと StateFlow で使うところがだが、当時は LiveData を使っていた。
viewModel.state.observe(this) { state ->
    val items = state.messages.map { message ->
        MessageBindableItem(message)
    }
    adapter.update(items)
}
```

# まとめ

LazyColumn や LazyRow でアイテムが削除または追加されたときには、自然なアニメーションが欲しいところです。以下の手順で、自然なアニメーションを実装することができます。

- Compose は1.7.0以上を使用する
- アニメーション対象のアイテムに [key を設定する](https://developer.android.com/develop/ui/compose/lists#item-keys)
- アイテムの Composable 関数に [animateItem Modider を設定する](https://developer.android.com/develop/ui/compose/lists#item-animations)

また Compose 登場前から活動している Android エンジニアが、このような実装が無いと、ユーザ体験的にもコード的にも物足りなく感じる理由として、Compose 登場前の RecyclerView における同様のアニメーションの実装方法を簡単に紹介しました。