STORES Product Blog

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

Jetpack Compose移行に手間取ったボタンの話

はじめに

こんにちは! STORES 決済 のAndroidアプリを開発しているchukaです。

こちらはSTORES Advent Calendar 2024 4日目の記事です。

突然ですが、みなさんボタンは好きですか?

Material3の公式ドキュメントを覗いてみると、そこには9種類ものボタンが紹介されていました。 一口にボタンと言っても、その目的や用途によって様々なデザインのものがあるかと思います。

Material3で紹介されているボタンたち

STORES 決済 Androidチームでは、Android ViewからJetpack Composeへの移行に絶賛取り組んでいる最中です。

今回はその一環で出会った、移行に手間取ったボタンについてお話ししたいと思います。

ボタンの要件

こちらがそのボタンです。店舗のスタッフを削除するためのボタンで、1回目のタップではボタンアクションが発生せず、1回目のタップから一定時間内に再度タップすると削除が発動します。

スタッフ削除ボタン

こちらのボタンは以下の仕様になっています。

  • 3つの状態がある
    PRIMARY(左), PRESSING(中央), SECONDARY(右)
    • PRIMARY
      • 初期状態
    • PRESSING
      • 長押し中の状態
      • 長押し中にボタンの領域外で指を離したらPRIMARYへ遷移
      • 長押し中にボタンの領域内で指を離したらSECONDARYへ遷移
    • SECONDARY
      • PRIMARYから1回タップした後の状態
      • PRESSINGからボタンの領域内で指を離した後の状態
  • SECONDARYの状態で3秒間何もしないと、PRIMARYにリセットされる
  • SECONDARYの状態でタップすると、何らかの処理(今回はユーザー削除)が実行される

これを図にしたものがこちらです。

ボタンの遷移を表した図

一見すると、とてもシンプルなボタンに見えます。

このボタンを実装する上で、大きく2箇所詰まった点があるのでお話ししたいと思います。

1.ジェスチャーイベントの処理

1つ目に詰まったのが、タップと長押しのジェスチャーイベントの処理についてです。

ジェスチャーイベントの検知

さて、Jetpack Composeでのジェスチャーイベントの検知にはいくつかの方法があります。

今回のボタンの要件では、長押し後に指が領域内にあるか・領域外にあるかを検知する必要があります。

この要件を満たす方法として、Modifier.pointerInput内でdetectTapGestureを使う方法と、InteractionSourceを使う方法の2つが思いつきました。

結論から言うと、今回の実装ではInteractionSourceを使用することにしました。

理由としては、ButtonコンポーザブルにModifier.pointerInputを渡してもジェスチャーイベントを処理できないためです。

Buttonコンポーザブルの持つonClick()がジェスチャーイベントを消費しており、 外からModifier.pointerInputを渡してもそのイベントを受け取ることはありません。

詳細は以下の記事とstackoverflowが参考になります。

Buttonコンポーザブルを使わず自前で実装する場合はModifier.pointerInputも候補になりますが、今回はButtonコンポーザブルを用いて実装するため、InteractionSourceを使用してジェスチャーイベントの検知をしていきます。

ジェスチャーイベントの重複

続いてInteractionSourceを用いてボタンの実装をしていきます。

InteractionSourceはinteractionをKotlin CoroutinesのFlowで公開します。 LaunchedEffect内でinteractionをcollectし、PressInteraction.Press/Release/Cancelそれぞれの状態に合わせた処理を実装します。

当初は「ボタンの要件」に示した遷移図のように、ボタンをタップした場合と長押しした場合で処理を分けたいと考えていました。 ボタンを長押しした際の処理はInteractionSourceで行い、タップした際の処理はonClick()で行えばよさそうです。 しかし、この実装には大きな問題があることがわかりました。 実はonClick()PressInteraction.Releaseも「指が離れた瞬間」に処理が走るため、分けたいはずの2つの処理が同時に実行されてしまうのです。

サンプルアプリを使って説明します。

onClick()とPressInteraction.Releaseが同時に実行されている。
⚪︎で指の動きが表示されています。タップ→長押しの順に操作しています。

タップと長押しどちらの場合でも、触れた瞬間にPressInteraction.Pressになり、指が離れた瞬間にonClick()PressInteraction.Releaseの順で処理が走っていることがわかります。

この問題を回避するための策として、以下の2つを思いつきました。

  1. n秒以上Pressされたとき、長押しと判断してPressInteraction.Releaseのみが走るような分岐を作る
  2. クリックと長押しを区別せず、全てInteractionSourceに処理を任せる

1は長押しと判定するための適当な時間設定が難しかったので、2で対処することにしました。 こんな感じでonClick()には処理を記載せず、ジェスチャーイベントの処理は全てInteractionSourceに任せることにします。

