STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

高品質アプリを支える連続タップ防止の工夫

こんにちは、tomorrowkeyです。

今回はAndroidアプリ開発においてボタンのダブルタップをどう防ぐかについて話していきます。

モバイルアプリのQAのよくある項目として、ボタンのダブルタップがあります。開発に集中していると、この不具合になかなか気づけず、ついつい見逃してしまいがちですが、高い品質が求められるアプリでは確実に防ぐ必要があります。

私はいま STORES 決済 というプロダクトに関わっています。名前からわかるとおり、決済できるアプリなわけですが、まさにこのアプリには高い品質が求められるため、この観点は非常に重要になってくるわけです。

よくあるアプローチとその課題

この問題への一般的な対応として、ボタンをタップした際に一定時間連続したタップを無視する「debounce処理」を実装する方法があります。

実装例を以下に示します。

@Composable
fun debounce(
    interval: Long = 1000L,
    invokableState: MutableState<Boolean> = remember { mutableStateOf(true) },
    block: () -> Unit,
): () -> Unit {
    val coroutineScope = rememberCoroutineScope()
    var invokable by invokableState

    return {
        if (invokable) {
            invokable = false
            coroutineScope.launch {
                delay(interval)
                invokable = true
            }
            block.invoke()
        }
    }
}

// 使用例
@Composable
fun Footer(
    onClickNext: () -> Unit,
) {
  Button(onClick = debounce { onClickNext() }) {
    Text("次へ")
  }
}

この実装を使えば単にonClickで debounce と書くだけなので、なかなかいい実装だなと思いますが、これだけでは実は不十分です。

問題提起としてはボタンのダブルタップと話しましたが、実は複数のボタンをほぼ同時にタップするようなシチュエーションもあります。

「そんな意地悪な操作はだいぶレアケースなのでケアしなくてもよいのでは…?」という見方もあるかもしれませんが、スペックの低いデバイスを利用されている場合、ユーザーがボタンをタップしてもなかなか状態変更されないことでユーザーがボタンがタップできていないと勘違いしてしまい、「同じボタンを押す」「やっぱり他のボタンを押してみる」といったことはよくあることなのではないかなと考えます。

また、単にプロダクトとして高い品質が求められる場合は、必ずケアしておきたい項目でもあります。

それでは複数ボタンでダブルタップを防ぐにはどうすればよいでしょうか。 

新しいアプローチ

複数のボタンで連続タップを防ぐフラグを共有できたらいいなと思いますが、各ボタンを実装するのは、各画面の末端部分であり、単純にすべてのComponentでフラグを共有するには、恐ろしい量の引数を定義しなくてはなりません。

Jetpack Composeにはそういったときに便利に使える仕組みが用意されており、それが Composition Localです。  Composition Localを使ってフラグを共有して連続タップを防いでみましょう。

実装のポイント

  • フラグを上位レベルのComposableで管理する
  • フラグはCompositionLocalで共有する

新しいdebounceの仕組み

// グローバルなクリック可能状態を管理するフラグを提供するCompositionLocal
val LocalGlobalClickableState = compositionLocalOf<MutableState<Boolean>> { error("No GlobalClickableState provided") }

@Composable
fun ProvideGlobalDebounce(
    interval: Long = 1000L,
    content: @Composable () -> Unit,
) {
    // クリック可能状態を保持している変数
    val globalInvokableState = remember { mutableStateOf(true) }
    val coroutineScope = rememberCoroutineScope()

    CompositionLocalProvider(LocalGlobalClickableState provides globalInvokableState) {
        // 指定されたインターバル後に状態をリセットするためのLaunchedEffect
        // フラグがfalseになったときに時限式でtrueに戻している
        LaunchedEffect(globalInvokableState.value) {
            if (!globalInvokableState.value) {
                coroutineScope.launch {
                    delay(interval)
                    globalInvokableState.value = true
                }
            }
        }

        content()
    }
}

@Composable
fun debounce(block: () -> Unit): () -> Unit {
    // グローバルなクリック可能状態を取得
    // プレビューでは LocalGlobalClickableState が提供されないので、適当なmutableStateを設定している
    val globalInvokableState = if (LocalInspectionMode.current) {
        remember { mutableStateOf(true) }
    } else {
        LocalGlobalClickableState.current
    }
    // クリック可能状態を簡単に扱えるように変数定義している
    var invokable by globalInvokableState

    return {
        // クリック可能状態がtrueのときのみ実行する。
        // その際は状態をfalseにして連続したクリックを無視させる
        if (invokable) {
            invokable = false
            block.invoke()
        }
    }
}

ProvideGlobalDebounce はフラグを管理しているComposableで、そのフラグの宣言とComposition Localを使って提供しています。*1

debounce は前述のコードと似ていますが、Composition Localで提供されたフラグを使ってクリックを伝搬させたり、遮断したりしています。 また、フラグを戻す処理も debounce に書かれていましたが、ProvideGlobalDebounce に移っています。

この仕組みを使った実装例を次に示します。

使用例

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            ProvideGlobalDebounce {
                App()
            }
        }
    }
}

@Composable
fun App() {
    var num by remember { mutableIntStateOf(0) }

    Column {
        Text("${num}")
        Button(onClick = debounce { num += 1 }) { 
            Text("+1")
        }
        Button(onClick = debounce { num += 2 }) {
            Text("+2")
        }
        Button(onClick = { num += 3 }) {
            Text("+3")
        }
    }
}

プロダクトコードの本質的なコードを阻害しないような書き方ができました。

+1と+2のボタンは debounce を使っているので、お互いに連続したクリックは防がれるようになっています。

+3のボタンにはdebounceを使っていません。このように連続タップを許容するような書き方もできます。

より発展的な話をすると、debounce関数にキーを持たせられるようにすれば、連続タップを防ぐグルーピングも実現できると思います。

このアプローチで解決できたこと

  • 単一のボタンの連続タップを防げるようになった
  • 複数ボタンの連続タップを防げるようになった

このアプローチでは解決できなかったこと

  • debounceの設定漏れには気づけない
    • もしアプリ全体でdebounceの使用を強制するということであれば、例えばKtlintのカスタムルールを作って未使用の箇所を検出するといった方法があります。

おわりに

この記事では連続タップをどうやって防ぐかについて紹介しました。今回の改善によりユーザー体験の安定化と開発効率の向上を実現できました。
STORES ではより良いアーキテクチャや実装パターンを積極的に取り入れ、高い品質のプロダクトを提供できるように日々努めています。

*1:LocalGlobalClickableState という名前はLocalなのかGlobalなのかわからない名前になっているところが面白いですね。LocalとプレフィックスをつけるのはCompositionLocalの慣習です