ntsk

Jetpack Composeにおける2重タップの防止

August 11, 2023

Android Viewにおける2重タップ防止としては、ViewやHandlerの postDelayed や、coroutinesの delay を利用して一定時間遅延させるものが一般的だが、Composeでも同様に2重タップ防止の共通ロジックが欲しい。

throttlingして実行するcomposable functionを作成する

Coroutinesを利用してタップイベントのthrottlingを行なって一定時間の実行を間引くcomposable function を作成すると下記のようになる。

実装

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

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

実行可能かをStateで保持し、delayで指定時間待機しするようになっており、待機時間中のblockは無視される。 RxJavaのthrottleFirstに因んで、メソッド名もthrottleFirstとしている。 throttleFirst v3

使い方

呼び出す時は次のように呼び出す。

Button(
    onClick = throttleFirst {
        // Click時の処理
    },
) {
    Text(text = "Button")
}

invokableState を渡せるようにしているので、外から状態を渡すこともできる。
状態を外で持つことで、操作できない場合に見た目を変更したりといったケースにも対応できる。
また、複数のコンポーネントで共通のStateを利用することで、複数のボタンを同時にタップできないように制御することもできる。

val invokableState = remember { mutableStateOf(true) }

Button(
    onClick = throttleFirst(invokableState = invokableState) {
        // Click時の処理
    },
) {
    Text(text = "Button1")
}

Button(
    onClick = throttleFirst(invokableState = invokableState) {
        // Click時の処理
    },
) {
    Text(text = "Button2")
}

Button(
    onClick = throttleFirst(invokableState = invokableState) {
        // Click時の処理
    },
) {
    Text(text = "Button3")
}

SwitchClickableText のようにonClickに引数を持つケースもあるので、次のようにジェネリクスの実装も追加しておくと便利。

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

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

Modifier.clickableをthrottlingする拡張関数を作成する

前述のthrottleFirstを利用して、Modifierの拡張関数としてthrottleClickableを定義する。 onClickを引数に持たないcomposable functionはこちらを利用して対応する。

実装

API Guidelines for Jetpack Compose に記載されている通り、Modifierはelement毎に構成され、それぞれが独自の状態を持つ必要がある。このため、 @Composable なModifierの拡張関数を作るのではなく、composed{} を利用してModifierを作ることが推奨されている。

Composed modifiers are composed at each point of application to an element; the same composed modifier may be provided to multiple elements and each will have its own composition state:

As a result, Jetpack Compose framework development and Library development SHOULD use Modifier.composed {} to implement composition-aware modifiers, and SHOULD NOT declare modifier extension factory functions as @Composable functions themselves.

Composed modifiers may be created outside of composition, shared across elements, and declared as top-level constants, making them more flexible than modifiers that can only be created via a @Composable function call, and easier to avoid accidentally sharing state across elements.

fun Modifier.throttleClickable(
    intervalMs: Long = 500L,
    clickableState: MutableState<Boolean>? = null,
    enabled: Boolean = true,
    onClickLabel: String? = null,
    role: Role? = null,
    onClick: () -> Unit,
) = composed {
    Modifier.throttleClickable(
        intervalMs = intervalMs,
        clickableState = clickableState,
        interactionSource = remember { MutableInteractionSource() },
        indication = LocalIndication.current,
        enabled = enabled,
        onClickLabel = onClickLabel,
        role = role,
        onClick = onClick,
    )
}

fun Modifier.throttleClickable(
    intervalMs: Long = 500L,
    clickableState: MutableState<Boolean>? = null,
    interactionSource: MutableInteractionSource,
    indication: Indication?,
    enabled: Boolean = true,
    onClickLabel: String? = null,
    role: Role? = null,
    onClick: () -> Unit,
) = composed(
    inspectorInfo = debugInspectorInfo {
        name = "clickable"
        properties["interactionSource"] = interactionSource
        properties["indication"] = indication
        properties["enabled"] = enabled
        properties["onClickLabel"] = onClickLabel
        properties["role"] = role
        properties["onClick"] = onClick
    },
) {
    val onClickOnce = if (clickableState != null) {
        throttleFirst(intervalMs = intervalMs, invokableState = clickableState, block = onClick)
    } else {
        throttleFirst(intervalMs = intervalMs, block = onClick)
    }

    Modifier.clickable(
        interactionSource = interactionSource,
        indication = indication,
        enabled = enabled,
        onClickLabel = onClickLabel,
        role = role,
        onClick = onClickOnce,
    )

使い方

Modifier.clickable の代わりにModifier.throttleClickableを指定する。
引数はModifier.clickableと同様のものに加えて、throttleFirstで利用するためのintervalMsとclickableStateを渡せるようになっている。

Box(
    modifier = Modifier.throttleClickable { onClickSetting.invoke(setting) },
) {
}

注意点

名前の通り throttle であり、debounce ではない。タップ毎に結果が変わるような機能の場合には、最新の実行結果が発火されるわけではないので注意。


© 2020-2024 ntsk.