👆

Androidでジェスチャー ナビゲーションで困った話

に公開

始め

(ブログを書くのが初めてなので自分なりに面白く興味が湧くように書きたいと思います。)

これはAndroid初心者の自分がJetpack Composeを使って楽しく開発をしているとき起きた話です。

CameraXで写真を撮ってその画像の切り取りの機能(crop)を実装できてすごくテンションが上がっていました。
しかしその喜びもつかの間、画像の切り取り領域を調整しようとすると、ジェスチャーナビゲーション領域(以前の画面に戻るジェスチャー)に触れてしまい、前のページに戻ってしまうことが頻繁に起きました。

恐ろしいことに頑張って検索してみましたが、Jetpack Composeでジェスチャーナビゲーションを防ぐ方法に関する情報があまり見つかりませんでした(単純に自分が見つけられなかったかもしれません)。
そのため、自分がどう解決したのかを記録として残しておきたいと思い、この記事を書くことになりました。

🙋🏻‍♂️問題

下記画像のように、切り取り領域とジェスチャーナビゲーション領域が被ってしまい、切り取り領域を調整するたびに前のページに戻ってしまう問題が発生しました。

最初はWindowInsets.safeGesturesを使って戻るジェスチャーを避けようとしましたが、あまり状況が変わらず操作するときジェスチャー領域に触れて以前のページに戻ってしまうことが起きていました。😱

そこからいろいろ調べてsystemGestureExclusionを使うとジェスチャーナビゲーション領域を除外できることがわかりました。
systemGestureExclusionは、ジェスチャーナビゲーションの領域を除外するためのModifierの拡張関数で、これを使ってるコンポーネントがジェスチャーナビゲーションの影響を受けなくなります。

調べた情報によるとそうなるはずだったのですが・・・

切り取りをするコンポーネントやその親コンポーネントのmodifierにsystemGestureExclusionを適用しても、以前と何も変わらず(あとで説明しますが、少し変化はありました。)ジェスチャーナビゲーション領域をドラッグすると前のページに戻ってしまうことが起きていました。(絶望)

ドキュメントのHandle conflicting app gesturesなどを参考してみたのですが、
Jetpack Composeの内容ではなかったり、何が原因でsystemGestureExclusionを使っても解決できないか見つけられない時間が続いていました。

そこで偶然codeLabでヒントを見つけました。
codeLabの中にはこのように書かれていました。

注: Gesture Exclusion API は、どちらのサイドにも 200 dp の制限があるため、必要な場合にのみ使用してください。ビューまたはアプリ内の特定の部分で「戻る」ジェスチャーを無効にすると、システムおよび他のアプリとの不整合が生じます。

これを見つけたときに「あれ?200dpの制限って・・・そんな厳しいことあるの?」と思いました。

それで改めて考えてみたら「制限があるということは一応systemGestureExclusionが適応はされているっていうことだよね?」と思い、画面のジェスチャーナビゲーション領域内のいろんなところをドラッグしてみました。

すると、なんと!
画面一定部分の上をドラッグするとジェスチャーナビゲーションが発動して、その下をドラッグするとジェスチャーナビゲーションが発動していないことがわかりました。

ここでもうシステム的に画面全体的にジェスチャーナビゲーション防ぐことはできないし、

  1. paddingを入れてジェスチャーが届かないところで切り取りの操作ができるようにするか
  2. ImmersiveModeに逃げるか
  3. BackHandlerをno-opにして操作中に前のページに戻ってしまうことはどうにか防ぐか

で悩みました。

しかし、

  1. ジェスチャーナビゲーション領域に触れにくらいpaddingを入れると画像が小さくなってしまう
  2. ImmersiveModeにして、ジェスチャーナビゲーションの領域をドラッグすると1回目はsystem barが表示され矢印は表示されないがその状態で2回目にドラッグすると前のページに戻ってしまう。(そもそもImmersiveModeはジェスチャーナビゲーションの領域を無視することはできない)
  3. BackHandlerをno-opにしても、ジェスチャーナビゲーションの領域をドラッグすると前のページに戻る矢印が表示される&切り取り操作が無視されるので、ユーザーにとってはあまり良い体験ではない

