Upgrade to Pro
— share decks privately, control downloads, hide ads and more …
Speaker Deck
Features
Speaker Deck
PRO
Sign in
Sign up for free
Search
Search
タッチイベントの仕組みを理解してジェスチャーを使いこなそう
Search
usuiat
September 12, 2024
1
850
タッチイベントの仕組みを理解してジェスチャーを使いこなそう
DroidKaigi 2024
2024年9月13日
https://2024.droidkaigi.jp/timetable/693488/
usuiat
September 12, 2024
Tweet
Share
More Decks by usuiat
See All by usuiat
Google I/O 2024 報告LT会(Androidのオンデバイス生成AI)
usuiat
0
470
Modifier.Nodeに移行してパフォーマンスを比べてみた
usuiat
0
380
reifiedって何ですか?
usuiat
2
770
Featured
See All Featured
Building an army of robots
kneath
302
44k
How to Think Like a Performance Engineer
csswizardry
22
1.2k
Product Roadmaps are Hard
iamctodd
PRO
49
11k
A designer walks into a library…
pauljervisheath
204
24k
Gamification - CAS2011
davidbonilla
80
5.1k
It's Worth the Effort
3n
183
28k
I Don’t Have Time: Getting Over the Fear to Launch Your Podcast
jcasabona
29
2k
Responsive Adventures: Dirty Tricks From The Dark Corners of Front-End
smashingmag
251
21k
Save Time (by Creating Custom Rails Generators)
garrettdimon
PRO
28
900
Imperfection Machines: The Place of Print at Facebook
scottboms
266
13k
The Straight Up "How To Draw Better" Workshop
denniskardys
232
140k
Rails Girls Zürich Keynote
gr2m
94
13k
Transcript
タッチイベントの仕組みを理解して ジェスチャーを使いこなそう 2024年9⽉13⽇ DroidKaigi 2024 usuiat 1 Composeの
⾅井 篤志 / @usuiat / うっすぃー Androidエンジニア Jetpack Composeが好き サイボウズ
/ Garoon モバイル 個⼈開発 / Zoomable 著書「詳解 Jetpack Compose ── 基礎から学ぶAndroidアプリの宣⾔的UI」 2024年11⽉末ごろ 技術評論社より発売予定 2
このセッションのねらい 3 Composeのジェスチャーを あまり知らない⼈には ジェスチャーの ⾯⽩さと奥深さを 知ってほしい 基本を知っている⼈には トラブル解決のための 実例やノウハウを
持ち帰ってほしい
ジェスチャーの例 4 クリック ダブルクリック ⻑押し ドラッグ (パン) ズーム (ピンチ) 回転
その他
このセッションの内容 • 2種類のジェスチャーAPIの使い⽅ • ⾃分でジェスチャーを実装する⽅法 • 実装時に気をつけたいポイント • 実例から学ぶジェスチャー競合の回避策 5
• 2種類のジェスチャーAPIの使い⽅ • ⾃分でジェスチャーを実装する⽅法 • 実装時に気をつけたいポイント • 実例から学ぶジェスチャー競合の回避策 6
2種類のジェスチャーAPI 7 Modifier.〜able detect〜Gestures
Modifier.〜able clickable クリック combinedClickable クリック、ダブルクリック、⻑押し draggable 上下または左右のドラッグ draggable2D 任意の⽅向のドラッグ anchoredDraggable
指を離した後に定位置に⽌まるドラッグ transformable ズーム、パン、回転 8 ジェスチャー検出 + エフェクトによる演出 トークバックによるアクセシビリティ
combinedClickable 9 Image(modifier = Modifier .combinedClickable( onClickLabel = "clickの動作を確認する", role
= Role.Button, onLongClick = { logger.log("LongClick") }, onDoubleClick = { logger.log("DoubleClick") }, onClick = { logger.log("Click") }, ) ) クリック時のリップルエフェクト トークバックの内容を設定
detect〜Gestures detectTapGestures タップ、ダブルタップ、⻑押し detectDragGestures 任意の⽅向のドラッグ detectVerticalDragGestures 上下⽅向のドラッグ detectHorizontalDragGestures 左右⽅向のドラッグ detectDragGesturesAfterLongPress
⻑押し後のドラッグ detectTransformGestures ズーム、パン、回転 10 ジェスチャー検出のみ Modifier.〜ableよりも詳細な情報を取得できるものもある
detectTapGestures 11 Image(modifier = Modifier .pointerInput(Unit) { detectTapGestures( onDoubleTap =
{ offset -> logger.log("Double tap at $offset") }, onLongPress = { offset -> logger.log("Long press at $offset”) }, onTap = { offset -> logger.log("Tap at $offset") } ) } ) エフェクトやトークバックの機能はない ジェスチャーの位置を 取得できる
⽐較 Modifier.〜able detect〜Gestures クリック ダブルクリック ⻑押し clickable combinedClickable detectTapGestures ドラッグ
draggable2D draggable detectDragGestures detectHorizontalDragGestures detectVerticalDragGestures パン、ズーム 回転 transformable detectTransformGestures その他 anchoredDraggable scrollable hoverable etc. detectDragGesturesAfterLongPress 12 トークバック エフェクト 位置取得 位置取得 トークバック エフェクト 位置取得
標準APIではできないこと • 独⾃のジェスチャーの定義 • タップとドラッグを組み合わせたジェスチャー • 複数の指の位置を利⽤したジェスチャー • ジェスチャー競合の調整 •
重なったコンポーザブルがそれぞれジェスチャーを実装している場合 の細かい制御 ⾃分でジェスチャーを実装する必要がある 13
• 2種類のジェスチャーAPIの使い⽅ • ⾃分でジェスチャーを実装する⽅法 • 実装時に気をつけたいポイント • 実例から学ぶジェスチャー競合の回避策 14
あらためて、ジェスチャーって何? ? 15
ジェスチャーとは 16 ※厳密には指だけではなくマウスカーソルなども扱いますが、 このセッションではタッチパネルを指で操作している前提で説明します。 タッチの状態の変化を 画⾯に触れている指について 連続的に取得したもの ?
ジェスチャーとは 17 タッチの状態の変化を 画⾯に触れている指について 連続的に取得したもの
タッチ状態の変化は PointerInputChangeで表される 18 ※コードはイメージです class PointerInputChange { }
タッチ状態の変化は PointerInputChangeで表される 19 タッチ位置 position previousPosition class PointerInputChange { val
position: Offset val previousPosition: Offset } ※コードはイメージです
タッチ状態の変化は PointerInputChangeで表される 20 タッチ位置 イベント発⽣時刻 position previousPosition class PointerInputChange {
val position: Offset val previousPosition: Offset val uptimeMillis: Long val previousUptimeMillis: Long } ※コードはイメージです
タッチ状態の変化は PointerInputChangeで表される 21 タッチ位置 イベント発⽣時刻 接触状態 position previousPosition class PointerInputChange
{ val position: Offset val previousPosition: Offset val uptimeMillis: Long val previousUptimeMillis: Long val pressed: Boolean val previousPressed: Boolean } ※コードはイメージです
タッチ状態の変化は PointerInputChangeで表される 22 class PointerInputChange { fun changedToDown(): Boolean fun
changedToUp(): Boolean fun positionChanged(): Boolean fun positionChange(): Offset } 指が画⾯に触れたかどうか 指が画⾯から離れたかどうか 指の位置が変化したかどうか 指の位置の変化量 ※コードはイメージです
ジェスチャーとは? 23 タッチの状態の変化を 画⾯に触れている指について 連続的に取得したもの
画⾯に触れている指のリストは PointerEventで表される 24 class PointerEvent { val changes: List<PointerInputChange> }
※コードはイメージです 画⾯に触れている指の数だけ PointerInputChangeを保持
画⾯に触れている指のリストは PointerEventで表される 25 class PointerEvent { val changes: List<PointerInputChange> val
type: PointerEventType } 画⾯に触れている指の数だけ PointerInputChangeを保持 イベントの概要 (Press / Move / Releaseなど) ※コードはイメージです
class PointerEvent { fun calculateCentroid(): Offset fun calculateCentroidSize(): Float fun
calculatePan(): Offset fun calculateRotation(): Float fun calculateZoom(): Float } 重⼼ 広がり 移動量 回転量 ズーム PointerInputChange のリストから算出 している 画⾯に触れている指のリストは PointerEventで表される 26 ※コードはイメージです
ジェスチャーとは? 27 タッチの状態の変化を 画⾯に触れているすべての指について 連続的に取得したもの
awaitPointerEventを繰り返し呼び出して、 PointerEventを連続的に取得する 28 val pointerEvent = awaitPointerEvent() イベントを⼀つ取得
awaitPointerEventを繰り返し呼び出して、 PointerEventを連続的に取得する 29 do { val pointerEvent = awaitPointerEvent() }
while (pointerEvent.changes.any { it.pressed }) イベントを⼀つ取得 指が触れている間はループ
awaitPointerEventを繰り返し呼び出して、 PointerEventを連続的に取得する 30 do { val pointerEvent = awaitPointerEvent() //
pointerEventの内容を調べる処理 } while (pointerEvent.changes.any { it.pressed }) イベントを⼀つ取得 指が触れている間はループ
awaitPointerEventを繰り返し呼び出して、 PointerEventを連続的に取得する 31 • ⼀般的には、最初の指が画⾯に触れたらジェスチャー 開始、最後の指が離れたら終了 • ただしダブルタップのように、指が触れて離れて、を 2回以上繰り返すジェスチャーも定義可能 PointerEvent
(Press) PointerEvent (Move) PointerEvent (Move) PointerEvent (Move) PointerEvent (Release)
ジェスチャー実装例 32 Modifier.pointerInput(Unit) { } pointerInputのラムダに記述
ジェスチャー実装例 33 Modifier.pointerInput(Unit) { awaitEachGesture { } } } pointerInputのラムダに記述
1つのジェスチャーを記述
ジェスチャー実装例 34 Modifier.pointerInput(Unit) { awaitEachGesture { val event = awaitPointerEvent()
} } pointerInputのラムダに記述 1つのジェスチャーを記述 1つのイベントを取得
ジェスチャー実装例 35 Modifier.pointerInput(Unit) { awaitEachGesture { do { val event
= awaitPointerEvent() } while (event.changes.any { it.pressed }) } } pointerInputのラムダに記述 1つのジェスチャーを記述 1つのイベントを取得 指が離れるまでループ
ジェスチャー実装例 36 Modifier.pointerInput(Unit) { awaitEachGesture { var dx = 0f
do { val event = awaitPointerEvent() dx += event.calculatePan().x } while (event.changes.any { it.pressed }) } } pointerInputのラムダに記述 1つのジェスチャーを記述 1つのイベントを取得 移動量を算出 指が離れるまでループ
ジェスチャー実装例 37 Modifier.pointerInput(Unit) { awaitEachGesture { var dx = 0f
do { val event = awaitPointerEvent() dx += event.calculatePan().x } while (event.changes.any { it.pressed }) if (dx > 100) { logger.log("Swipe Right") } else if (dx < -100) { logger.log("Swipe Left") } } } pointerInputのラムダに記述 1つのジェスチャーを記述 1つのイベントを取得 移動量を算出 指が離れるまでループ 条件を満たしたら処理を実⾏
ジェスチャー実装例 38 Modifier.pointerInput(Unit) { awaitEachGesture { var dx = 0f
do { val event = awaitPointerEvent() dx += event.calculatePan().x } while (event.changes.any { it.pressed }) if (dx > 100) { logger.log("Swipe Right") } else if (dx < -100) { logger.log("Swipe Left") } } }
ジェスチャーとは 39 タッチの状態の変化を 画⾯に触れている指について 連続的に取得したもの PointerInputChange PointerEvent awaitPointerEventを繰り返し呼ぶ
イベントの伝達順序 40 Box(親) Box { Image() } ⼦から親へ (指に近い側から) 順に伝わる
PointerEvent Image(⼦) PointerEvent
イベントの伝達順序 41 Box(modifier = Modifier .pointerInput(Unit) { awaitEachGesture { do
{ val event = awaitPointerEvent() logger.log("Box ${event.type}") } while (event.changes.any { it.pressed }) } } ) { } Box(親)のジェスチャー
イベントの伝達順序 42 Box(modifier = Modifier ... val event = awaitPointerEvent()
logger.log("Box ${event.type}") ... ) { Image(modifier = Modifier .pointerInput(Unit) { awaitEachGesture { do { val event = awaitPointerEvent() logger.log("Image ${event.type}") } while (event.changes.any { it.pressed }) } } ) } Box(親)のジェスチャー Image(⼦)のジェスチャー
イベントの伝達順序 43 Box(modifier = Modifier ... val event = awaitPointerEvent()
logger.log("Box ${event.type}") ... ) { Image(modifier = Modifier ... val event = awaitPointerEvent() logger.log("Image ${event.type}") ... ) } Box(親)のジェスチャー Image(⼦)のジェスチャー
Box(親) イベントの消費 44 isConsumed == true Image(⼦) consume()
イベントの消費 45 Box(modifier = Modifier ... val event = awaitPointerEvent()
logger.log("Box ${event.type}") ... ) { Image(modifier = Modifier ... val event = awaitPointerEvent() logger.log("Image ${event.type}") ... ) }
イベントの消費 46 Box(modifier = Modifier ... val event = awaitPointerEvent()
logger.log("Box ${event.type}") ... ) { Image(modifier = Modifier ... val event = awaitPointerEvent() logger.log("Image ${event.type}") event.changes.forEach { it.consume() } ... ) } イベントを消費
イベントの消費 47 Box(modifier = Modifier ... val event = awaitPointerEvent()
if (event.changes.any { it.isConsumed.not() }) { logger.log("Box ${event.type}") } ... ) { Image(modifier = Modifier ... val event = awaitPointerEvent() logger.log("Image ${event.type}") event.changes.forEach { it.consume() } ... ) } イベントを消費 消費済みのイベントを無視
イベントの消費 48 Box(modifier = Modifier ... val event = awaitPointerEvent()
if (event.changes.any { it.isConsumed.not() }) { logger.log("Box ${event.type}") } ... ) { Image(modifier = Modifier ... val event = awaitPointerEvent() logger.log("Image ${event.type}") event.changes.forEach { it.consume() } ... ) } イベントを消費 消費済みのイベントを無視
• 2種類のジェスチャーAPIの使い⽅ • ⾃分でジェスチャーを実装する⽅法 • 実装時に気をつけたいポイント • 実例から学ぶジェスチャー競合の回避策 49
私がライブラリ開発中に 実際にハマった落とし⽳を 3つ紹介します 50
pointerInputのkeyは忘れずに 51 .pointerInput(Unit) { } 安易にUnitを設定しがちだが・・・
失敗例 52 var count by remember { mutableIntStateOf(1) } val
logger = remember(count) { Logger() } Image(modifier = Modifier .pointerInput(Unit) { awaitEachGesture { do { val event = awaitPointerEvent() logger.log("${event.type}") } while (event.changes.any { it.pressed }) } } ) Button(onClick = { count++ }) { Text("Reset Log") } loggerが新しくなっても 古いloggerを参照し続ける
修正例 53 var count by remember { mutableIntStateOf(1) } val
logger = remember(count) { Logger() } Image(modifier = Modifier .pointerInput(key1 = logger) { awaitEachGesture { do { val event = awaitPointerEvent() logger.log("${event.type}") } while (event.changes.any { it.pressed }) } } ) Button(onClick = { count++ }) { Text("Reset Log") } keyにloggerを指定 ラムダが更新されて最新のloggerを参照できる
pointerInputのkeyは忘れずに 54 pointerInputのラムダ内で参照しているオブジェクトが 作り直される場合は、keyに指定しましょう
タップのつもりでも指は動く 55 指の位置が少しでも動いたらドラッグ、 まったく動かなかったらタップ ・・・という実装は、誤検出が多くなる
失敗例 56 .pointerInput(Unit) { awaitEachGesture { var moved = false
do { val event = awaitPointerEvent() if (event.type == PointerEventType.Move) { moved = true } } while (event.changes.any { it.pressed }) if (moved) { logger.log("Drag") } else { logger.log("Tap") } } } Moveイベントを取得 ⼀度でもMoveイベントが 発⽣したらドラッグ
タッチスロップ(TouchSlop)の判定 57 画⾯に触れた指の移動距離が閾値以下なら、 移動していないとみなす 閾値にはviewConfiguration.touchSlopをつかうとよい (PointerInputScopeで利⽤可) touchSlop閾値
修正例 58 .pointerInput(Unit) { awaitEachGesture { var distance = 0f
do { val event = awaitPointerEvent() distance += event.calculatePan().getDistance() } while (event.changes.any { it.pressed }) if (distance > viewConfiguration.touchSlop) { logger.log("Drag") } else { logger.log("Tap") } } } 移動距離を算出 移動距離がtouchSlopより ⻑ければドラッグ
タップのつもりでも指は動く 59 指が移動したかどうかは タッチスロップで判定しましょう
2本の指が同時に動くとは限らない 60 2本以上の指のジェスチャーでは、 すべての指が同時に画⾯に触れたり 離れたりするとは限らない centroid(重⼼)は、 指の数が変わると⼤きく変化する centroid Release centroid
失敗例 61 .pointerInput(Unit) { awaitEachGesture { do { val event
= awaitPointerEvent() val centroid = event.calculateCentroid() val time = event.changes[0].uptimeMillis scope.launch { dragState.dragTo(centroid) dragState.trackVelocity(centroid, time) } } while (event.changes.any { it.pressed }) scope.launch { dragState.doFling() } } } centroidに基づいて速度を算出 速度に基づいてFling(慣性動作)
修正例 62 .pointerInput(Unit) { awaitEachGesture { do { val event
= awaitPointerEvent() val centroid = event.calculateCentroid() val time = event.changes[0].uptimeMillis scope.launch { dragState.dragTo(centroid) if (event.changes.size == 1) { dragState.trackVelocity(centroid, time) } } } while (event.changes.any { it.pressed }) scope.launch { dragState.doFling() } } } 指が1本の場合のみ 速度を算出
2本の指が同時に動くとは限らない 63 centroidは指の数が変化するときに⼤きく変化するので 使い⽅に注意しましょう centroidよりもpanやpositionを利⽤する⽅が良い場合も
• 2種類のジェスチャーAPIの使い⽅ • ⾃分でジェスチャーを実装する⽅法 • 実装時に気をつけたいポイント • 実例から学ぶジェスチャー競合の回避策 64
Zoomableというライブラリで直⾯した ジェスチャー競合の問題と その回避策を紹介します 65
Zoomableの紹介 66 • Imageなどのコンポーザブルを ズームできる • Pagerやスクロールする コンポーザブルの上でも使える https://github.com/usuiat/Zoomable ピンチ
ダブルタップ 1本指ズーム
ライブラリを作ったきっかけ フォトビューアーアプリで、 HorizontalPagerの上に配置した写真を ピンチジェスチャーでズームしたかった 67
なぜ標準APIではダメだったのか? • Modifier.transformableやdetectTransformGesturesは内部でイベントを 消費する • 親コンポーザブルのPagerにイベントが伝わらず、ページ送りできない 68 Image(⼦) Pager(親) detectTransformGestures
consume
基本的な考え⽅ • Zoomableが利⽤するジェスチャーは消費する 69 Image(⼦) Pager(親) zoomable Zoom consume
基本的な考え⽅ • Zoomableが利⽤しないジェスチャーは消費しない 70 Image(⼦) Pager(親) zoomable Swipe
基本的な実装イメージ 71 class ZoomState { var scale fun canConsumeGesture(zoom: Float):
Boolean { return scale != 1f || zoom != 1f } } ズームされている場合 または ズームしようとしている場合 はイベントを消費する
基本的な実装イメージ 72 .pointerInput(Unit) { awaitEachGesture { do { val event
= awaitPointerEvent() val zoom = event.calculateZoom() if (zoomState.canConsumeGesture(zoom)) { zoomState.applyZoom(zoom) event.changes.forEach { it.consume() } } } while (event.changes.any { it.pressed }) } } イベントを利⽤する場合のみ ズーム処理を呼び出し、 イベントを消費する
基本的な実装イメージ 73 HorizontalPager() { val zoomState = remember { ZoomState()
} Image(modifier = Modifier .scale(zoomState.scale) .pointerInput(Unit) { ... } ) }
ジェスチャー競合との戦い • ズームとページ送りが同時に発⽣する 場合がある 74 https://github.com/usuiat/Zoomable/issues/93
ピンチジェスチャー判定フロー 75 awaitPointerEvent TouchSlop? canConsume? onGesture(zoom, pan) Released? consume Y
N N N Y Y
原因はタッチスロップ判定 76 Zoomable Pager Press Move Swipe 1 2 3
4 4 Press Move (TouchSlop判定中) Move (TouchSlop判定済) Press Move Zoom consume
PointerEventPassの利⽤ 77 1 3 1つのPointerEventは コンポーザブル階層を 3回伝わる ① Initial ②
Main ③ Final 親 2 ⼦
PointerEventPassの利⽤ 78 1 ① Initialパス Mainパスの前に実⾏される。 親がInitialでイベントを消費 することで、Mainで⼦がイ ベントを利⽤するのを抑制 できる
親 ⼦
PointerEventPassの利⽤ 79 ② Mainパス 通常はこのパスを利⽤する 親 ⼦ 2
PointerEventPassの利⽤ 80 3 ③ Finalパス Mainパスの後に実⾏される。 親がMainパスでイベントを 消費したことを、⼦はFinal パスで検知して処理をキャ ンセルできる
親 ⼦
PointerEventPassを利⽤した修正案 81 Zoomable Pager Press Move Swipe consume https://github.com/usuiat/Zoomable/pull/143 Final
1 2 3 4 4 Press Press 親のイベント消費を検知し ズーム処理をキャンセル Move (TouchSlop判定中)
Zoomable Pager 1 2 3 4 4 Press Move (TouchSlop判定中)
Press Press Move Swipe consume Finalパスで親のイベント 消費を検知しズーム処理 をキャンセル https://github.com/usuiat/Zoomable/pull/143 PointerEventPassを利⽤した修正案 82 まだちょっと問題が!
PointerEventPassを利⽤した修正案の問題 83 Zoomable Clickable consume Press / Release Click Press
/ Release Finalパスで親のイベント消費を 検知しタップやダブルタップを キャンセルしてしまう https://github.com/usuiat/Zoomable/issues/238
PointerEventPassを利⽤した修正案(改) 84 Zoomable Move 1 2 3 Press consume Move
(TouchSlop判定中) consume Move (TouchSlop判定済) https://github.com/usuiat/Zoomable/pull/240
まだまだ問題が残っているかもしれません Issue / PR歓迎です 85 https://github.com/usuiat/Zoomable/issues
• 2種類のジェスチャーAPIの使い⽅ • ⾃分でジェスチャーを実装する⽅法 • 実装時に気をつけたいポイント • 実例から学ぶジェスチャー競合の回避策 86
まとめ • 2種類のジェスチャーAPIの使い⽅の⽐較 • Modifier.〜ableはUI作成に便利。 • detect〜Gesturesはジェスチャー検出に特化 • ⾃分でジェスチャーを実装する⽅法 •
PointerEventを取得し、タッチ状態の変化を調べて、条件を満たして いたら処理を実⾏ • 実装時に気をつけたいポイント • 3つの失敗例と修正例を紹介 • 実例から学ぶジェスチャー競合の回避策 • イベントの消費を細かく制御してズームジェスチャーとページ送りを 両⽴ 87
参考 サンプルコード https://github.com/usuiat/GesturePlayGround 88
ありがとうございました 89