ListやPagerの上でPointerInputを使う時の注意点

この記事をシェア

LazyColumnHorizontalPager/VerticalPagerの上に配置したUIエレメントに対してpointerInputを使う時は、keyを適切に設定しないとうまく動かない場合がある、という話です。

問題ない例

以下の例では、Textがタップジェスチャーを検出したらカウンターをインクリメントする処理を実装しています。このような例だと、pointerInputkeyUnitで問題ありません。

// 説明に関係ないレイアウト調整などのコードは省略しています。
@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オブジェクトをログ出力します。また、pointerInputblockが実行されるタイミングと、タップを検出したタイミングでもそれぞれ、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をタップした時に呼ばれるpointerInputblockと、detectTapGesturesonTapから参照されるcounterオブジェクトは、初回表示時に作成されたcounterオブジェクトになっています。

どうやらCompose 1.5では、pointerInputblockは、そのpointerInputを含むコンポーザブルのライフサイクルを超えて保持されるようです。そのため、コンポーザブルが新しいものに入れ替わっても、pointerInputblockとその内部で保持されているオブジェクトは古いまま残っているので、意図した動作になりません。

これが仕様なのかバグなのかを確認することはできませんでした。しかし、コンポーザブルそのものよりもコールバック関数のほうがライフサイクルが長いとなると、気を付けないとアプリのクラッシュを引き起こすこともありそうです。

pointerInputは、Compose 1.5で実装が大きく変更されています。これが影響しているのではないかと思っていますが、正確なところは分かっていません。

解決策

あらためてpointerInputのリファレンスを確認すると、異なるkeypointerInputが再コンポーズされると、blockはキャンセルされ、再スタートする、と書かれています。

fun Modifier.pointerInput(key1: Any?, block: suspend PointerInputScope.() -> Unit): Modifier

Specifying the captured value as a key parameter will cause block to cancel and restart from the beginning if the value changes:

今回のケースでは、counterオブジェクトを最新に保ちたいので、keycounterを指定してあげれば問題を回避できます。

このとき、counter by remember{ mutableStateOf() }の糖衣構文で記述するのではなく、MutableStateの形でpointerInputkeyに指定するのがポイントです。

// 説明に関係ないレイアウト調整などのコードは省略しています。
@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の指定が効いてくる場面について紹介しました。

この記事をシェア