と思いました。

そこで考えたのが 「制限されている範囲200dpを分けて配置したらいいんじゃない?」 でした。

つまり、下の画像の赤い部分ようにユーザーが画像を切り取りするためのハンドラの部分に合わせてジェスチャーが無視できる範囲(200dpの制限)を分けて配置することです。

なので、早速検証してみました。

🔎 検証

setSystemGestureExclusionRectsでジェスチャーナビゲーションを防ぐRectを設定する

下記のコードは、ジェスチャーナビゲーションが無視できる範囲を分けて配置した例です。

@Composable
fun EdgeExclusionLayer(
  modifier: Modifier = Modifier,
  leftDp: Dp = 48.dp,
  rightDp: Dp = 48.dp,
  // 画像の位置を指定するためのRect
  targetBounds: Rect? = null,
  content: @Composable () -> Unit,
) {
  val view = LocalView.current
  val density = LocalDensity.current

  Box(modifier.fillMaxWidth()) {
    DisposableEffect(targetBounds) {
      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && targetBounds != null) {
        val width = view.width
        val height = view.height
        val leftExclusionPx = with(density) { leftDp.roundToPx() }
        val rightExclusionPx = with(density) { rightDp.roundToPx() }
        // 200dpはジェスチャーナビゲーションが無視できる制限値
        val maxH = with(density) { 200.dp.roundToPx() }

        val top = targetBounds.top.toInt().coerceIn(0, height)
        val bottom = targetBounds.bottom.toInt().coerceIn(0, height)
        val targetHeight = (bottom - top).coerceAtLeast(0)

        // 制限値を3つに分けて配置するために3に分けます。
        // ここではsliceという変数名をつけました。
        // イメージの高さが200dp未満の場合は、イメージの高さを3等分します。
        val slice = minOf(maxH, targetHeight) / 3
        // 切り取りハンドラーがsliceの真ん中に配置されるようにします。
        val topPositionOfImage = top - slice / 2
        val middlePositionOfImage = top + (targetHeight - slice) / 2
        val bottomPositionOfImage = bottom - slice / 2

        // Rectを作成します。左側と右側のsliceを3つずつ作成します。
        val rects = buildList {
          // 左側のスライス3つ(左上、左中、左下)
          add(android.graphics.Rect(0, topPositionOfImage, leftExclusionPx, topPositionOfImage + slice))
          add(android.graphics.Rect(0, middlePositionOfImage, leftExclusionPx, middlePositionOfImage + slice))
          add(android.graphics.Rect(0, bottomPositionOfImage, leftExclusionPx, bottomPositionOfImage + slice))
          // 右側のスライス3つ(右上、右中、右下)
          add(android.graphics.Rect(width - rightExclusionPx, topPositionOfImage, width, topPositionOfImage + slice))
          add(android.graphics.Rect(width - rightExclusionPx, middlePositionOfImage, width, middlePositionOfImage + slice))
          add(android.graphics.Rect(width - rightExclusionPx, bottomPositionOfImage, width, bottomPositionOfImage + slice))
        }
        // ジェスチャーナビゲーションの除外領域を設定
        view.setSystemGestureExclusionRects(rects)
      }

      onDispose {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
          view.setSystemGestureExclusionRects(emptyList())
        }
      }
    }
    content()
  }
}

このコードの説明を少しすると、画面上イメージが実際ある位置をtargetBoundsで渡して、そのイメージの左上、左中、左下、右上、右中、右下の6つの除外領域を指定してsetSystemGestureExclusionRectsで設定しています。
(今、考えてみたらviewではなくcontentのwidth,heightでも良い気が・・・)

次はこのコンポーネントを使っている側のコードを見てみましょう。

