Slide 1

Slide 1 text

タッチイベントの仕組みを理解して ジェスチャーを使いこなそう 2024年9⽉13⽇ DroidKaigi 2024 usuiat 1 Composeの

Slide 2

Slide 2 text

⾅井 篤志 / @usuiat / うっすぃー Androidエンジニア Jetpack Composeが好き サイボウズ / Garoon モバイル 個⼈開発 / Zoomable 著書「詳解 Jetpack Compose ── 基礎から学ぶAndroidアプリの宣⾔的UI」 2024年11⽉末ごろ 技術評論社より発売予定 2

Slide 3

Slide 3 text

このセッションのねらい 3 Composeのジェスチャーを あまり知らない⼈には ジェスチャーの ⾯⽩さと奥深さを 知ってほしい 基本を知っている⼈には トラブル解決のための 実例やノウハウを 持ち帰ってほしい

Slide 4

Slide 4 text

ジェスチャーの例 4 クリック ダブルクリック ⻑押し ドラッグ (パン) ズーム (ピンチ) 回転 その他

Slide 5

Slide 5 text

このセッションの内容 • 2種類のジェスチャーAPIの使い⽅ • ⾃分でジェスチャーを実装する⽅法 • 実装時に気をつけたいポイント • 実例から学ぶジェスチャー競合の回避策 5

Slide 6

Slide 6 text

• 2種類のジェスチャーAPIの使い⽅ • ⾃分でジェスチャーを実装する⽅法 • 実装時に気をつけたいポイント • 実例から学ぶジェスチャー競合の回避策 6

Slide 7

Slide 7 text

2種類のジェスチャーAPI 7 Modifier.〜able detect〜Gestures

Slide 8

Slide 8 text

Modifier.〜able clickable クリック combinedClickable クリック、ダブルクリック、⻑押し draggable 上下または左右のドラッグ draggable2D 任意の⽅向のドラッグ anchoredDraggable 指を離した後に定位置に⽌まるドラッグ transformable ズーム、パン、回転 8 ジェスチャー検出 + エフェクトによる演出 トークバックによるアクセシビリティ

Slide 9

Slide 9 text

combinedClickable 9 Image(modifier = Modifier .combinedClickable( onClickLabel = "clickの動作を確認する", role = Role.Button, onLongClick = { logger.log("LongClick") }, onDoubleClick = { logger.log("DoubleClick") }, onClick = { logger.log("Click") }, ) ) クリック時のリップルエフェクト トークバックの内容を設定

Slide 10

Slide 10 text

detect〜Gestures detectTapGestures タップ、ダブルタップ、⻑押し detectDragGestures 任意の⽅向のドラッグ detectVerticalDragGestures 上下⽅向のドラッグ detectHorizontalDragGestures 左右⽅向のドラッグ detectDragGesturesAfterLongPress ⻑押し後のドラッグ detectTransformGestures ズーム、パン、回転 10 ジェスチャー検出のみ Modifier.〜ableよりも詳細な情報を取得できるものもある

Slide 11

Slide 11 text

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") } ) } ) エフェクトやトークバックの機能はない ジェスチャーの位置を 取得できる

Slide 12

Slide 12 text

⽐較 Modifier.〜able detect〜Gestures クリック ダブルクリック ⻑押し clickable combinedClickable detectTapGestures ドラッグ draggable2D draggable detectDragGestures detectHorizontalDragGestures detectVerticalDragGestures パン、ズーム 回転 transformable detectTransformGestures その他 anchoredDraggable scrollable hoverable etc. detectDragGesturesAfterLongPress 12 トークバック エフェクト 位置取得 位置取得 トークバック エフェクト 位置取得

Slide 13

Slide 13 text

標準APIではできないこと • 独⾃のジェスチャーの定義 • タップとドラッグを組み合わせたジェスチャー • 複数の指の位置を利⽤したジェスチャー • ジェスチャー競合の調整 • 重なったコンポーザブルがそれぞれジェスチャーを実装している場合 の細かい制御 ⾃分でジェスチャーを実装する必要がある 13

