Jetpack Compose でモーダル風画面をつくる
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さんの記事を参考にさせていただきました
.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