// .. 上部省略
// cropStateはイメージ切り取りの状態を管理するためのコントローラーです。イメージのサイズや位置などの情報を持っています。
val cropState = cropController.state.collectAsStateWithLifecycle().value
// cropperBoundsInRoot変数はImageCropperコンポーネントをonGloballyPositionedで測った位置を保持するための変数です。
var cropperBoundsInRoot by remember { mutableStateOf<Rect?>(null) }
// targetBoundsは上のコードで少し話したジェスチャーナビゲーションの除外領域を設定するために必要なイメージの位置を指定するためのRectです。
val targetBounds = remember(cropperBoundsInRoot, cropState.imageRect) {
  val bounds = cropperBoundsInRoot
  val img = cropState.imageRect
  if (bounds != null) {
    // imgはコンポーネントの中でどこにイメージが位置しているかを表示するためにboundsを基準にしています。
    Rect(
      left = bounds.left + img.left,
      top = bounds.top + img.top,
      right = bounds.left + img.right,
      bottom = bounds.top + img.bottom
    )
  } else null
}

EdgeExclusionLayer(
  modifier = Modifier
    .weight(1f),
  // イメージの位置を指定するためのRect
  targetBounds = targetBounds
) {
  ImageCropper(
    modifier = Modifier
      .fillMaxWidth()
      .weight(1f)
      // onGloballyPositionedでImageCropperの位置を測定してcropperBoundsInRootに保存します。
      .onGloballyPositioned { coords ->
        cropperBoundsInRoot = coords.boundsInRoot()
      },
    cropController = cropController
  )
}
// .. 下部省略

このコードではImageCropperの位置をonGloballyPositionedで取得して、さらにその情報(cropperBoundsInRoot)を使ってイメージの位置を計算(targetBounds)してEdgeExclusionLayerに渡しています。

これを実行した画面は下記のようになります。
どこにジェスチャーナビゲーションを防ぐRect(上記コードのview.setSystemGestureExclusionRects(rects)rects参照)の位置がわかりやすいように画面に赤いボックスで表示しています。

赤いボックスの中はジェスチャーナビゲーションが無視されていることがわかります。

systemGestureExclusionRectsでジェスチャーナビゲーションを防ぐRectを設定する

上のコードではsetSystemGestureExclusionRectsを使ってジェスチャーナビゲーションを防ぐRectを設定しました。
今からはせっかくなので、Jetpack ComposeのModifier.systemGestureExclusionを使って同じことを試してみます。

ジェスチャーナビゲーションを防ぐRectを作るロジック(sliceRectLocal)は長いので、private関数に切り出して、EdgeExclusionLayerの中で使うようにしました。

下記のコードはEdgeExclusionLayerの実装です。
setSystemGestureExclusionRectsを使うコードからModifier.systemGestureExclusionを使うコードにどう変わったのかに注目してください。)

// Sliceの位置をよりわかりやすくするために、SideとSlotをenum classで定義
enum class Side { Left, Right }
enum class Slot { Top, Middle, Bottom }

@Composable
fun EdgeExclusionLayer(
  modifier: Modifier = Modifier,
  targetBounds: Rect? = null,
  content: @Composable () -> Unit,
) {
  if (targetBounds == null) {
    content()
    return
  }
  val density = LocalDensity.current

  // systemGestureExclusionの中をより簡潔にするための関数
  val makeRect: (LayoutCoordinates, Side, Slot) -> Rect = { coords, side, slot ->
    sliceRectLocal(
      coords = coords,
      targetBounds = targetBounds,
      density = density,
      side = side,
      slot = slot
    )
  }

  val exclusionModifier =
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
      Modifier
        // Rectを何個も作るためにsystemGestureExclusionをチェイニングする
        // 左側 3 スライス
        .systemGestureExclusion { coords -> makeRect(coords, Side.Left, Slot.Top) }
        .systemGestureExclusion { coords -> makeRect(coords, Side.Left, Slot.Middle) }
        .systemGestureExclusion { coords -> makeRect(coords, Side.Left, Slot.Bottom) }
        // 右側 3 スライス
        .systemGestureExclusion { coords -> makeRect(coords, Side.Right, Slot.Top) }
        .systemGestureExclusion { coords -> makeRect(coords, Side.Right, Slot.Middle) }
        .systemGestureExclusion { coords -> makeRect(coords, Side.Right, Slot.Bottom) }
    else Modifier

  Box(
    modifier
      .fillMaxWidth()
      .then(exclusionModifier)
  ) {
    content()
  }
}

上のmakeRect関数で使ったsliceRectLocal関数の中身は下記のようになります。

