Share
Embed
タッチイベントの仕組みを理解してジェスチャーを使いこなそう
DroidKaigi 2024
2024年9月13日
https://2024.droidkaigi.jp/timetable/693488/
usuiat
September 12, 2024
Transcript
89 slides
-
⾅井 篤志 / @usuiat / うっすぃー Androidエンジニア Jetpack Composeが好き サイボウズ
/ Garoon モバイル 個⼈開発 / Zoomable 著書「詳解 Jetpack Compose ── 基礎から学ぶAndroidアプリの宣⾔的UI」 2024年11⽉末ごろ 技術評論社より発売予定 2 -
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 -
タッチ状態の変化は 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 } 指が画⾯に触れたかどうか 指が画⾯から離れたかどうか 指の位置が変化したかどうか 指の位置の変化量 ※コードはイメージです -
画⾯に触れている指のリストは 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 ※コードはイメージです -
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) -
ジェスチャー実装例 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") } } } -
イベントの伝達順序 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(⼦)のジェスチャー -
イベントの消費 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() } ... ) } イベントを消費 消費済みのイベントを無視 -
失敗例 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を参照できる -
失敗例 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イベントが 発⽣したらドラッグ -
修正例 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より ⻑ければドラッグ -
失敗例 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本の場合のみ 速度を算出 -
基本的な実装イメージ 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) { ... } ) } -
原因はタッチスロップ判定 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を利⽤した修正案 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 -
まとめ • 2種類のジェスチャーAPIの使い⽅の⽐較 • Modifier.〜ableはUI作成に便利。 • detect〜Gesturesはジェスチャー検出に特化 • ⾃分でジェスチャーを実装する⽅法 •
PointerEventを取得し、タッチ状態の変化を調べて、条件を満たして いたら処理を実⾏ • 実装時に気をつけたいポイント • 3つの失敗例と修正例を紹介 • 実例から学ぶジェスチャー競合の回避策 • イベントの消費を細かく制御してズームジェスチャーとページ送りを 両⽴ 87