Android Compose Swipeable から AnchoredDraggable に移行する - Gunosy Tech Blog

Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

Android Compose Swipeable から AnchoredDraggable に移行する

こんにちは。Android アプリ開発担当の nagayama(@nagayan_dev)です。

今回は Swipeable API が deprecated になったため、代替となる AnchoredDraggable API に移行していきたいと思います。

背景

以前、下記の「Android Jetpack Compose で途中で止まる Swipeable レイアウトを作ってみる」で Modifier.swipeable を用いた Compose UI 実装をご紹介しました。 tech.gunosy.io

しかし Modifier.swipeableandroidx.compose.foundation:foundation-* ライブラリの 1.6.0 から deprecated となり、代わりに Modifier.anchoredDraggable を使用するようにと公式からアナウンスがされています。 developer.android.com

今回は「Android Jetpack Compose で途中で止まる Swipeable レイアウトを作ってみる」で作成したコードを Modifier.anchoredDraggable に移行する対応をしたいと思います。


実装

完成したコードは下記のようになります。

private enum class OpenedSwipeableState {
    INITIAL,
    OPENED,
    OVER_SWIPED
}

@SuppressLint("RememberReturnType")
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SwipeableRow(
  onSwipe: () -> Unit,
  content: @Composable () -> Unit
) {
  BoxWithConstraints {
    val constraintsScope = this
    // 画面の横幅
    val maxWidthPx = with(LocalDensity.current) {
      constraintsScope.maxWidth.toPx()
    }
    // 削除ボタンの横幅
    val deleteButtonWidth = 64.dp
    val deleteButtonWidthPx = with(LocalDensity.current) {
      deleteButtonWidth.toPx()
    }
    val anchors = DraggableAnchors {
      OpenedSwipeableState.INITIAL at 0f
      OpenedSwipeableState.OPENED at deleteButtonWidthPx
      OpenedSwipeableState.OVER_SWIPED at maxWidthPx
    }
    val decayAnimationSpec = rememberSplineBasedDecay<Float>()
    val anchorDraggableState = remember {
      AnchoredDraggableState(
        initialValue = OpenedSwipeableState.INITIAL,
        confirmValueChange = {
          when (it) {
            OpenedSwipeableState.INITIAL   -> {
              // do nothing
            }
            OpenedSwipeableState.OPENED      -> {
              // Opened Event
            }
            OpenedSwipeableState.OVER_SWIPED -> {
              // Over Swipe Event
              // todo delete
              onSwipe.invoke()
            }
          }
          true
        },
        anchors = anchors,
        positionalThreshold = { distance: Float -> distance * 0.5f },
        velocityThreshold = { 5000f }, // 横スワイプですぐに消えてしまうため、大きい数値を設定
        snapAnimationSpec = SpringSpec(
          dampingRatio = Spring.DampingRatioMediumBouncy,
          stiffness = Spring.StiffnessMediumLow
        ),
        decayAnimationSpec = decayAnimationSpec,
      )
    }
  
    Box(
      Modifier.anchoredDraggable(
        state = anchorDraggableState,
        reverseDirection = true,
        orientation = Orientation.Horizontal,
      )
    ) {
      DeleteButtonLayout() // 後から出てくる削除ボタン
      Box(
        modifier = Modifier
                .fillMaxWidth()
                .wrapContentHeight()
                .offset { IntOffset(-anchorDraggableState.offset.roundToInt(), 0) }
      ) {
        content()
      }
    }
  }
}

こちらを元に解説をしていきます。

DraggableAnchors

val anchors = DraggableAnchors {
  OpenedSwipeableState.INITIAL at 0f
  OpenedSwipeableState.OPENED at deleteButtonWidthPx
  OpenedSwipeableState.OVER_SWIPED at maxWidthPx
}

DraggableAnchors は「状態」と「停止する位置(px)」をセットにした Map を定義します。状態の定義は前回使用した OpenedSwipeableState をそのまま使用します。停止位置も同じ値にするため、INITIAL 状態は 0 、OPENED は削除ボタン分サイズ 、OVER_SWIPED は画面の横幅サイズを指定します。

AnchoredDraggableState

val decayAnimationSpec = rememberSplineBasedDecay<Float>()
val anchorDraggableState = remember {
  AnchoredDraggableState(
    initialValue = OpenedSwipeableState.INITIAL,
    confirmValueChange = {
      when (it) {
        OpenedSwipeableState.INITIAL   -> {
          // do nothing
        }
        OpenedSwipeableState.OPENED      -> {
          // Opened Event
        }
        OpenedSwipeableState.OVER_SWIPED -> {
          // Over Swipe Event
          // todo delete
          onSwipe.invoke()
        }
      }
      true
    },
    anchors = anchors,
    positionalThreshold = { distance: Float -> distance * 0.5f },
    velocityThreshold = { 5000f }, // 横スワイプですぐに消えてしまうため、大きい数値を設定
    snapAnimationSpec = SpringSpec(
      dampingRatio = Spring.DampingRatioMediumBouncy,
      stiffness = Spring.StiffnessMediumLow
    ),
    decayAnimationSpec = decayAnimationSpec,
  )
}

