LazyColumn
やHorizontalPager
/VerticalPager
の上に配置したUIエレメントに対してpointerInput
を使う時は、key
を適切に設定しないとうまく動かない場合がある、という話です。
問題ない例
以下の例では、Text
がタップジェスチャーを検出したらカウンターをインクリメントする処理を実装しています。このような例だと、pointerInput
のkey
はUnit
で問題ありません。
// 説明に関係ないレイアウト調整などのコードは省略しています。
@Composable
fun PointerInputTest() {
Box {
var counter by remember { mutableStateOf(0) }
Text(
text = "Counter = $counter",
modifier = Modifier
.pointerInput(Unit) {
detectTapGestures {
counter++
}
}
)
}
}
上の例のように、Unit
で問題ないケースが多いので、私は普段、特に深く考えずにpointerInput(Unit) { ... }
と決まり文句のように書いてしまっていました。
うまくいかない例
ところが、以下のようにLazyColumn
の中のText
に対してpointerInput
を使うケースでは、問題が起こりました。Composeのバージョンが1.4.xのときはうまく動いていたのに、1.5.0では動作しませんでした。
最初は問題ないのですが、いったんスクロールしてから再度先頭のアイテムに戻ってきたときに、カウンターがインクリメントされません。
// 説明に関係ないレイアウト調整などのコードは省略しています。
@Composable
fun PointerInputTest() {
LazyColumn {
items(10) { index ->
val counter = remember { mutableStateOf(0) }
Text(
text = "Item($index) Counter = ${counter.value}",
modifier = Modifier
.pointerInput(Unit) {
detectTapGestures {
counter.value++
}
}
)
}
}
}
原因調査
何が起きているのか、ログを出して調べてみました。
LazyColumn
のアイテムがコンポーズされるタイミングと破棄されるタイミングで、counter
オブジェクトをログ出力します。また、pointerInput
のblock
が実行されるタイミングと、タップを検出したタイミングでもそれぞれ、counter
オブジェクトをログ出力します。
// 説明に関係ないレイアウト調整などのコードは省略しています。
@Composable
fun PointerInputTest() {
LazyColumn {
items(10) { index ->
val counter = remember { mutableStateOf(0) }
DisposableEffect(true) {
println("$index: Compose counter=$counter")
onDispose { println("$index: Dispose counter=$counter") }
}
Text(
text = "Item($index) Counter = ${counter.value}",
modifier = Modifier
.pointerInput(Unit) {
println("$index: pointerInput counter=$counter")
detectTapGestures {
println("$index: onTap counter=$counter")
counter.value++
}
}
)
}
}
}
ログの内容を見てみます。
0: Compose counter=MutableState(value=0)@148325955 // 初回コンポーズ
1: Compose counter=MutableState(value=0)@85321693
2: Compose counter=MutableState(value=0)@123639313
3: Compose counter=MutableState(value=0)@206417413
4: Compose counter=MutableState(value=0)@137036729
2: pointerInput counter=MutableState(value=0)@123639313
5: Compose counter=MutableState(value=0)@92139172
6: Compose counter=MutableState(value=0)@182386215
0: Dispose counter=MutableState(value=0)@148325955 // 最初にコンポーズされたコンポーザブルが破棄される
3: pointerInput counter=MutableState(value=0)@206417413
6: Dispose counter=MutableState(value=0)@182386215
0: Compose counter=MutableState(value=0)@60144284 // 再びコンポーズされる
5: Dispose counter=MutableState(value=0)@92139172
0: pointerInput counter=MutableState(value=0)@148325955 // pointerInputのblockには、初回コンポーズ時のオブジェクトが残っている
0: onTap counter=MutableState(value=0)@148325955 // タップ時も、初回コンポーズ時のオブジェクトが参照されている
0: onTap counter=MutableState(value=1)@148325955
0: onTap counter=MutableState(value=2)@148325955
Item 0 に着目します。初回コンポーズ時には、counter
オブジェクトのアドレスは148325955
です。
LazyColumnをスクロールしてItem 0が画面外に出たとき、いったんItem 0は破棄され、その後再び画面内に表示されるときにまたコンポーズされますが、この時のcounter
オブジェクトのアドレスは60144284
で、初回表示時とは別物になっています。
ところが、その後Item 0をタップした時に呼ばれるpointerInput
のblock
と、detectTapGestures
のonTap
から参照されるcounter
オブジェクトは、初回表示時に作成されたcounter
オブジェクトになっています。
どうやらCompose 1.5では、pointerInput
のblock
は、そのpointerInput
を含むコンポーザブルのライフサイクルを超えて保持されるようです。そのため、コンポーザブルが新しいものに入れ替わっても、pointerInput
のblock
とその内部で保持されているオブジェクトは古いまま残っているので、意図した動作になりません。
これが仕様なのかバグなのかを確認することはできませんでした。しかし、コンポーザブルそのものよりもコールバック関数のほうがライフサイクルが長いとなると、気を付けないとアプリのクラッシュを引き起こすこともありそうです。
pointerInput
は、Compose 1.5で実装が大きく変更されています。これが影響しているのではないかと思っていますが、正確なところは分かっていません。
解決策
あらためてpointerInputのリファレンス
を確認すると、異なるkey
でpointerInput
が再コンポーズされると、block
はキャンセルされ、再スタートする、と書かれています。
fun Modifier.pointerInput(key1: Any?, block: suspend PointerInputScope.() -> Unit): Modifier
Specifying the captured value as a
key
parameter will causeblock
to cancel and restart from the beginning if the value changes:
今回のケースでは、counter
オブジェクトを最新に保ちたいので、key
にcounter
を指定してあげれば問題を回避できます。
このとき、counter by remember{ mutableStateOf() }
の糖衣構文で記述するのではなく、MutableState
の形でpointerInput
のkey
に指定するのがポイントです。
// 説明に関係ないレイアウト調整などのコードは省略しています。
@Composable
fun PointerInputTest() {
LazyColumn {
items(10) { index ->
val counter = remember { mutableStateOf(0) }
Text(
text = "Item($index) Counter = ${counter.value}",
modifier = Modifier
.pointerInput(counter) {
detectTapGestures {
counter.value++
}
}
)
}
}
}
以上、今回はpointerInput
を使う際に見落としがちなkey
の指定が効いてくる場面について紹介しました。