Slide 14

Slide 14 text

• 2種類のジェスチャーAPIの使い⽅ • ⾃分でジェスチャーを実装する⽅法 • 実装時に気をつけたいポイント • 実例から学ぶジェスチャー競合の回避策 14

Slide 15

Slide 15 text

あらためて、ジェスチャーって何? ? 15

Slide 16

Slide 16 text

ジェスチャーとは 16 ※厳密には指だけではなくマウスカーソルなども扱いますが、 このセッションではタッチパネルを指で操作している前提で説明します。 タッチの状態の変化を 画⾯に触れている指について 連続的に取得したもの ?

Slide 17

Slide 17 text

ジェスチャーとは 17 タッチの状態の変化を 画⾯に触れている指について 連続的に取得したもの

Slide 18

Slide 18 text

タッチ状態の変化は PointerInputChangeで表される 18 ※コードはイメージです class PointerInputChange { }

Slide 19

Slide 19 text

タッチ状態の変化は PointerInputChangeで表される 19 タッチ位置 position previousPosition class PointerInputChange { val position: Offset val previousPosition: Offset } ※コードはイメージです

Slide 20

Slide 20 text

タッチ状態の変化は PointerInputChangeで表される 20 タッチ位置 イベント発⽣時刻 position previousPosition class PointerInputChange { val position: Offset val previousPosition: Offset val uptimeMillis: Long val previousUptimeMillis: Long } ※コードはイメージです

Slide 21

Slide 21 text

タッチ状態の変化は PointerInputChangeで表される 21 タッチ位置 イベント発⽣時刻 接触状態 position previousPosition class PointerInputChange { val position: Offset val previousPosition: Offset val uptimeMillis: Long val previousUptimeMillis: Long val pressed: Boolean val previousPressed: Boolean } ※コードはイメージです

Slide 22

Slide 22 text

タッチ状態の変化は PointerInputChangeで表される 22 class PointerInputChange { fun changedToDown(): Boolean fun changedToUp(): Boolean fun positionChanged(): Boolean fun positionChange(): Offset } 指が画⾯に触れたかどうか 指が画⾯から離れたかどうか 指の位置が変化したかどうか 指の位置の変化量 ※コードはイメージです

Slide 23

Slide 23 text

ジェスチャーとは? 23 タッチの状態の変化を 画⾯に触れている指について 連続的に取得したもの

Slide 24

Slide 24 text

画⾯に触れている指のリストは PointerEventで表される 24 class PointerEvent { val changes: List } ※コードはイメージです 画⾯に触れている指の数だけ PointerInputChangeを保持

Slide 25

Slide 25 text

画⾯に触れている指のリストは PointerEventで表される 25 class PointerEvent { val changes: List val type: PointerEventType } 画⾯に触れている指の数だけ PointerInputChangeを保持 イベントの概要 (Press / Move / Releaseなど) ※コードはイメージです

Slide 26

Slide 26 text

class PointerEvent { fun calculateCentroid(): Offset fun calculateCentroidSize(): Float fun calculatePan(): Offset fun calculateRotation(): Float fun calculateZoom(): Float } 重⼼ 広がり 移動量 回転量 ズーム PointerInputChange のリストから算出 している 画⾯に触れている指のリストは PointerEventで表される 26 ※コードはイメージです

Slide 27

Slide 27 text

ジェスチャーとは? 27 タッチの状態の変化を 画⾯に触れているすべての指について 連続的に取得したもの

Slide 28

Slide 28 text

awaitPointerEventを繰り返し呼び出して、 PointerEventを連続的に取得する 28 val pointerEvent = awaitPointerEvent() イベントを⼀つ取得

Slide 29

Slide 29 text

awaitPointerEventを繰り返し呼び出して、 PointerEventを連続的に取得する 29 do { val pointerEvent = awaitPointerEvent() } while (pointerEvent.changes.any { it.pressed }) イベントを⼀つ取得 指が触れている間はループ

