How to prevent clicks from passing through a custom Compose bottom sheet scrim?


Description

I'd like my CustomSheet to cover the screen properly and block all interactions with the screen underneath. I thought this was the default behaviour for any Composable that is on top. However, in this case, I can still click the button on the HomeScreen even though the entire screen is covered. Do u have any ideas?

Code details

My CustomSheet wrapper:

val visibleState = remember { MutableTransitionState(false) }.apply { targetState = visible } if (visibleState.currentState || visibleState.targetState || !visibleState.isIdle) { Box(modifier = Modifier.fillMaxSize()) { Box( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.scrim) ) AnimatedVisibility( modifier = Modifier.align(Alignment.BottomCenter), visibleState = visibleState, enter = slideInVertically( initialOffsetY = { it }, animationSpec = tween(durationMillis = 500, delayMillis = 250) ), exit = slideOutVertically( targetOffsetY = { it }, animationSpec = tween(500) ) ) { Box( modifier = modifier .clip(shape) .background(color = backgroundColor) ){ content() } } } }

CustomSheet:

@Composable fun CustomSheet( viewModel: ChangelogViewModel = hiltViewModel(), navController: NavController ) { val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination // display only in homeScreen val isHomeScreen = currentDestination?.hierarchy?.any { it.route in listOf( NavigationBarEnum.HOME.route.name(), ) } == true val uiState by viewModel.uiState.collectAsStateWithLifecycle() var delayedVisible by remember { mutableStateOf(false) } LaunchedEffect(uiState.changelog, uiState.visible, isHomeScreen) { val showChangelog = uiState.changelog != null && uiState.visible && isHomeScreen if (showChangelog) { delay(ANIMATION_LONG_DELAY_DURATION.toLong()) delayedVisible = true } else { delayedVisible = false } } uiState.changelog?.let { changelogItem -> CustomSheetWrapper( visible = delayedVisible, modifier = Modifier .fillMaxWidth() .fillMaxHeight(0.85f), ) { ChangelogContent( currentVersion = changelogItem.version, changelog = changelogItem.changelog, onSubmit = { viewModel.onChangelogDismissed() }) } } }

I connect it directly in MainActivity:

NavHost(navController = navController) CustomSheet(navController = navController)

However, underneath is the HomeScreen with a button at the top (the button is completely obscured by the CustomSheet):

@Composable fun ClickableCard(data: Data?, onClick: () -> Unit) { val painter = if (diocese != null) painterResource(diocese.image) else painterResource(R.drawable.example) Card( shape = RoundedCornerShape(Dimens.roundedCornersLarge), elevation = CardDefaults.elevatedCardElevation(Dimens.roundedCornersLarge), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), modifier = Modifier .fillMaxWidth() .aspectRatio(16f / 9f) ) {} }
0
May 20 at 11:33 AM
User AvatarStanisław Olszak
#android#kotlin#android-jetpack-compose

Accepted Answer

In Jetpack Compose, a Box with only a background() modifier does not consume pointer events. background paints pixels but stays "transparent" to the input system, so taps fall through to whatever is drawn beneath the Box. Stacking inside a parent is decided by declaration order. Later siblings draw on top, but draw order does not automatically intercept touches.

Add either

.clickable(
  interactionSource = remember { MutableInteractionSource() },
  indication = null,
  onClick = { /* dismiss */ },
)

or

.pointerInput(Unit) {
  awaitPointerEventScope {
      while (true) { awaitPointerEvent() }
  }
}

to the scrim Box, and ideally to the sheets content Box too, so touches are absorbed instead of falling through to the screen below.

Full example:

Box(
  modifier = Modifier
      .fillMaxSize()
      .background(MaterialTheme.colorScheme.scrim)
      .clickable(
          interactionSource = remember { MutableInteractionSource() },
          indication = null,
          onClick = onScrimClick,   // or {} to just absorb
      ),
)

You can read more on it in docs: https://developer.android.com/develop/ui/compose/touch-input/pointer-input/understand-gestures?utm_source=chatgpt.com#event-dispatching

> Pointer events are dispatched to a composable hierarchy. The moment that a new pointer triggers its first pointer event, the system starts hit-testing the "eligible" composables. A composable is considered eligible when it has pointer input handling capabilities.

User Avatarmarcinj
May 20 at 1:15 PM
2