飴屋

Kotlin/リストをスワイプして消したい

少しずつ、Jetpack Compose化計画を進めていて、もうそろそろXMLのレイアウトから脱却できそうな見込みです。

要素を一覧するよくある画面で、要素にアクションを加えることになりました。要素はCardで一覧するのにLazyColumnを使って縦に並べます。縦持ちを想定するとLazyColumnをよく使います。LazyColumnみたいにLazyが名前につくと一覧の中身が動的に変更できますね。

LazyColumn
  └Card

↑こんな構成で↓こんな実装になりました。

val items = remember { viewModel.getCards().toMutableStateList() }

LazyColumn() {
  itemsIndexed(items) { i,item ->
    Card(
      modifier = Modifier.combinedClickable(
        onClick = { ... },
        onLongClick = { ... }
      )
    ) { ... }
  }
}

mutableStateListにカードの中身を適応に詰めてremember したものをitemsで繰り返し表示していきます。今回たまたま要素の添え字番号(インデックス番号)が必要だったので、itemsIndexedで繰り返しています。Cardにクリックで選択、長押しで詳細表示、スワイプで削除といったアクションをつけようと思います。クリックと長押しはmodifier に処理を書くだけなので、特に難しいことはなさそうです。combinedClickableなんて丁度いいものもありました。しかし、スワイプはそうもいかないようです。まず、スワイプ操作中はCard自身の描画位置が変わり続ける点で、タップのように単発の処理では済みません。連続する処理は大体いろんなことを想定しなきゃならなくなって面倒なのです。(アニメーションとかすぐ複雑になります)

しかし、便利なComposable関数がやはり提供されているものです。まずSwipeToDismissというのをみつけました。ただこれはもう古いらしい・・・今はSwipeToDismissBoxを使うのがいいみたいです。onClick をonSwipe みたいに書けたら楽そうでしたが、スワイプにはいろんな可能性があるので、可用性を考慮した結果こうなったんでしょう。右スワイプと左スワイプで挙動を変えたり、スワイプすると裏から別のメニューが現れたり、そういえば他所のアプリでいろんな使われ方をみますね。100回スワイプして皮を剥いでいくとようやくやりたい処理ができる、みたいなUIもできそうです。

スワイプしている(またはしていない)ときの状態をrememberSwipeToDismissBoxStateを使って管理することになります。ただ、ここでちょっと詰まったんですよね。rememberSwipeToDismissBoxStateにconfirmValueChangeというイベントを定義できて、ここで現在のスワイプ状況をの変化を管理しろって話だったのですが、Android Studioで実装したら、「これはもうdeprecatedだよ」ってやんわり取り消し線を引かれてしまいました。もう、すごい速さで更新さえてるんでしょうね。deprecatedではない最新のやり方を調べてもなかなか出てきません。AIに聞いても古いやり方を教えられます。AIは「古いよ」ってツッコむと調べ直してくれるのですが、私自身が「古いかどうか」知らない時はなかなかツッコめないんですよね。このケースではまずGeminiに聞くのが一番だったのかも。

LazyColumn() {
  itemsIndexed(items) { i,item ->
    val dismissState = rememberSwipeToDismissBoxState(
      positionalThreshold = { totalDistance -> totalDistance * 0.5f }
    )
    LaunchedEffect(dismissState.currentValue) {
      if (dismissState.currentValue == SwipeToDismissBoxValue.EndToStart || dismissState.currentValue == SwipeToDismissBoxValue.StartToEnd) {
        viewModel.passCard(item.id) // 何かスワイプしたときの処理
        items.remove(item) // リストから削除
      }
    }
    SwipeToDismissBox(
      state = dismissState,
      modifier = Modifier.animateItem(),
      backgroundContent = {}
    ) {
      Card()
    }
  }
}

confirmValueChangeでやってたことはcurrentValueを監視することで同じように書けました。LaunchedEffectで状態を監視しています。currentValueが定常状態(SwipeToDismissBoxValue.Settled)から左右スワイプ(SwipeToDismissBoxValue.EndToStart、SwipeToDismissBoxValue.StartToEnd)に切り替わったら、必要な処理と、リストからスワイプされたカードを削除する処理を走らせます。

LazyColumn
  └LaunchedEffect
  └SwipeToDismissBox
    └Card

最終的に↑こんな構成になりました。(結構ここに辿り着くまでに時間がかかってしまった。)

SwipeToDismissBoxには先の状態管理を渡してあげました。あとはmodifier = Modifier.animateItem()をつけてあげると、追加や削除時のアニメーション効果が期待できるそうです。backgroundContent はスワイプしてズレた要素の裏に描画される要素のようです。ゴミ箱アイコンでも置いておくと、スワイプで削除されることが伝わりやすそうです。今回は不要なので空にしました。

新しい機能を追加するとき、機能を表すComposable関数を纏わせるっていう実装があるんですね。そして、状態を用意すると。なるほど。

さて、実はもう一つ問題がありまして、ここで時間がかかったんですが、Cardを一つスワイプしたら、もう一つ下のCardも一緒に消えちゃうっていう問題でした。

itemsIndexed(items, key = { i, item -> item.id }) { i,item ->
  ...
}

こんな感じでkeyを設定してあげると解消する問題でした。スワイプでCardが消えると画面の描画が更新されるわけですが、消えてしまったCardのスワイプ状態(dismissState)が次のCardのSwipeToDismissBoxに残ってしまって、一緒に消えてしまうようです。・・・まぁ、中身を詳しくしりませんが、そんなこともあるかもな。ここで今繰り返し表示している操作一つ一つに一意のkeyを用意してやると、間違えて一緒に消えることがなくなるそうです。keyで本人確認してるような感じでしょうか。keyにi(インデックス番号)を渡すのは、リストの中身が削除されて、インデックス番号も書き換わる曲面なので、悪手だそうです。私の場合はカードごとにidを持っていたので、それをそのまま使いました。

一覧へ