Mirrativ Tech Blog

株式会社ミラティブの開発者(バックエンド,iOS,Android,Unity,機械学習,インフラ, etc.)によるブログです

Jetpack ComposeにおけるViewの多重タップ、複数同時タップの防止策

こんにちは。ミラティブのAndroidエンジニアのかねき@kanesyです。

以前の記事でも何度か紹介していますが、MirrativのAndroidアプリでは新規で作る画面には積極的にJetpack Composeを活用しています。

tech.mirrativ.stream

Jetpack Composeでの開発が活発になるにつれて、Viewの多重タップや、リスト中の複数アイテムの同時タップなどの想定外の挙動に向き合う必要が出てきました。

そこで今回は、MirrativのAndroidアプリで採用したJetpack ComposeにおけるViewの多重タップや、リスト中のアイテムの複数同時タップに対する取り組みについて紹介します。

Android Viewにおける多重タップ防止

Jetpack Composeでの実装の説明に移る前に、まずは既存のAndroid Viewにおける多重タップ防止について説明します。

MirrativのAndroidアプリでは、以下の記事で紹介されている方法を採用しています。 https://medium.com/@simon.gerges/solving-android-multiple-clicks-problem-kotlin-b99c06135da0

こちらの記事で紹介されている方法は「Viewに対するonClickイベントが一度発火したら、一定間隔は次のonClickイベントが発火しないように制御する」といったものです。

/**
 * Viewの多重タップを抑止するOnClickListener
 */
class SafeClickListener(
    private var defaultIntervalMillis: Int = 1000,
    private val onSafeClick: (View) -> Unit,
) : View.OnClickListener {
    private var lastMillisClicked: Long = 0

    override fun onClick(v: View) {
        // 現在時刻が、最後にクリックしてからインターバルで指定した時間分経過していない場合はクリックイベントを発火しない
        if (System.currentTimeMillis() - lastMillisClicked < defaultIntervalMillis) {
            return
        }
        lastMillisClicked = System.currentTimeMillis()
        onSafeClick(v)
    }
}

/**
 * SafeClickListenerをViewに設定するための拡張関数
 */
fun View.setSafeOnClickListener(onSafeClick: (View) -> Unit) {
    val safeClickListener = SafeClickListener {
        onSafeClick(it)
    }
    setOnClickListener(safeClickListener)
}

Viewにリスナーを設定する場合は以下のように書きます。

binding.hogeButton.setSafeOnClickListener {
    showDialog()
}

以上がコードの説明になります。 では実際の挙動を見てみましょう。

通常のOnClickListener SafeClickListener

SafeClickListenerを使った場合は、ボタンを連打しても1秒間は次のOnClickイベントが発火していないことがわかると思います。

こちらのケースは、処理の特性から採用するUIに向き不向きがあります。

  • 向いているケース
    • タップして画面遷移する
  • 向いていないケース
    • タップ時にサーバーリクエストなどの処理時間が不確定な処理を行う場合
      • 1秒後に再度タップできてしまうので、多重にサーバーリクエストが走ってしまう可能性がある
    • チェックボックスなど、そもそも1秒間に何度タップされても支障がない場合
      • 1秒間タップが無効化されるのでストレスになる

従って通常のOnClickListenerと使い分けたり、この手法単体で使うのではなくローディング表示や、ボタン自体をdisableにするなど、別の方法と合わせて使ったりしています。

Android Viewにおける同時タップ防止

次に同時タップ防止についてです。こちらはもう少しシンプルでandroid:splitMotionEvents=falseのスタイルを各Activityのthemeに指定することでアプリ全体のViewの同時タップを抑止しています。

<!-- styles.xml -->
<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
    <!-- 二つのボタンを同時押しできないようにする -->
    <item name="android:splitMotionEvents">false</item>
</style>
<!-- AndroidManifest.xml -->
<activity
    android:name=".AndroidActivity"
    android:exported="true"
    android:label="Android View"
    android:theme="@style/AppTheme">
</activity>
splitMotionEvents=true splitMotionEvents=false

android:splitMotionEvents=falseを設定すると、2つ目のViewをタップしても反応しないことがわかります。

Activityのthemeに設定しているので、そのActivity内においては全てのViewで同時押しができなくなるのですが、そういったUIが存在しないActivityにおいては、必要十分な設定だと思います。

JetpackComposeにおける多重タップ防止と同時タップ防止

ではいよいよ今回の本題であるJetpackComposeにおける取り組みについて紹介します。

では実際のコードを見ていきましょう。

/**
 * 多重タップ、複数同時タップを抑止したクリック処理を提供する
 */
