0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Jetpack Compose】内部をスクロールできるBottomSheetを自作する

Last updated at Posted at 2025-12-14

ModalBottomSheetに操作を持っていかれる

 Material3のModalBottomSheetは内部で、drag, fling, scrollなどの方向を持つ操作をボトムシートのサイズ変更の指示として受ける仕組みがあります。(同様の処理はMaterial3のBottomSheetScaffold、Material2のModalBottomSheetLayoutにもあります。)

androidx/compose/material3/SheetDefaults.kt
@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が厄介で、これがonPreScrollonPreFling の時点でスクロール処理を実行するので、子要素でいかにnestedScrollを指定しても動作しません。

(スクロール中にボトムシートのサイズが変わる↓)

 しかも内部でComponentDialog を作成しているためか、親の方からonPreScrollonPreFling を設定しようとしても、Connectionに値を返してきません(参考)。

 今年の9月にリリースされたandroidx.compose.material3:material3-*:1.4.0 にはsheetGesturesEnabled追加されている ため、そちらを使用すればボトムシートにスクロールを奪われることはなくなります。

androidx/compose/material3/ModalBottomSheet.kt
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を流用したり、positionalThresholdBottomSheetDefaults.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を切るのが良いかと思います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?