ListやPagerの上でPointerInputを使う時の注意点
LazyColumnやHorizontalPager/VerticalPagerの上に配置したUIエレメントに対してpointerInputを使う時は、keyを適切に設定しないとうまく動かない場合がある、という話です。
問題ない例
以下の例では、Textがタップジェスチャーを検出したらカウンターをインクリメントする処理を実装しています。このような例だと、pointerInputのkeyはUnitで問題ありません。
// 説明に関係ないレイアウト調整などのコードは省略しています。@Composablefun 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では動作しませんでした。
最初は問題ないのですが、いったんスクロールしてから再度先頭のアイテムに戻ってきたときに、カウンターがインクリメントされません。
// 説明に関係ないレイアウト調整などのコードは省略しています。@Composablefun 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オブジェクトをログ出力します。
// 説明に関係ないレイアウト調整などのコードは省略しています。@Composablefun 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)@853216932: Compose counter=MutableState(value=0)@1236393133: Compose counter=MutableState(value=0)@2064174134: Compose counter=MutableState(value=0)@1370367292: pointerInput counter=MutableState(value=0)@1236393135: Compose counter=MutableState(value=0)@921391726: Compose counter=MutableState(value=0)@1823862150: Dispose counter=MutableState(value=0)@148325955 // 最初にコンポーズされたコンポーザブルが破棄される3: pointerInput counter=MutableState(value=0)@2064174136: Dispose counter=MutableState(value=0)@1823862150: Compose counter=MutableState(value=0)@60144284 // 再びコンポーズされる5: Dispose counter=MutableState(value=0)@921391720: pointerInput counter=MutableState(value=0)@148325955 // pointerInputのblockには、初回コンポーズ時のオブジェクトが残っている0: onTap counter=MutableState(value=0)@148325955 // タップ時も、初回コンポーズ時のオブジェクトが参照されている0: onTap counter=MutableState(value=1)@1483259550: onTap counter=MutableState(value=2)@148325955Item 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): ModifierSpecifying the captured value as a
keyparameter will causeblockto cancel and restart from the beginning if the value changes:
今回のケースでは、counterオブジェクトを最新に保ちたいので、keyにcounterを指定してあげれば問題を回避できます。
このとき、counter by remember{ mutableStateOf() }の糖衣構文で記述するのではなく、MutableStateの形でpointerInputのkeyに指定するのがポイントです。
// 説明に関係ないレイアウト調整などのコードは省略しています。@Composablefun 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の指定が効いてくる場面について紹介しました。