タップと長押しの処理をまとめる

@Composable
fun ThreeStateButton(
    confirm: () -> Unit,
) {
    val interactionSource = remember { MutableInteractionSource() }

    var buttonState by remember { mutableStateOf(ButtonState.PRIMARY) }
    var tapCount by remember { mutableIntStateOf(0) }

    LaunchedEffect(Unit) {
        // ジェスチャーイベントの処理はinteractionSourceに任せる
        interactionSource.interactions.collect { interaction ->
            when (interaction) {
                is PressInteraction.Press -> {
                    // SECONDARY以降はPRESSINGにならない
                    if (tapCount < 1) {
                        buttonState = ButtonState.PRESSING
                    }
                }

                is PressInteraction.Release -> {
                    tapCount++
                    buttonState = ButtonState.SECONDARY
                    if (tapCount >= 2) {
                        confirm()
                    }
                 }

                is PressInteraction.Cancel -> {
                    // SECONDARY以降はCancelを考慮しない
                    if (tapCount < 1) {
                        buttonState = ButtonState.PRIMARY
                    }
                }
            }
        }
    }

    Button(
        interactionSource = interactionSource,
        onClick = { 
              // タップ時の処理はInteractionSourceに任せるので何も実装しない  
        },
    ) {
        Text(text = text)
    }
}

実際にできたものがこちらになります。タップ時にも一瞬PRESSINGを通過してしまうのですが、今回の場合はクリティカルな問題でないとして気にしないことにしました。もし気になる場合は、やはりタップと長押しの処理の分岐が必要になるかと思います。

タップと長押しをInteractionSourceにまとめた

2.リセットの実装

次に詰まったのがリセット処理です。 ボタンのstateがSECONDARYになった状態で3秒放置すると、PRIMARYの状態にリセットされるようにします。

PressInteraction.Release時にtapCountが2未満だったらリセットを開始、2以上だったらconfirm()を実行しリセット処理をキャンセルします。

初めは以下のようにjobの変数宣言時にリセット処理の内容を記述していました。 すると、初回タップから3秒経過していないのにリセットされる1度リセットされたあとは2度とリセットされないといった問題が生じました。

val job: Job = remember {
        scope.launch {
            delay(3000)
            tapCount = 0
            buttonState = ButtonState.PRIMARY
        }
    }

...

    LaunchedEffect(Unit) {
        interactionSource.interactions.collect { interaction ->
            when (interaction) {
                is PressInteraction.Press -> { ... }

                is PressInteraction.Release -> {
                    tapCount++
                    if (tapCount >= 2) {
                        confirm()
                        // confirmしたらリセットをキャンセル
                        job.cancel()
                    } else {
                        ...
                        // リセットを実行
                        job.join()
                    }
                 }

                is PressInteraction.Cancel -> { ... }
            }
        }
    }

それもそのはず。 これはjobが宣言されたタイミングでActiveになってしまい、アプリ起動時から意図せずjobが実行されていたためです。 また、Completedになったjobは新たに詰め直してあげないと、joinしてもActiveにすることができません。

CompletedになったjobはActiveにできない

したがって、宣言時はnullを代入しておき、リセットを呼びたいところで処理を記述してあげます。

// nullで宣言
var job: Job? =  remember { null }

...

    LaunchedEffect(Unit) {
        interactionSource.interactions.collect { interaction ->
            when (interaction) {
                is PressInteraction.Press -> { ... }

                is PressInteraction.Release -> {
                    tapCount++
                    if (tapCount >= 2) {
                        confirm()
                        // confirmしたらリセットをキャンセル
                        job?.cancel()
                    } else {
                        ...
                        // リセットの開始タイミングで処理を記述
                        job = scope.launch {
                            delay(3000)
                            tapCount = 0
                            buttonState = ButtonState.PRIMARY
                        }
                    }
                 }

                is PressInteraction.Cancel -> { ... }
            }
        }
    }

完成

最終的に完成したものがこちらです。 `

完成したボタン
gifでは分かりにくい箇所もありますが、以下を満たした実装になっています。

  • タップ・長押しで次の状態に遷移
  • SECONDARYの状態で3秒放置するとPRIMARYにリセットされる
  • confirmでSnackBarを表示させ、その後はリセットされない

まとめ

一見するとシンプルそうだけど、考えることが多く実装に手間取ってしまったボタンのお話でした。 ジェスチャー周りの知見が深まったり、Jobの挙動について調べるきっかけになったり、とても学びが多かったです。 本当は書ききれないくらい試行錯誤したのですが、言語化して説明できない箇所が多いので、また改めてこのボタンについて思いを馳せたいと思います。

「自分ならこう実装する!」や「こうやって実装するといいよ!」などがあればぜひ教えていただけると嬉しいです!