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としている。
使い方
呼び出す時は次のように呼び出す。
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")
}
Switch
や ClickableText
のように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
ではない。タップ毎に結果が変わるような機能の場合には、最新の実行結果が発火されるわけではないので注意。