📱

Jetpack Compose でモーダル風画面をつくる

2024/02/26に公開

Jetpack Composeでモーダルを作るときにはModalBottomsheetなどを利用すると思います。Composeのモーダルはroot画面下部から表示される挙動をします(間違っていたらごめんなさい)。
デザイン次第ではBottomTabの上辺から表示されるような挙動をしたいケースもあると思います。今回はそんなケースに対応するモーダル風なComposable関数を作った共有になります。

AnchoredDraggable

Compose-Foundation 1.6.0 までは Swipeable Modifilerを利用してComposable関数をすワイプ可能なものにしていました。1.6.0 以降は AnchoredDraggable に置き換わり、Swipeable の利用は非推奨となっています。今回は AnchoredDraggable を利用して Composable関数をスワイプ可能なものとし、モーダル風な画面を作ってみました。

fun <T> Modifier.anchoredDraggable(
    state: AnchoredDraggableState<T>,
    orientation: Orientation,
    enabled: Boolean = true,
    reverseDirection: Boolean = false,
    interactionSource: MutableInteractionSource? = null
)

2つのパラメータだけ準備できれば AnchoredDraggable を利用することができます。

orientation

その名の通りなので利用用途に合わせて、縦・横どちらかを渡してあげれば良いです。今回の場合だと Orienn.Vertical を渡しています。

state(AnchoredDraggableState)

class AnchoredDraggableState<T>(
    initialValue: T,
    internal val positionalThreshold: (totalDistance: Float) -> Float,
    internal val velocityThreshold: () -> Float,
    val animationSpec: AnimationSpec<Float>,
    internal val confirmValueChange: (newValue: T) -> Boolean = { true }
)

Anchored と名前がついている通り、開く・閉じるなどの2値だけではなく 開く・中間・閉じる といった2値以上の任意状態をアンカーポイントとして設定することができます。AnchoredDraggableState はそれぞれの状態に遷移するための条件・初期状態を設定します。
今回はシンプルな開く・閉じるのパターンで考えていきます。

  • initialValue
    • 初期状態です、今回は true を開いている状態として扱います。
  • positionalThreshold
    • 位置しきい値。モーダル内をどれだけドラッグさせたら次の状態にするか
    • 今回はモーダル高さの半分をドラッグしたらとしています。
  • velocityThreshol
    • 速度しきい値。モーダル内を1秒間にどれだけドラッグしたら次の状態にするか
    • 指をシュッっとドラッグしたら閉じるアレです。
    • 今回は適当に500pxとしています
val draggableState = remember {
    AnchoredDraggableState(
        initialValue = true,
        positionalThreshold = { it * 0.5f },
        velocityThreshold = { with(density) { 500.dp.toPx() } },
        animationSpec = tween(),
    )
}

ここまでの成果物を組み込んでみます。

BoxWithConstraints(
    modifier = modifier
        .anchoredDraggable(
            state = draggableState,
            orientation = Orientation.Vertical
        )
    ) {
    content()
}

ただこのままだとモーダル内をドラッグしてもモーダルの高さは変わらず、モーダルを閉じ開きすることはできません。そこで、stateからドラッグ量を取得してOffsetに反映させてみましょう。

BoxWithConstraints(
    modifier = modifier
        .offset {
            IntOffset(
                x = 0,
                y = draggableState // 👈 Y軸は画面下に向かって+なのでドラッグ量をそのまま指定するとモーダルが閉じ開きするようになる
                    .requireOffset()
                    .roundToInt()
            )
        }
        .anchoredDraggable(
            state = draggableState,
            orientation = Orientation.Vertical
        )
) {
    content()
}

さいごにアンカーポイントを指定する必要があります。
Y軸は画面下部に向かってプラスなのを間違えないように。

BoxWithConstraints(
    modifier = modifier
        .offset {
            IntOffset(
                x = 0,
                y = draggableState
                    .requireOffset()
                    .roundToInt()
            )
        }
        .anchoredDraggable(
            state = draggableState,
            orientation = Orientation.Vertical
        )
) {
    SideEffect {
        draggableState.updateAnchors( // 👈
            DraggableAnchors {
                false at with(density) { maxHeight.toPx() }
                true at 0f  // 0f is y-axis top position
            }
        )
    }
    content()
}

さいごに

ModalBottomsheet と少し違った挙動、デザインでモーダル風を作る必要が出てきたときの参考になれば幸いです。またモーダル内にスクロールできるリストを表示したい場合は nestedscroll の設定が必要です。
(やや書くのに力つきたので後日追記、修正します :pray:)

usuiatさんの記事を参考にさせていただきました
https://engawapg.net/jetpack-compose/2536/nestedscroll1/

.nestedScroll(object : NestedScrollConnection {
    override fun onPreScroll(
        available: Offset,
        source: NestedScrollSource,
    ): Offset {
        val delta = available.y
        return if (delta < 0) {
            Offset(0f, draggableState.dispatchRawDelta(delta))
        } else {
            Offset.Zero
        }
    }

    override fun onPostScroll(
        consumed: Offset,
        available: Offset,
        source: NestedScrollSource,
    ): Offset {
        val delta = available.y
        return Offset(0f, draggableState.dispatchRawDelta(delta))
    }

    override suspend fun onPreFling(available: Velocity): Velocity {
        return if (available.y < 0 && draggableState.offset.isNaN()) {
            draggableState.settle(available.y)
            available
        } else {
            Velocity.Zero
        }
    }

    override suspend fun onPostFling(
        consumed: Velocity,
        available: Velocity,
    ): Velocity {
        draggableState.settle(available.y)
        return available
    }
})

Discussion