こんにちは!Jetpack Compose と KMM が好きなエンジニアのスージです。
去年 Jetpack Compose v1.3.0
で新しい Jetpack Compose の BoM(Bill of Materials) など様々な API 変更についてまとめ記事を書きました。
Compose BoM のお陰で毎月わかりやすいバージョン(例えば、2023年1月のリリースv2023.01.00
)で Compose のリリースが続いてます。いよいよ Compose v1.4.0
が stable になる時期が近付いてるのでまとめ記事を準備してます。
準備中 v1.4.0-alpha04 で追加された新しい PinnableContainer
API が気になって、早速調べてみました。この記事で調べたことを軽く紹介しようと思ってます。
LazyListとフォーカスのバグ
LazyList
でTextField
がフォーカス持っていたら、画面から表示されなくなった時フォーカスが外れてしまうバグが報告されました。
LazyList は RecyclerViewの仕様と近く、viewport から無くなるアイテムが Composition から廃止され、もしそのアイテムがフォーカス持っていたら、フォーカスを持たせるアイテムが無くなります。
このバグの対応で新しい PinnableContainer
API が出ました。
https://android-review.googlesource.com/c/platform/frameworks/support/+/2304138android-review.googlesource.com
Add items pinning functionality to the lazy lists.
Lazy lists now provide an instance of PinnableContainer via LocalPinnableContainer for each item. It allows to pin the current item from inside the content of the item. Pinning means we will continue composing, measuring and placing this item even when it is scrolled away from the view.
意図としては、アイテムが viewport から無くなっても Composition で残り続けるようにすることで、もしそのアイテムがフォーカスを持っていたら、フォーカスを持たせ続けることができます。
PinnableContainer
PinnableContainer
のドキュメンテーション によると、
/** * Represents a container which can be pinned when the content of this container is important. * ... * */ @Stable interface PinnableContainer { .. }
PinnableContainer
は特別扱いをしたい物を "ピン留め"(指摘)する API です。API自体は Composition と関係なく、ピン留めされる物の取り扱い方は完全に実装次第です。
この API でいくつか新しいキーワードがあります:
- PinnableContainer: ピン留めできるオブジェクト。Compose でだいたい
@Composable
がある特別処理に「ピン留めしたい」「ピン留めを外したい」ことを指摘するような interface です。LocalPinnableContainer.current
を使って当@Composable
に PinnableContainer を提供します。Compose v1.4.0 で既に LazyList の各アイテムに提供されてます。 - .pin:
PinnableContainer.pin()
を使って「ピン留めしたい」ことを指摘します。PinnedHandle
が返されます。一般的にこのコールバックで何か「ピン留めリスト」に追加するようにします。 - .release:
.pin()
から返された PinnedHandle のPinnedHandle.release()
を使って「ピン留めを外したい」ことを指摘します。一般的にこのコールバックでピン留めリストから削除するようにします。
LazyList
PinnableContainer を利用してLazyList
でピン留めしたアイテムが viewport から無くなっても Composition から廃止されないような実装が Compose v1.4.0 以降で追加されてます。
/** * .. * For example, each item of lazy list represents one [PinnableContainer], and if this * container is pinned, this item will not be disposed when scrolled out of the viewport.
つまり、ピン留めされた@Composable
が画面で表示されなくなって、仕様的に Composition から廃止されるような状態になっても、残り続けます。この実装の副作用として、ピン留めされる @Composable
が表示されなくなっても measure-layout フェーズが実行し続けます。
LazyList で PinnableContainer のこの実装について調べたことを詳しく以下のスレッドで書きましたので良ければ見てみてください。
Compose 1.4.0 から LazyList の「ピン留め」するアイテムが viewport で表示されなくなっても Composition から廃止されないようになります。#JetpackCompose
— すーじ・🍜・🍙 (@_SUR4J_) February 22, 2023
スレッド🧵⬇️https://t.co/4rkM5w5ZNt
Modifier.focusable()
ユースケース
以前紹介した LazyList とフォーカスのバグが Modifier.focusable()
の PinnableContainer 対応で修正されてます。ドキュメンテーションでもこのユースケースについて書いてあります。
/** * .. * Pinning a currently focused item so the focus is not lost is one of the examples when this * functionality can be useful.
LazyList のアイテムがModifier.focusable()
でフォーカスを持っていたら、そのアイテムをピン留めするように実装されてます。
fun Modifier.focusable(..) { .. // [1] val pinnableContainer = LocalPinnableContainer.current var pinHandle by remember { mutableStateOf<PinnableContainer.PinnedHandle?>(null) } DisposableEffect(pinnableContainer) { // [2] if (isFocused) { pinHandle = pinnableContainer?.pin() } .. } Modifier .onFocusChanged { // [3] if (isFocused) { pinHandle = pinnableContainer?.pin() .. } else { .. } } }
- LazyList の各アイテムが PinnableContainer 持ってるので、この場合
LocalPinnableContainer.current
が non-null PinnableContainer を返します。 - アイテムが Composition に追加されたタイミングからフォーカス持っていたら、
.pin()
でピン留めされます。 - 後からフォーカス持つようになったら、ピン留めされます。
ピン留めしてから、アイテムはフォーカスを持ちつつ Composition に残り続けます。
fun Modifier.focusable(..) { .. DisposableEffect(pinnableContainer) { .. // [1] onDispose { pinHandle?.release() pinHandle = null } } Modifier .onFocusChanged { if (isFocused) { .. } else { // [2] pinHandle?.release() pinHandle = null } } } }
- ピン留めされたアイテムを廃止したら、
.release()
でピン留めを外します。 - アイテムが廃止されてないけど、フォーカスを持てなくなっていたらピン留めを外します。
ピン留めを外した後、LazyList の仕様通りアイテムを Composition に追加・廃止するようになります。
カスタムユースケース実装してみた
Modifier.focusable()
の実装を参考にして、フォーカス以外のカスタムユースケースを実装してみました。 例えば、リストの真ん中に「タイマー」を入れて、リストをスクロールしてもそのタイマーがリセットされずそのまま作動したいです。
早速 PinnableContainer を使わず UI を作ってみました。
LazyColumn { items { e -> ListItem(e) } item { Timer() } items{ e -> ListItem(e) } } @Composable fun Timer() { var time by remember { mutableStateOf(0) } LaunchedEffect(Unit) { while(true) { time++ delay(1_000) } } Text(text = "$time") }
想定通り Timer
アイテムが viewport から無くなった時 Composition から廃止されてます。再度現れた時 var time
もリセットされ、LaunchedEffect
がまた実行しはじめてます。
次は Timer
アイテムをピン留めしてみます。
@Composable fun Timer() { // [1] val pinnableContainer = LocalPinnableContainer.current DisposableEffect(pinnableContainer) { // [2] val pinnedHandle = pinnableContainer?.pin() onDispose { // [3] pinnedHandle?.release() } } .. }
- LazyList の各アイテムが PinnableContainer 持ってるので
LocalPinnableContainer.current
からそれを取得します。 - Composition に入ったタイミングからピン留めしたいので
DisposableEffect
の中で.pin()
呼びます。 - LazyList が廃止されたタイミングで Timer アイテムのピン留めを外したいので、
onDispose()
コールバックで.release()
を呼びます。
Timer
をピン留めしてるので、viewport から無くなっても Composition から廃止されないし、LaunchedEffect
もそのまま実行し続けます。
このカスタムユースケースのコードを gist でまとめました。参考になれば嬉しいです 🙆♂️
PinnableItem in Compose 1.4.0 · GitHub
最後に
PinnableContainer
API 含めて色んな API が更新と追加されてますので、引き続き Compose v1.4.0 のまとめ記事を少々を待ちください。
Goodpatch には、デザインと技術の両方を追求できる環境があります。 少しでもご興味を持ってくださった方、ぜひ一度カジュアルにお話ししましょう!