Slide 30

Slide 30 text

awaitPointerEventを繰り返し呼び出して、 PointerEventを連続的に取得する 30 do { val pointerEvent = awaitPointerEvent() // pointerEventの内容を調べる処理 } while (pointerEvent.changes.any { it.pressed }) イベントを⼀つ取得 指が触れている間はループ

Slide 31

Slide 31 text

awaitPointerEventを繰り返し呼び出して、 PointerEventを連続的に取得する 31 • ⼀般的には、最初の指が画⾯に触れたらジェスチャー 開始、最後の指が離れたら終了 • ただしダブルタップのように、指が触れて離れて、を 2回以上繰り返すジェスチャーも定義可能 PointerEvent (Press) PointerEvent (Move) PointerEvent (Move) PointerEvent (Move) PointerEvent (Release)

Slide 32

Slide 32 text

ジェスチャー実装例 32 Modifier.pointerInput(Unit) { } pointerInputのラムダに記述

Slide 33

Slide 33 text

ジェスチャー実装例 33 Modifier.pointerInput(Unit) { awaitEachGesture { } } } pointerInputのラムダに記述 1つのジェスチャーを記述

Slide 34

Slide 34 text

ジェスチャー実装例 34 Modifier.pointerInput(Unit) { awaitEachGesture { val event = awaitPointerEvent() } } pointerInputのラムダに記述 1つのジェスチャーを記述 1つのイベントを取得

Slide 35

Slide 35 text

ジェスチャー実装例 35 Modifier.pointerInput(Unit) { awaitEachGesture { do { val event = awaitPointerEvent() } while (event.changes.any { it.pressed }) } } pointerInputのラムダに記述 1つのジェスチャーを記述 1つのイベントを取得 指が離れるまでループ

Slide 36

Slide 36 text

ジェスチャー実装例 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つのイベントを取得 移動量を算出 指が離れるまでループ

Slide 37

Slide 37 text

ジェスチャー実装例 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つのイベントを取得 移動量を算出 指が離れるまでループ 条件を満たしたら処理を実⾏

Slide 38

Slide 38 text

ジェスチャー実装例 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") } } }

Slide 39

Slide 39 text

ジェスチャーとは 39 タッチの状態の変化を 画⾯に触れている指について 連続的に取得したもの PointerInputChange PointerEvent awaitPointerEventを繰り返し呼ぶ

Slide 40

Slide 40 text

イベントの伝達順序 40 Box(親) Box { Image() } ⼦から親へ (指に近い側から) 順に伝わる PointerEvent Image(⼦) PointerEvent

Slide 41

Slide 41 text

イベントの伝達順序 41 Box(modifier = Modifier .pointerInput(Unit) { awaitEachGesture { do { val event = awaitPointerEvent() logger.log("Box ${event.type}") } while (event.changes.any { it.pressed }) } } ) { } Box(親)のジェスチャー

Slide 42

Slide 42 text

イベントの伝達順序 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(⼦)のジェスチャー

Slide 43

Slide 43 text

イベントの伝達順序 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(⼦)のジェスチャー

Slide 44

Slide 44 text

Box(親) イベントの消費 44 isConsumed == true Image(⼦) consume()

Slide 45

Slide 45 text

イベントの消費 45 Box(modifier = Modifier ... val event = awaitPointerEvent() logger.log("Box ${event.type}") ... ) { Image(modifier = Modifier ... val event = awaitPointerEvent() logger.log("Image ${event.type}") ... ) }

Slide 46

Slide 46 text

イベントの消費 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() } ... ) } イベントを消費

Slide 47

Slide 47 text

イベントの消費 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() } ... ) } イベントを消費 消費済みのイベントを無視

Slide 48

Slide 48 text

イベントの消費 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() } ... ) } イベントを消費 消費済みのイベントを無視

Slide 49

Slide 49 text

