はじめに
こんにちは! STORES 決済 のAndroidアプリを開発しているchukaです。
こちらはSTORES Advent Calendar 2024 4日目の記事です。
突然ですが、みなさんボタンは好きですか?
Material3の公式ドキュメントを覗いてみると、そこには9種類ものボタンが紹介されていました。 一口にボタンと言っても、その目的や用途によって様々なデザインのものがあるかと思います。
STORES 決済 Androidチームでは、Android ViewからJetpack Composeへの移行に絶賛取り組んでいる最中です。
今回はその一環で出会った、移行に手間取ったボタンについてお話ししたいと思います。
ボタンの要件
こちらがそのボタンです。店舗のスタッフを削除するためのボタンで、1回目のタップではボタンアクションが発生せず、1回目のタップから一定時間内に再度タップすると削除が発動します。
こちらのボタンは以下の仕様になっています。
- 3つの状態がある
- PRIMARY
- 初期状態
- PRESSING
- 長押し中の状態
- 長押し中にボタンの領域外で指を離したらPRIMARYへ遷移
- 長押し中にボタンの領域内で指を離したらSECONDARYへ遷移
- SECONDARY
- PRIMARYから1回タップした後の状態
- PRESSINGからボタンの領域内で指を離した後の状態
- PRIMARY
- SECONDARYの状態で3秒間何もしないと、PRIMARYにリセットされる
- SECONDARYの状態でタップすると、何らかの処理(今回はユーザー削除)が実行される
これを図にしたものがこちらです。
一見すると、とてもシンプルなボタンに見えます。
このボタンを実装する上で、大きく2箇所詰まった点があるのでお話ししたいと思います。
1.ジェスチャーイベントの処理
1つ目に詰まったのが、タップと長押しのジェスチャーイベントの処理についてです。
ジェスチャーイベントの検知
さて、Jetpack Composeでのジェスチャーイベントの検知にはいくつかの方法があります。
今回のボタンの要件では、長押し後に指が領域内にあるか・領域外にあるかを検知する必要があります。
この要件を満たす方法として、Modifier.pointerInput
内でdetectTapGestureを使う方法と、InteractionSourceを使う方法の2つが思いつきました。
結論から言うと、今回の実装ではInteractionSource
を使用することにしました。
理由としては、ButtonコンポーザブルにModifier.pointerInput
を渡してもジェスチャーイベントを処理できないためです。
Buttonコンポーザブルの持つonClick()
がジェスチャーイベントを消費しており、
外からModifier.pointerInput
を渡してもそのイベントを受け取ることはありません。
詳細は以下の記事とstackoverflowが参考になります。
- 意外と知らないModifier.clickable #Android - Qiita
- kotlin - Jetpack Compose Android Button detect long click - Stack Overflow
Buttonコンポーザブルを使わず自前で実装する場合はModifier.pointerInput
も候補になりますが、今回はButtonコンポーザブルを用いて実装するため、InteractionSource
を使用してジェスチャーイベントの検知をしていきます。
ジェスチャーイベントの重複
続いてInteractionSource
を用いてボタンの実装をしていきます。
InteractionSource
はinteractionをKotlin CoroutinesのFlowで公開します。
LaunchedEffect内でinteractionをcollectし、PressInteraction.Press/Release/Cancel
それぞれの状態に合わせた処理を実装します。
当初は「ボタンの要件」に示した遷移図のように、ボタンをタップした場合と長押しした場合で処理を分けたいと考えていました。
ボタンを長押しした際の処理はInteractionSource
で行い、タップした際の処理はonClick()
で行えばよさそうです。
しかし、この実装には大きな問題があることがわかりました。
実はonClick()
もPressInteraction.Release
も「指が離れた瞬間」に処理が走るため、分けたいはずの2つの処理が同時に実行されてしまうのです。
サンプルアプリを使って説明します。
⚪︎で指の動きが表示されています。タップ→長押しの順に操作しています。
タップと長押しどちらの場合でも、触れた瞬間にPressInteraction.Press
になり、指が離れた瞬間にonClick()
→PressInteraction.Release
の順で処理が走っていることがわかります。
この問題を回避するための策として、以下の2つを思いつきました。
- n秒以上Pressされたとき、長押しと判断して
PressInteraction.Release
のみが走るような分岐を作る - クリックと長押しを区別せず、全て
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を通過してしまうのですが、今回の場合はクリティカルな問題でないとして気にしないことにしました。もし気になる場合は、やはりタップと長押しの処理の分岐が必要になるかと思います。
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にすることができません。
したがって、宣言時は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の挙動について調べるきっかけになったり、とても学びが多かったです。 本当は書ききれないくらい試行錯誤したのですが、言語化して説明できない箇所が多いので、また改めてこのボタンについて思いを馳せたいと思います。
「自分ならこう実装する!」や「こうやって実装するといいよ!」などがあればぜひ教えていただけると嬉しいです!