Gunosy Tech Blog

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

Android Jetpack Compose で途中で止まる Swipeable レイアウトを作ってみる

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

今回は下のようなスワイプが途中で止まるレイアウトを Compose で作っていきたいと思います。


2024年10月3日追記
本記事で使用している Modifier.swipeable の API は deprecated になりました。本記事の内容を新しい Modifier.anchoredDraggable に置き換える記事を公開していますので、こちらも併せてご参照ください。

tech.gunosy.io

背景

リスト表示されている項目をスワイプして削除ができる UI を新規に作成することになりました。またスワイプの途中で背後にある「削除」ボタンが表示されるよう、スワイプ途中で止める要件がありました。これは iOS ではデフォルトで用意されている API になります

実現したい UI の仕様

仕様としては下記になります。

  • 左方向に大きくスワイプした時、その項目をリストから削除する
  • スワイプは途中で止めることができ、背後から「削除」ボタンが表示される


すでに用意されているAPI

Jetpack Compose ではスワイプ削除の SwipeToDismiss API が用意されています。

しかし、スワイプの状態として下記が定義されており、「デフォルト状態」か「スワイプ削除されている状態」かのどちらかしかなく、途中で止めることができませんでした。

enum class DismissValue {
    /**
     * Indicates the component has not been dismissed yet.
     */
    Default,

    /**
     * Indicates the component has been dismissed in the reading direction.
     */
    DismissedToEnd,

    /**
     * Indicates the component has been dismissed in the reverse of the reading direction.
     */
    DismissedToStart
}

今回はこちらの API を利用せず、swipeable を用いて自作をしていきたいと思います。


実装

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

private enum class OpenedSwipeableState {
    INITIAL,
    OPENED,
    OVER_SWIPED
}

@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun SwipeableRow(
        onSwipe: () -> Unit,
        content: @Composable () -> Unit
) {
    val swipeableState = rememberSwipeableState(
            initialValue = OpenedSwipeableState.INITIAL,
            confirmStateChange = {
                when (it) {
                    OpenedSwipeableState.INITIAL     -> {
                        // Initial Event
                    }
                    OpenedSwipeableState.OPENED      -> {
                        // Opened Event
                    }
                    OpenedSwipeableState.OVER_SWIPED -> {
                        // Over Swipe Event
                        // todo delete
                        onSwipe.invoke()
                    }
                }
                true
            }
    )

    BoxWithConstraints {
        val constraintsScope = this
        val maxWidthPx = with(LocalDensity.current) {
            constraintsScope.maxWidth.toPx()
        }
        val deleteButtonWidth = 64.dp
        val deleteButtonWidthPx = with(LocalDensity.current) {
            deleteButtonWidth.toPx()
        }

        Box(
                Modifier
                        .background(Color.Black)
                        .swipeable(
                                state = swipeableState,
                                orientation = Orientation.Horizontal,
                                reverseDirection = true,
                                anchors = mapOf(
                                        0f to OpenedSwipeableState.INITIAL,
                                        deleteButtonWidthPx to OpenedSwipeableState.OPENED,
                                        maxWidthPx to OpenedSwipeableState.OVER_SWIPED
                                )
                        )
        ) {
            DeleteButtonLayout() // 後から出てくる削除ボタン
            Box(
                    modifier = Modifier
                            .fillMaxWidth()
                            .wrapContentHeight()
                            .offset { IntOffset(-swipeableState.offset.value.roundToInt(), 0) }
            ) {
                content() //表側に表示するレイアウト
            }
        }
    }
}

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

SwipeableState

まずは状態を定義します。

  • 初期状態(INITIAL)
  • スワイプが途中で止まり、開いている状態(OPENED)
  • スワイプされた状態(OVER_SWIPED)

の 3 種類を用意しました。

private enum class OpenedSwipeableState {
    INITIAL,
    OPENED,
    OVER_SWIPED
}

次に Composable で SwipeableState の作成をします。confirmStateChange は State の変更が通知されます。OVER_SWIPED の時にスワイプされたものをリストから削除をするため、onSwipe で通知し、通知先で削除しています。

    val swipeableState = rememberSwipeableState(
            initialValue = OpenedSwipeableState.INITIAL,
            confirmStateChange = {
                when (it) {
                    OpenedSwipeableState.INITIAL     -> {
                        // Initial Event
                    }
                    OpenedSwipeableState.OPENED      -> {
                        // Opened Event
                    }
                    OpenedSwipeableState.OVER_SWIPED -> {
                        // Over Swipe Event
                        // todo delete
                        onSwipe.invoke()
                    }
                }
                true
            }
    )

swipeable

続いてスワイプ部分についてです。

    Box(
            Modifier
                    .background(Color.Black)
                    .swipeable(
                            state = swipeableState,
                            orientation = Orientation.Horizontal,
                            reverseDirection = true,
                            anchors = mapOf(
                                    0f to OpenedSwipeableState.INITIAL,
                                    deleteButtonWidthPx to OpenedSwipeableState.OPENED,
                                    maxWidthPx to OpenedSwipeableState.OVER_SWIPED
                            )
                    )
    ) {

今回は Modifier の swipeable でレイアウトをスワイプできるようにします。パラメータは下記です。

  • state:スワイプ状態(先ほど作成したもの)
  • orientation:スワイプできる方向。今回は横方向のスワイプなので Orientation.Horizontal を指定します。
  • reverseDirection:デフォルトは右方向にスワイプできます。ここを true にすることで左方向にスワイプできるようになります。
  • anchors:状態に対する px 値を Map 形式で指定します。次で解説します。

anchors

anchors に指定するパラメータについてです。スワイプをした時のアンカーポイントを State とセットにして指定します。 INITIAL 状態の時は 0 、OPENED は削除ボタン分を指定したかったので 64dp 、OVER_SWIPED は完全にスワイプされるため、画面の横幅サイズを指定したいと考えました。

この「64dp」や「画面の横幅サイズ」を取得するため、BoxWithConstraints を用いて現在の density からそれぞれの px 値を取得するようにしました。

BoxWithConstraints {
    val constraintsScope = this
    val maxWidthPx = with(LocalDensity.current) {
            constraintsScope.maxWidth.toPx()
    }
    val deleteButtonWidth = 64.dp
    val deleteButtonWidthPx = with(LocalDensity.current) {
            deleteButtonWidth.toPx()
    }

こちらで用意した px 値を anchors に設定しています。

また、anchors に指定する以外に、実際のレイアウトを横に移動させる指定が必要になります。スワイプ対象の offset を指定します。state から offset を取得することができ、これを Int に変換します。offset は右方向が正の値になるため、今回は「マイナス値」で指定をします。

            Box(
                    modifier = Modifier
                            .fillMaxWidth()
                            .wrapContentHeight()
                            .offset { IntOffset(-swipeableState.offset.value.roundToInt(), 0) }
            ) {
                content() //表側に表示するレイアウト
            }

以上により、スワイプ削除と途中でスワイプが止まる Swipeable レイアウトが完成しました。

まとめ

途中で止まる Swipeable レイアウトを作成してみました。Compose はその自由な設計思想により、実装の工夫により様々なものが作成できると実感しました。これからも色んなレイアウトに挑戦していきたいと思います。