• 2種類のジェスチャーAPIの使い⽅ • ⾃分でジェスチャーを実装する⽅法 • 実装時に気をつけたいポイント • 実例から学ぶジェスチャー競合の回避策 49

Slide 50

Slide 50 text

私がライブラリ開発中に 実際にハマった落とし⽳を 3つ紹介します 50

Slide 51

Slide 51 text

pointerInputのkeyは忘れずに 51 .pointerInput(Unit) { } 安易にUnitを設定しがちだが・・・

Slide 52

Slide 52 text

失敗例 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を参照し続ける

Slide 53

Slide 53 text

修正例 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を参照できる

Slide 54

Slide 54 text

pointerInputのkeyは忘れずに 54 pointerInputのラムダ内で参照しているオブジェクトが 作り直される場合は、keyに指定しましょう

Slide 55

Slide 55 text

タップのつもりでも指は動く 55 指の位置が少しでも動いたらドラッグ、 まったく動かなかったらタップ ・・・という実装は、誤検出が多くなる

Slide 56

Slide 56 text

失敗例 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イベントが 発⽣したらドラッグ

Slide 57

Slide 57 text

タッチスロップ(TouchSlop)の判定 57 画⾯に触れた指の移動距離が閾値以下なら、 移動していないとみなす 閾値にはviewConfiguration.touchSlopをつかうとよい (PointerInputScopeで利⽤可) touchSlop閾値

Slide 58

Slide 58 text

修正例 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より ⻑ければドラッグ

Slide 59

Slide 59 text

タップのつもりでも指は動く 59 指が移動したかどうかは タッチスロップで判定しましょう

Slide 60

Slide 60 text

2本の指が同時に動くとは限らない 60 2本以上の指のジェスチャーでは、 すべての指が同時に画⾯に触れたり 離れたりするとは限らない centroid(重⼼)は、 指の数が変わると⼤きく変化する centroid Release centroid

Slide 61

Slide 61 text

失敗例 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(慣性動作)

Slide 62

Slide 62 text

修正例 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本の場合のみ 速度を算出

Slide 63

Slide 63 text

2本の指が同時に動くとは限らない 63 centroidは指の数が変化するときに⼤きく変化するので 使い⽅に注意しましょう centroidよりもpanやpositionを利⽤する⽅が良い場合も

Slide 64

Slide 64 text

• 2種類のジェスチャーAPIの使い⽅ • ⾃分でジェスチャーを実装する⽅法 • 実装時に気をつけたいポイント • 実例から学ぶジェスチャー競合の回避策 64

Slide 65

Slide 65 text

Zoomableというライブラリで直⾯した ジェスチャー競合の問題と その回避策を紹介します 65

Slide 66

Slide 66 text

Zoomableの紹介 66 • Imageなどのコンポーザブルを ズームできる • Pagerやスクロールする コンポーザブルの上でも使える https://github.com/usuiat/Zoomable ピンチ ダブルタップ 1本指ズーム

Slide 67

Slide 67 text

ライブラリを作ったきっかけ フォトビューアーアプリで、 HorizontalPagerの上に配置した写真を ピンチジェスチャーでズームしたかった 67

Slide 68

Slide 68 text

なぜ標準APIではダメだったのか? • Modifier.transformableやdetectTransformGesturesは内部でイベントを 消費する • 親コンポーザブルのPagerにイベントが伝わらず、ページ送りできない 68 Image(⼦) Pager(親) detectTransformGestures consume

Slide 69

Slide 69 text

基本的な考え⽅ • Zoomableが利⽤するジェスチャーは消費する 69 Image(⼦) Pager(親) zoomable Zoom consume

Slide 70

Slide 70 text

基本的な考え⽅ • Zoomableが利⽤しないジェスチャーは消費しない 70 Image(⼦) Pager(親) zoomable Swipe

Slide 71

Slide 71 text

