• トップ
  • ブログ一覧
  • JetpackCompose日記(連続タップ対策を真剣に考える)
  • JetpackCompose日記(連続タップ対策を真剣に考える)

    江幡(エンジニア)江幡(エンジニア)
    2025.02.28

    IT技術

    はじめに

    JetpackComposeでの連続タップ対策を真面目に考たい。

    遷移ボタンの連続タップ対策として
    よくdropUnlessResumed()が使われている記事があったりしますが。
    中身を見ると、連続タップ対策というよりは
    「特定のライフサイクル内かどうか?」を見ているだけです。

    押下と同時に遷移するのであれば2重遷移対策として使えますが、
    「押下時に何かしら処理をして、その処理を待ってから遷移する」みたいな実装では使えません。
    これでは使い勝手が悪いです。

    汎用的な連続タップ対策を考えるなら自分で実装する必要がありそうです。

    「操作を受け付けるかどうか」の状態を保持する

    連続タップ対策では
    「操作を受け付けるかどうか」という状態をBooleanで保持することになると思います。
    なので、「UiState」や「UiStateを作成するための状態」と同じように保持するのがよいです。

    保持場所については以下の動画が参考になります。

    「操作を受け付けるかどうか」のBooleanをどこで保持するのか?
    大きく分けると2種類

    Booleanの保持場所利用するケース
    UiState or ViewModel通信や重い処理など待つ時間が一定ではない場合
    Composable関数内簡易的な連続タップ対策
    ダイアログ表示ボタンの連続タップなど
    待つ時間が一定の場合

    UiState or ViewModelで保持する

    通信や重い処理が絡む場合は、基本的このやり方で実装するべきだと思います。

    1class HogeViewModel {
    2    // 今回はローディング中という状態として保持
    3    val isLoading = MutableStateFlow(false)
    4
    5    val uiState: StateFlow = combine(/*省略*/).stateIn(/*省略*/)
    6    
    7    fun onClickButton() {
    8        if (isLoading.value) {
    9            // ローディング中は無視する
    10            return
    11        }
    12        // ローディング中にする
    13        isLoading.update { true }
    14        // 通信
    15        try {
    16            fetchHoge()
    17        } finally {
    18            isLoading.update { false }
    19        }
    20    }
    21}

    もし、「ローディング中はボタンを非活性にする」などUIにも影響を与えたい場合は。
    以下のように、UiState作成のcombineに、isLoadingを追加すればよさそう。

    1 // UiState生成要素の一つとしている
    2val uiState: StateFlow = combine(
    3        repository.sourceFlow,
    4        isLoading
    5    ) { source, isLoading ->
    6        HogeUiState(
    7            text = source.text,
    8            isEnableButton = !isLoading // isLoadingをUiStateの生成に利用する
    9        )
    10    }.stateIn(/*省略*/)

    Composable関数内で保持する

    ビジネスロジックが絡まない場合や、とにかく簡易的に連続タップ対策をしたい場合はこちらの方法がよいです。
    「Compose関数内のrememberでBooleanを保持して、一定時間経ったらBooleanをもとに戻す」
    みたいな実装です。

    ちょうど参考になりそうな記事を書いてる方がいたのでリンク貼ります。

    Jetpack Compose での多重タップを防ぐコードを考えた
    https://zenn.dev/t2low/articles/4f96f32c919f27

    上記の記事を参考に自分が使いやすいようにカスタムしてみました。

    1/*
    2 * 二重タップや同時押しなど
    3 * 連続で発生するイベントを抑制することを主目的としたDelay状態
    4 *
    5 * 参考:https://zenn.dev/t2low/articles/4f96f32c919f27
    6 */
    7data class DelayState(
    8    val delayTimeMillis: Long = 500,
    9) {
    10    var isDelayed: Boolean by mutableStateOf(true)
    11}
    12
    13@Composable
    14fun rememberDelayState(
    15    intervalTimeMillis: Long = 500,
    16): DelayState {
    17    val state = remember { DelayState(delayTimeMillis = intervalTimeMillis) }
    18    LaunchedEffect(state.isDelayed) {
    19        if (state.isDelayed) return@LaunchedEffect
    20        delay(state.delayTimeMillis)
    21        state.isDelayed = true
    22    }
    23    return state
    24}
    25
    26/**
    27 * Composable関数で使わない場合
    28 */
    29fun dropRedundantEventWith(
    30    delayState: DelayState,
    31    block: () -> Unit,
    32) {
    33    if (delayState.isDelayed) {
    34        delayState.isDelayed = false
    35        block()
    36    }
    37}
    38
    39/**
    40 * Composable関数で直接使う場合
    41 */
    42@Composable
    43fun dropRedundantEvent(
    44    intervalTimeMillis: Long = 500L,
    45    delayState: DelayState = rememberDelayState(intervalTimeMillis = intervalTimeMillis),
    46    block: () -> Unit,
    47): () -> Unit {
    48    return {
    49        if (delayState.isDelayed) {
    50            delayState.isDelayed = false
    51            block()
    52        }
    53    }
    54}

    以下のように使います。

    1// Composable関数内
    2Button(
    3    onClick = dropRedundantEvent {
    4        // 0.5秒以内のイベントは無視する
    5        viewModel.onClickbutton()
    6    }
    7)

    DelayStateを使えば、少し複雑な制御もできます。

    1// Composable関数内
    2val delayState = rememberDelayState()
    3// Button1
    4Button(
    5    onClick = dropRedundantEvent(delayState = delayState) {
    6        // Button1の押下直後は、押下イベントを無視する
    7        // ただし、Button2の押下直後はイベントを無視しない
    8        viewModel.onClickbutton1()
    9    }
    10)
    11// Button2
    12Button(
    13    onClick = {
    14        // Button1の押下直後はButton2の押下イベントも無視する
    15        // ただし、Button2の押下直後はイベントを無視しない
    16        if (delayState.isDelayed) {
    17            viewModel.onClickbutton2()
    18        }
    19    }
    20)

    dropRedundantEventWith()は
    Composable関数を使わない場合用です

    1// Composable関数内
    2val delayState = rememberDelayState() 
    3checkBoxUiStates.forEach {
    4    CheckBoxRow(
    5        // 引数があるラムダなど、Composable関数外から呼びたくなることもあるので
    6        onClick = { id ->
    7            dropRedundantEventWith(delayState = delayState) {
    8                viewModel.onClickCheckButton(id)
    9            }
    10        }
    11    )
    12}

    ライトコードでは、エンジニアを積極採用中!

    ライトコードでは、エンジニアを積極採用しています!社長と一杯しながらお話しする機会もご用意しております。そのほかカジュアル面談等もございますので、くわしくは採用情報をご確認ください。

    採用情報へ

    おすすめ記事

    エンジニア大募集中!

    ライトコードでは、エンジニアを積極採用中です。

    特に、WEBエンジニアとモバイルエンジニアは是非ご応募お待ちしております!

    また、フリーランスエンジニア様も大募集中です。

    background