AnchoredDraggableState は新しい API である Modifier.anchoredDraggable に指定する State です。引数は下記のようになっています。

  • initialValueDraggableAnchors で定義した状態の初期状態を設定します。

  • confirmValueChange:状態の変化した時に処理を実行する場合、ここに実装します。OVER_SWIPED のイベント時に削除を実行するため、onSwipe の通知を出しています。

  • anchors:先ほど作成した DraggableAnchors を設定します。

  • positionalThreshold:状態の変化位置のしきい値です。特にこだわりは無いため 50% の位置に設定しています。

  • velocityThreshold:スワイプ速度のしきい値です。positionalThreshold に位置に達していなくてもこの速度以上のスワイプがされると終了するもので、横スワイプの使いやすさに直結します。元の Modifier.swipeable にも velocityThreshold があり、デフォルト値は 125.dp となっていました。今回も同じ値を指定してみましたが横スワイプするとすぐに消えてしまう印象があったため、 何度か試してみた結果 5000f が使いやすかったのでこの値にしました。

  • snapAnimationSpec:跳ねるアニメーションの設定値である SpringSpec を設定します。後述を参照してください。デフォルトの値よりも跳ねるように設定しています。

  • decayAnimationSpec:別の状態に到達または通過する速度でスワイプした時のアニメーション設定です。こちらも特にこだわりはないため rememberSplineBasedDecay<Float>() をそのまま設定しています。


以前の SwipeableState / Modifier.swipeable と違い、細かいスワイプ動作やアニメーション設定ができるようになっています。

SpringSpec

SpringSpec は跳ねるアニメーションの設定値です。

@Immutable
class SpringSpec<T>(
    val dampingRatio: Float = Spring.DampingRatioNoBouncy,
    val stiffness: Float = Spring.StiffnessMedium,
    val visibilityThreshold: T? = null
) 

「バネ」のような動き方を再現するため、主に以下 2 つを設定します。

  • dampingRatio:バネの振動がどれくらい早く収束するかの値(減衰比)です。値が大きいほど速く振動が停止し、小さいほど長い時間振動します。値は 0.0 〜 1.0 の間で指定します。デフォルトは 1.0 となっているため、まったく振動しない設定になっています。

  • stiffness:バネの固さ(剛性)です。値が大きいほど「固いバネ」で速いアニメーションとなり、値が小さいほど「柔らかいバネ」でゆっくりとアニメーションをします。デフォルトは 1500f です。

Modifier.anchoredDraggable

Box(
  Modifier.anchoredDraggable(
    state = anchorDraggableState,
    reverseDirection = true,
    orientation = Orientation.Horizontal,
  )
) {
  DeleteButtonLayout() // 後から出てくる削除ボタン

}

Modifier.anchoredDraggable は deprecated になった Modifier.swipeable に代わる API です。設定する引数は基本的に swipeable のものとは変わりありません。

  • state:スワイプの状態です。先ほど作成した AnchoredDraggableState を設定します。

  • reverseDirection:デフォルトは右方向にスワイプでき、ここを true にすると左方向にスワイプできます。

  • orientation:スワイプできる方向。今回は横方向のスワイプなので Orientation.Horizontal を指定します。

offset

Box(
  modifier = Modifier
    .fillMaxWidth()
    .wrapContentHeight()
    .offset { IntOffset(-anchorDraggableState.offset.roundToInt(), 0) }
) {
  content()
}

最後にスワイプ対象の offset 値を設定します。元の Modifier.swipeable の時と同様に、state から offset 値を取得して Int 型に変換します。offset は右方向が正の値になるため、「マイナス値」で指定をします。

以上、Modifier.anchoredDraggable に移行する対応が完了しました。


まとめ

Modifier.swipeable から Modifier.anchoredDraggable に移行する対応をしました。設定できるパラメータが増えたためちょっと手間ではあるものの、表現の幅が広がって使いやすくなったのではないかと感じました。ただ、Modifier.anchoredDraggable 等が ExperimentalFoundationApi となっているため、今後変更が入る可能性があります。これからも Jetpack Compose ライフを楽しみましょう。


最後に

Gunosy では Android エンジニアを募集しております。ご興味をお持ちの方はぜひご連絡ください。

gunosy.co.jp