// ジェスチャーナビゲーションを防ぐRect(この記事ではsliceと変数名をつけています。)
// を作成する関数
private fun sliceRectLocal(
  coords: LayoutCoordinates,
  targetBounds: Rect,
  density: Density,
  leftDp: Dp = 48.dp,
  rightDp: Dp = 48.dp,
  side: Side, // "left" or "right"
  slot: Slot, // "top", "middle", or "bottom"
): Rect {

  // LayoutCoordinatesからローカル座標系の幅と高さを取得
  val widthLocal = coords.size.width.toFloat()
  val heightLocal = coords.size.height.toFloat()

  // ローカル座標系の上端を取得
  val nodeTopInRoot = coords.boundsInRoot().top

  // targetBoundsの位置をローカル座標系に変換
  val topLocal = (targetBounds.top - nodeTopInRoot).coerceIn(0f, heightLocal)
  val bottomLocal = (targetBounds.bottom - nodeTopInRoot).coerceIn(0f, heightLocal)
  val targetH = (bottomLocal - topLocal).coerceAtLeast(0f)

  // 左と右のジェスチャーナビゲーションを防ぐ領域の幅をdpからpxに変換
  val leftPx = with(density) { leftDp.toPx() }
  val rightPx = with(density) { rightDp.toPx() }
  // ジェスチャーナビゲーションの制限値を200dpからpxに変換
  val maxHPx = with(density) { 200.dp.toPx() }

  val sliceH = (minOf(maxHPx, targetH)) / 3f
  if (sliceH <= 0f) return Rect.Zero
  
  // ジェスチャーナビゲーションを防ぐRectのY座標を計算
  val yTop = when (slot) {
    Slot.Top -> topLocal - sliceH / 2f
    Slot.Middle -> topLocal + (targetH - sliceH) / 2f
    else -> bottomLocal - sliceH / 2f
  }.coerceIn(0f, (heightLocal - sliceH).coerceAtLeast(0f))

  return if (side == Side.Left) {
    Rect(
      left = 0f,
      top = yTop,
      right = leftPx.coerceAtMost(widthLocal),
      bottom = (yTop + sliceH).coerceAtMost(heightLocal)
    )
  } else {
    Rect(
      left = (widthLocal - rightPx).coerceAtLeast(0f),
      top = yTop,
      right = widthLocal,
      bottom = (yTop + sliceH).coerceAtMost(heightLocal)
    )
  }
}

アプリをrunすると同じ機能が実装されていることがわかります。

まとめ

個人的には、Jetpack ComposeではsetSystemGestureExclusionRectsを使うよりも、Modifier.systemGestureExclusionを使う方が良いと思います。
なぜかというと、

  1. Modifier.systemGestureExclusionを追加するだけなので、使いやすいです。
  2. Modifier.systemGestureExclusionがBoxのサイズや位置を返してくれるのでsetSystemGestureExclusionRectsより便利だと思います。
  3. DisposableEffectを使わなくもいいので、気にすることがひとつ減ります。

上記の理由から、これからJetpack Composeでジェスチャーナビゲーションを防ぐ必要がまたあったらModifier.systemGestureExclusionを使おうと思います。

ちなみに今回の実験を通してsystemGestureExclusionをチェイニングして使うことでジェスチャーナビゲーションを防ぐRectを何個も指定できることがわかりました。

終わり

まだAndroid初心者でAndroidでジェスチャーナビゲーションを防ぐことに高さ200dpの制限があることも初めて知りました。
setSystemGestureExclusionRectsModifier.systemGestureExclusionの使い方がある程度わかった今はそこまで難しくはなかったんだと思いますが、
最初検索してもあまりジェスチャーナビゲーションを防ぐための情報が出てこなかったので、とても大変でした。
(もしかしたらまだ自分が知らないだけでもっと良い方法があってみんなその方法を使ってこちらの方法は情報があまりないかもしれません。ご存じの方はぜひ教えてください。🙇‍♂️)

まだAndroid開発に知らないことがたくさんあるので、間違ったことが書かれているかもしれませんが、もし間違いがあればぜひ教えてください。

最後に同じようなことで困っている方に、この記事が少しでも参考になれば幸いです。

GitHubで編集を提案

Discussion