基本的な実装イメージ 71 class ZoomState { var scale fun canConsumeGesture(zoom: Float): Boolean { return scale != 1f || zoom != 1f } } ズームされている場合 または ズームしようとしている場合 はイベントを消費する

Slide 72

Slide 72 text

基本的な実装イメージ 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 }) } } イベントを利⽤する場合のみ ズーム処理を呼び出し、 イベントを消費する

Slide 73

Slide 73 text

基本的な実装イメージ 73 HorizontalPager() { val zoomState = remember { ZoomState() } Image(modifier = Modifier .scale(zoomState.scale) .pointerInput(Unit) { ... } ) }

Slide 74

Slide 74 text

ジェスチャー競合との戦い • ズームとページ送りが同時に発⽣する 場合がある 74 https://github.com/usuiat/Zoomable/issues/93

Slide 75

Slide 75 text

ピンチジェスチャー判定フロー 75 awaitPointerEvent TouchSlop? canConsume? onGesture(zoom, pan) Released? consume Y N N N Y Y

Slide 76

Slide 76 text

原因はタッチスロップ判定 76 Zoomable Pager Press Move Swipe 1 2 3 4 4 Press Move (TouchSlop判定中) Move (TouchSlop判定済) Press Move Zoom consume

Slide 77

Slide 77 text

PointerEventPassの利⽤ 77 1 3 1つのPointerEventは コンポーザブル階層を 3回伝わる ① Initial ② Main ③ Final 親 2 ⼦

Slide 78

Slide 78 text

PointerEventPassの利⽤ 78 1 ① Initialパス Mainパスの前に実⾏される。 親がInitialでイベントを消費 することで、Mainで⼦がイ ベントを利⽤するのを抑制 できる 親 ⼦

Slide 79

Slide 79 text

PointerEventPassの利⽤ 79 ② Mainパス 通常はこのパスを利⽤する 親 ⼦ 2

Slide 80

Slide 80 text

PointerEventPassの利⽤ 80 3 ③ Finalパス Mainパスの後に実⾏される。 親がMainパスでイベントを 消費したことを、⼦はFinal パスで検知して処理をキャ ンセルできる 親 ⼦

Slide 81

Slide 81 text

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判定中)

Slide 82

Slide 82 text

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 まだちょっと問題が!

Slide 83

Slide 83 text

PointerEventPassを利⽤した修正案の問題 83 Zoomable Clickable consume Press / Release Click Press / Release Finalパスで親のイベント消費を 検知しタップやダブルタップを キャンセルしてしまう https://github.com/usuiat/Zoomable/issues/238

Slide 84

Slide 84 text

PointerEventPassを利⽤した修正案(改) 84 Zoomable Move 1 2 3 Press consume Move (TouchSlop判定中) consume Move (TouchSlop判定済) https://github.com/usuiat/Zoomable/pull/240

Slide 85

Slide 85 text

まだまだ問題が残っているかもしれません Issue / PR歓迎です 85 https://github.com/usuiat/Zoomable/issues

Slide 86

Slide 86 text

• 2種類のジェスチャーAPIの使い⽅ • ⾃分でジェスチャーを実装する⽅法 • 実装時に気をつけたいポイント • 実例から学ぶジェスチャー競合の回避策 86

Slide 87

Slide 87 text

まとめ • 2種類のジェスチャーAPIの使い⽅の⽐較 • Modifier.〜ableはUI作成に便利。 • detect〜Gesturesはジェスチャー検出に特化 • ⾃分でジェスチャーを実装する⽅法 • PointerEventを取得し、タッチ状態の変化を調べて、条件を満たして いたら処理を実⾏ • 実装時に気をつけたいポイント • 3つの失敗例と修正例を紹介 • 実例から学ぶジェスチャー競合の回避策 • イベントの消費を細かく制御してズームジェスチャーとページ送りを 両⽴ 87

Slide 88

Slide 88 text

参考 サンプルコード https://github.com/usuiat/GesturePlayGround 88

Slide 89

Slide 89 text

ありがとうございました 89