Upgrade to Pro — share decks privately, control downloads, hide ads and more …

タッチイベントの仕組みを理解してジェスチャーを使いこなそう

usuiat
September 12, 2024
760

 タッチイベントの仕組みを理解してジェスチャーを使いこなそう

DroidKaigi 2024
2024年9月13日
https://2024.droidkaigi.jp/timetable/693488/

usuiat

September 12, 2024
Tweet

Transcript

  1. ⾅井 篤志 / @usuiat / うっすぃー Androidエンジニア Jetpack Composeが好き サイボウズ

    / Garoon モバイル 個⼈開発 / Zoomable 著書「詳解 Jetpack Compose ── 基礎から学ぶAndroidアプリの宣⾔的UI」 2024年11⽉末ごろ 技術評論社より発売予定 2
  2. Modifier.〜able clickable クリック combinedClickable クリック、ダブルクリック、⻑押し draggable 上下または左右のドラッグ draggable2D 任意の⽅向のドラッグ anchoredDraggable

    指を離した後に定位置に⽌まるドラッグ transformable ズーム、パン、回転 8 ジェスチャー検出 + エフェクトによる演出 トークバックによるアクセシビリティ
  3. combinedClickable 9 Image(modifier = Modifier .combinedClickable( onClickLabel = "clickの動作を確認する", role

    = Role.Button, onLongClick = { logger.log("LongClick") }, onDoubleClick = { logger.log("DoubleClick") }, onClick = { logger.log("Click") }, ) ) クリック時のリップルエフェクト トークバックの内容を設定
  4. detect〜Gestures detectTapGestures タップ、ダブルタップ、⻑押し detectDragGestures 任意の⽅向のドラッグ detectVerticalDragGestures 上下⽅向のドラッグ detectHorizontalDragGestures 左右⽅向のドラッグ detectDragGesturesAfterLongPress

    ⻑押し後のドラッグ detectTransformGestures ズーム、パン、回転 10 ジェスチャー検出のみ Modifier.〜ableよりも詳細な情報を取得できるものもある
  5. 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") } ) } ) エフェクトやトークバックの機能はない ジェスチャーの位置を 取得できる
  6. ⽐較 Modifier.〜able detect〜Gestures クリック ダブルクリック ⻑押し clickable combinedClickable detectTapGestures ドラッグ

    draggable2D draggable detectDragGestures detectHorizontalDragGestures detectVerticalDragGestures パン、ズーム 回転 transformable detectTransformGestures その他 anchoredDraggable scrollable hoverable etc. detectDragGesturesAfterLongPress 12 トークバック エフェクト 位置取得 位置取得 トークバック エフェクト 位置取得
  7. 標準APIではできないこと • 独⾃のジェスチャーの定義 • タップとドラッグを組み合わせたジェスチャー • 複数の指の位置を利⽤したジェスチャー • ジェスチャー競合の調整 •

    重なったコンポーザブルがそれぞれジェスチャーを実装している場合 の細かい制御 ⾃分でジェスチャーを実装する必要がある 13
  8. タッチ状態の変化は PointerInputChangeで表される 20 タッチ位置 イベント発⽣時刻 position previousPosition class PointerInputChange {

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

    { val position: Offset val previousPosition: Offset val uptimeMillis: Long val previousUptimeMillis: Long val pressed: Boolean val previousPressed: Boolean } ※コードはイメージです
  10. タッチ状態の変化は PointerInputChangeで表される 22 class PointerInputChange { fun changedToDown(): Boolean fun

    changedToUp(): Boolean fun positionChanged(): Boolean fun positionChange(): Offset } 指が画⾯に触れたかどうか 指が画⾯から離れたかどうか 指の位置が変化したかどうか 指の位置の変化量 ※コードはイメージです
  11. 画⾯に触れている指のリストは PointerEventで表される 24 class PointerEvent { val changes: List<PointerInputChange> }

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

    type: PointerEventType } 画⾯に触れている指の数だけ PointerInputChangeを保持 イベントの概要 (Press / Move / Releaseなど) ※コードはイメージです
  13. class PointerEvent { fun calculateCentroid(): Offset fun calculateCentroidSize(): Float fun

    calculatePan(): Offset fun calculateRotation(): Float fun calculateZoom(): Float } 重⼼ 広がり 移動量 回転量 ズーム PointerInputChange のリストから算出 している 画⾯に触れている指のリストは PointerEventで表される 26 ※コードはイメージです
  14. awaitPointerEventを繰り返し呼び出して、 PointerEventを連続的に取得する 29 do { val pointerEvent = awaitPointerEvent() }

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

    pointerEventの内容を調べる処理 } while (pointerEvent.changes.any { it.pressed }) イベントを⼀つ取得 指が触れている間はループ
  16. ジェスチャー実装例 34 Modifier.pointerInput(Unit) { awaitEachGesture { val event = awaitPointerEvent()

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

    = awaitPointerEvent() } while (event.changes.any { it.pressed }) } } pointerInputのラムダに記述 1つのジェスチャーを記述 1つのイベントを取得 指が離れるまでループ
  18. ジェスチャー実装例 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つのイベントを取得 移動量を算出 指が離れるまでループ
  19. ジェスチャー実装例 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つのイベントを取得 移動量を算出 指が離れるまでループ 条件を満たしたら処理を実⾏
  20. ジェスチャー実装例 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") } } }
  21. イベントの伝達順序 41 Box(modifier = Modifier .pointerInput(Unit) { awaitEachGesture { do

    { val event = awaitPointerEvent() logger.log("Box ${event.type}") } while (event.changes.any { it.pressed }) } } ) { } Box(親)のジェスチャー
  22. イベントの伝達順序 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(⼦)のジェスチャー
  23. イベントの伝達順序 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(⼦)のジェスチャー
  24. イベントの消費 45 Box(modifier = Modifier ... val event = awaitPointerEvent()

    logger.log("Box ${event.type}") ... ) { Image(modifier = Modifier ... val event = awaitPointerEvent() logger.log("Image ${event.type}") ... ) }
  25. イベントの消費 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() } ... ) } イベントを消費
  26. イベントの消費 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() } ... ) } イベントを消費 消費済みのイベントを無視
  27. イベントの消費 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() } ... ) } イベントを消費 消費済みのイベントを無視
  28. 失敗例 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を参照し続ける
  29. 修正例 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を参照できる
  30. 失敗例 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イベントが 発⽣したらドラッグ
  31. 修正例 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より ⻑ければドラッグ
  32. 失敗例 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(慣性動作)
  33. 修正例 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本の場合のみ 速度を算出
  34. 基本的な実装イメージ 71 class ZoomState { var scale fun canConsumeGesture(zoom: Float):

    Boolean { return scale != 1f || zoom != 1f } } ズームされている場合 または ズームしようとしている場合 はイベントを消費する
  35. 基本的な実装イメージ 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 }) } } イベントを利⽤する場合のみ ズーム処理を呼び出し、 イベントを消費する
  36. 基本的な実装イメージ 73 HorizontalPager() { val zoomState = remember { ZoomState()

    } Image(modifier = Modifier .scale(zoomState.scale) .pointerInput(Unit) { ... } ) }
  37. 原因はタッチスロップ判定 76 Zoomable Pager Press Move Swipe 1 2 3

    4 4 Press Move (TouchSlop判定中) Move (TouchSlop判定済) Press Move Zoom consume
  38. 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判定中)
  39. 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 まだちょっと問題が!
  40. PointerEventPassを利⽤した修正案の問題 83 Zoomable Clickable consume Press / Release Click Press

    / Release Finalパスで親のイベント消費を 検知しタップやダブルタップを キャンセルしてしまう https://github.com/usuiat/Zoomable/issues/238
  41. PointerEventPassを利⽤した修正案(改) 84 Zoomable Move 1 2 3 Press consume Move

    (TouchSlop判定中) consume Move (TouchSlop判定済) https://github.com/usuiat/Zoomable/pull/240
  42. まとめ • 2種類のジェスチャーAPIの使い⽅の⽐較 • Modifier.〜ableはUI作成に便利。 • detect〜Gesturesはジェスチャー検出に特化 • ⾃分でジェスチャーを実装する⽅法 •

    PointerEventを取得し、タッチ状態の変化を調べて、条件を満たして いたら処理を実⾏ • 実装時に気をつけたいポイント • 3つの失敗例と修正例を紹介 • 実例から学ぶジェスチャー競合の回避策 • イベントの消費を細かく制御してズームジェスチャーとページ送りを 両⽴ 87