fun Modifier.safeClickable(
    defaultIntervalMillis: Long = 1000L,
    enabled: Boolean = true,
    role: Role? = null,
    onClickLabel: String? = null,
    onClick: () -> Unit,
) = composed(inspectorInfo = NoInspectorInfo) {
    val clickableInvoker = LocalSafeClickableInvoker.current
    Modifier.clickable(
        enabled = enabled,
        role = role,
        onClickLabel = onClickLabel,
        onClick = { clickableInvoker.invoke(defaultIntervalMillis, onClick) }
    )
}

// SafeClickableInvokerのインスタンスを保持するCompositionLocalのインスタンス
val LocalSafeClickableInvoker = compositionLocalOf {
    SafeClickableInvoker()
}

/**
 * Clickイベントの発火を制御するクラス
 */
class SafeClickableInvoker {
    private var lastInvokedMillis: Long = 0L

    operator fun invoke(intervalMillis: Long, call: (() -> Unit)) {
        val now = System.currentTimeMillis()
        if (now - lastInvokedMillis > intervalMillis) {
            call()
            lastInvokedMillis = now
        }
    }
}

Modifier.clickableと同じようなインタフェースで使えるModifier.safeClickableという独自の拡張関数を定義しています。中身の処理はAndroid Viewの例と同じような実装になっており、タップしてから一定間隔は次のタップイベントが発火しないように制御しています。

実際に作成したsafeClickableをComposableに設定する場合は以下のように書きます。

@Composable
fun HogeView(
    modifier: Modifier = Modifier,
    onHogeClick: (() -> Unit)? = null,
){
    Box(
        modifier
            .safeClickable { 
                onHogeClick?.invoke() 
            },
    ){
        ///
    }
}
clickable safeClickable

Modifier.clickableと同じようなインタフェースで、想定外のタップ挙動を抑止することができました!

また、実は前述のコードで同時タップについても防ぐことができています。

LocalSafeClickableInvokerというCompositionLocalのインスタンスを定義し、SafeClickableInvokerのインスタンスを同一のCompositionLocalのスコープ内で共有するような構造になっています。

clickable safeClickable

CompositonLocalについての説明は公式の以下の記事を参照してください。

developer.android.com

同時タップの防止が必要ない場合はCompositionLocalを使う必要はなく、上記のコードを以下のように書き換えてsafeClickableのローカルスコープでSafeClickableInvokerをインスタンス化すれば充分です。

    // これを
    val clickableInvoker = LocalSafeClickableInvoker.current

    // こう書き換える
    val clickableInvoker = remember { SafeClickableInvoker() }

おまけ

MirrativのAndroidアプリでの取り組みは以上となりますが、応用編として同時押し出来ない範囲をコントロールする方法についても紹介したいと思います。

CompositionLocalを使っているため、CompositionLocalProviderを使ってスコープを制御することで実現可能です。例えば以下のような実装になります。

@Composable
fun SafeClickableScope(
    content: @Composable () -> Unit
){
    CompositionLocalProvider(LocalSafeClickableInvoker provides SafeClickableInvoker()){
        content()
    }
}

以下のようにスコープを分けることで、同時押しできる範囲を制御することが可能になります。

Row() {
    SafeClickableScope {
        Column() {
            Box(Modifier.safeClickable{ /** A */ })
            Box(Modifier.safeClickable{ /** B */ })        
        }
    }

    SafeClickableScope {
        Column() {
            Box(Modifier.safeClickable{ /** C */ })
            Box(Modifier.safeClickable{ /** D */ })
        }
    }
}

上記の例では以下のような挙動になります。

  • AとB、CとDはそれぞれ同時押しできない
  • AとC、BとDのように、スコープが跨る範囲は同時押しできる

このようなコードが必要になるケースは多くはないと思いますが、例えば「リスト内だけ同時押しを制御したい」等の場合にリストをスコープで囲むことでリスト内とリスト外で制御を分けることが可能になります。

まとめ

今回はMirrativのAndroidアプリにおける多重タップ、同時タップの防止策について紹介しました。

本記事で紹介した方法はあくまでも一例であり、採用しているアーキテクチャやアプリのUI/UXの特性、開発チームの置かれている状況などに応じて適切に実装方法を選択していくことが重要です。例えばMirrativのAndroidアプリではアーキテクチャにFluxを採用しているので、Fluxの中でイベントが単一になるように制御するという方法も検討しました。しかし毎回その実装を行うのも大変だし、この方法で充分だよねということで今回の方法に落ち着きました。

少しでも皆さまの参考になったら幸いです。

We are hiring!

ミラティブでは一緒にアプリを作ってくれる Android エンジニアを募集中です!気軽にご連絡ください!

www.mirrativ.co.jp

speakerdeck.com

mirrativ.notion.site