ModalBottomSheetに操作を持っていかれる
Material3のModalBottomSheetは内部で、drag, fling, scrollなどの方向を持つ操作をボトムシートのサイズ変更の指示として受ける仕組みがあります。(同様の処理はMaterial3のBottomSheetScaffold、Material2のModalBottomSheetLayoutにもあります。)
@OptIn(ExperimentalMaterial3Api::class)
internal fun ConsumeSwipeWithinBottomSheetBoundsNestedScrollConnection(
sheetState: SheetState,
orientation: Orientation,
onFling: (velocity: Float) -> Unit
): NestedScrollConnection =
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val delta = available.toFloat()
return if (delta < 0 && source == NestedScrollSource.UserInput) {
sheetState.anchoredDraggableState.dispatchRawDelta(delta).toOffset()
} else {
Offset.Zero
}
}
...
}
ですが、内部にスクロール可能なViewがあると、このViewに対する操作をModalBottomSheetがサイズ変更の指示と解釈して、イベントを横取りすることがあります。
特にConsumeSwipeWithinBottomSheetBoundsNestedScrollConnectionで設定されるnestedScrollが厄介で、これがonPreScroll、onPreFling の時点でスクロール処理を実行するので、子要素でいかにnestedScrollを指定しても動作しません。
しかも内部でComponentDialog を作成しているためか、親の方からonPreScroll、onPreFling を設定しようとしても、Connectionに値を返してきません(参考)。
今年の9月にリリースされたandroidx.compose.material3:material3-*:1.4.0 にはsheetGesturesEnabled が追加されている ため、そちらを使用すればボトムシートにスクロールを奪われることはなくなります。
fun ModalBottomSheet(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(),
sheetMaxWidth: Dp = BottomSheetDefaults.SheetMaxWidth,
sheetGesturesEnabled: Boolean = true, // これをfalseにする
shape: Shape = BottomSheetDefaults.ExpandedShape,
containerColor: Color = BottomSheetDefaults.ContainerColor,
contentColor: Color = contentColorFor(containerColor),
tonalElevation: Dp = 0.dp,
scrimColor: Color = BottomSheetDefaults.ScrimColor,
dragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.windowInsets },
properties: ModalBottomSheetProperties = ModalBottomSheetProperties(),
content: @Composable ColumnScope.() -> Unit,
)
ただ、今度はサイズ変更のドラッグ処理も無効になるため、そちらを手元で実装する必要があります。
加えて、半開き状態のボトムシートはModifier.graphicsLayerでoffsetの値だけ小さく表示しているだけなので、表示領域より大きいViewを配置していると、下げられた分が画面内に表示されなくなります。
一応、表示領域の高さを計算してcontentに設定すれば解決できるのですが、サイズ変更のドラッグ処理も実装するなら、いっそ自前で用意した方が早いのでは?と考え実装しました。
実装
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ScrollableBottomSheet(
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val density = LocalDensity.current
val screenHeightPx = LocalWindowInfo.current.containerSize.height
val anchoredDraggableState = remember(screenHeightPx) {
AnchoredDraggableState(
initialValue = SheetValue.PartiallyExpanded,
anchors = DraggableAnchors {
SheetValue.Hidden at screenHeightPx.toFloat()
SheetValue.PartiallyExpanded at screenHeightPx * 0.5f
SheetValue.Expanded at 0f
},
)
}
LaunchedEffect(anchoredDraggableState) {
snapshotFlow { anchoredDraggableState.settledValue }
.collect { if (it == SheetValue.Hidden) onDismissRequest() }
}
Box(modifier = Modifier.fillMaxSize()) {
Column(
modifier = modifier
.fillMaxWidth()
.background(color = Color.White)
.align(Alignment.BottomCenter)
.anchoredDraggable(
state = anchoredDraggableState,
orientation = Orientation.Vertical,
flingBehavior = AnchoredDraggableDefaults.flingBehavior(
state = anchoredDraggableState,
positionalThreshold = { with(density) { 56.dp.toPx() } },
)
)
.layout { measurable, constraints ->
val sheetHeightPx = (screenHeightPx - anchoredDraggableState.requireOffset())
.roundToInt()
.coerceIn(0, constraints.maxHeight)
val placeable = measurable.measure(
constraints.copy(minHeight = sheetHeightPx, maxHeight = sheetHeightPx)
)
layout(constraints.maxWidth, sheetHeightPx) { placeable.place(0, 0) }
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
BottomSheetDefaults.DragHandle()
// anchoredDraggableはDragHandleのみで処理する
Box(modifier = Modifier.draggable(DraggableState {}, Orientation.Vertical)) {
content()
}
}
}
}
DragHandle()やSheetValueを流用したり、positionalThresholdにBottomSheetDefaults.PositionalThresholdと同じ56.dpを指定したりと、基本的にはModalBottomSheetを基に実装していますが、すべて自作してしまっても良いかと思います。
var isShowBottomSheet by remember { mutableStateOf(false) }
Button(
onClick = { isShowBottomSheet = true },
content = { Text(text = "Scrollable BottomSheet") },
)
if (isShowBottomSheet) {
ScrollableBottomSheet(
onDismissRequest = { isShowBottomSheet = false },
modifier = Modifier.systemBarsPadding(),
) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.background(color = Color.Gray),
) {
items(100) { item -> Text(text = item.toString()) }
}
}
}
無事、内部のスクロールとサイズ変更を分離し、ドラッグハンドルの操作のみでサイズ変更ができるようになりました。
他Viewとの関係でBox(modifier = Modifier.fillMaxSize())で画面いっぱいを使用できない状況もあると思うので、汎用的に使う場合はModalBottomSheet同様ComponentDialogに乗せて別windowを切るのが良いかと思います。

