Skip to content

Commit d0f9033

Browse files
committed
Preserve modal bottom sheet during interrupted dismiss
Keep the sheet measured from its current visible offset when an entering sheet is dismissed before settling, and let the explicit dismiss path own modal teardown until the hide animation completes.
1 parent a3ab3cf commit d0f9033

3 files changed

Lines changed: 77 additions & 8 deletions

File tree

composeunstyled-bottom-sheet/src/commonMain/kotlin/com/composeunstyled/BottomSheet.kt

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -321,12 +321,13 @@ class BottomSheetState(
321321
} else {
322322
val currentHeight = anchoredHeightFor(currentDetent)
323323
val targetHeight = anchoredHeightFor(targetDetent)
324+
val offsetHeight = currentOffsetHeight()
324325

325326
when {
326-
currentHeight.isNaN() && targetHeight.isNaN() -> maxDetentHeightPx
327-
currentHeight.isNaN() -> targetHeight
328-
targetHeight.isNaN() -> currentHeight
329-
else -> max(currentHeight, targetHeight)
327+
currentHeight.isNaN() && targetHeight.isNaN() && offsetHeight.isNaN() -> maxDetentHeightPx
328+
else -> listOf(currentHeight, targetHeight, offsetHeight)
329+
.filterNot { it.isNaN() }
330+
.maxOrNull() ?: maxDetentHeightPx
330331
}
331332
}
332333
}
@@ -336,8 +337,11 @@ class BottomSheetState(
336337

337338
val currentHeight = detentHeightPx(currentDetent, measuredContentHeightPx)
338339
val targetHeight = detentHeightPx(targetDetent, measuredContentHeightPx)
340+
val offsetHeight = currentOffsetHeight().coerceAtMost(measuredContentHeightPx.coerceAtLeast(0f))
339341

340-
return max(currentHeight, targetHeight)
342+
return listOf(currentHeight, targetHeight, offsetHeight)
343+
.filterNot { it.isNaN() }
344+
.maxOrNull() ?: Float.NaN
341345
}
342346

343347
internal fun hasContentDependentDetents(): Boolean {
@@ -358,6 +362,15 @@ class BottomSheetState(
358362
}
359363
}
360364

365+
private fun currentOffsetHeight(): Float {
366+
val offset = anchoredDraggableState.offset
367+
return if (containerHeightPx.isNaN() || offset.isNaN()) {
368+
Float.NaN
369+
} else {
370+
(containerHeightPx - offset).coerceAtLeast(0f)
371+
}
372+
}
373+
361374
private fun detentHeightPx(detent: SheetDetent, sheetHeightPx: Float): Float {
362375
if (containerHeightPx.isNaN()) return Float.NaN
363376

composeunstyled-modal-bottom-sheet/src/commonMain/kotlin/com/composeunstyled/ModalBottomSheet.kt

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ class ModalBottomSheetState internal constructor(
103103
internal var pendingDetentChange: Job? = null
104104
internal var modalDetent by mutableStateOf(bottomSheetState.currentDetent)
105105
internal var dismissAnimationSpec by mutableStateOf(dismissAnimationSpec)
106+
internal var dismissAnimationInProgress by mutableStateOf(false)
106107
private var pendingTargetDetent: SheetDetent? by mutableStateOf(null)
107108

108109
val currentDetent: SheetDetent
@@ -238,10 +239,15 @@ fun UnstyledModalBottomSheet(
238239

239240
fun requestDismiss() {
240241
dispatchDismiss()
242+
state.dismissAnimationInProgress = true
241243
state.pendingDetentChange?.cancel()
242244
state.launchPendingDetentChange {
243-
state.bottomSheetState.animateTo(SheetDetent.Hidden, state.dismissAnimationSpec)
244-
completeDismiss(notifyDismiss = false)
245+
try {
246+
state.bottomSheetState.animateTo(SheetDetent.Hidden, state.dismissAnimationSpec)
247+
completeDismiss(notifyDismiss = false)
248+
} finally {
249+
state.dismissAnimationInProgress = false
250+
}
245251
}
246252
}
247253

@@ -255,8 +261,13 @@ fun UnstyledModalBottomSheet(
255261
LaunchedEffect(
256262
state.bottomSheetState.isIdle,
257263
state.bottomSheetState.currentDetent,
264+
state.dismissAnimationInProgress,
258265
) {
259-
if (state.bottomSheetState.isIdle && state.bottomSheetState.currentDetent == SheetDetent.Hidden) {
266+
if (
267+
state.dismissAnimationInProgress.not() &&
268+
state.bottomSheetState.isIdle &&
269+
state.bottomSheetState.currentDetent == SheetDetent.Hidden
270+
) {
260271
completeDismiss()
261272
}
262273
}

composeunstyled-modal-bottom-sheet/src/commonTest/kotlin/ModalBottomSheet.test.kt

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ import assertk.assertThat
4343
import assertk.assertions.isCloseTo
4444
import assertk.assertions.isEqualTo
4545
import assertk.assertions.isFalse
46+
import assertk.assertions.isGreaterThan
47+
import assertk.assertions.isLessThan
4648
import assertk.assertions.isTrue
4749
import kotlinx.coroutines.CoroutineScope
4850
import kotlinx.coroutines.launch
@@ -342,6 +344,49 @@ class ModalBottomSheetTest {
342344
mainClock.autoAdvance = true
343345
}
344346

347+
testCase("modal sheet remains visible while outside tap interrupts enter animation") {
348+
val peek = SheetDetent("peek") { containerHeight, _ ->
349+
containerHeight * 0.6f
350+
}
351+
lateinit var state: ModalBottomSheetState
352+
setContent {
353+
state = rememberModalBottomSheetState(
354+
initialDetent = SheetDetent.Hidden,
355+
detents = listOf(SheetDetent.Hidden, peek, SheetDetent.FullyExpanded),
356+
animationSpec = tween(durationMillis = 1000),
357+
dismissAnimationSpec = tween(durationMillis = 1000),
358+
)
359+
UnstyledModalBottomSheet(
360+
state = state,
361+
properties = ModalSheetProperties(dismissOnClickOutside = true),
362+
overlay = { UnstyledScrim(Modifier.testTag("scrim")) },
363+
) {
364+
Sheet { Box(Modifier.testTag("sheet").size(400.dp)) }
365+
}
366+
}
367+
waitForIdle()
368+
onNode(isDialog()).assertDoesNotExist()
369+
370+
mainClock.autoAdvance = false
371+
state.targetDetent = peek
372+
mainClock.advanceTimeBy(300)
373+
onNode(isDialog()).assertExists()
374+
onNodeWithTag("sheet").assertExists()
375+
assertThat(state.offset).isGreaterThan(0f)
376+
377+
onNode(isDialog()).performTouchInput { click(Offset(1f, 1f)) }
378+
mainClock.advanceTimeByFrame()
379+
380+
val dialogBottomAfterDismiss = onNode(isDialog()).fetchSemanticsNode().boundsInRoot.bottom
381+
val sheetTopAfterDismiss = onNodeWithTag("sheet").fetchSemanticsNode().boundsInRoot.top
382+
383+
assertThat(state.offset).isGreaterThan(0f)
384+
assertThat(sheetTopAfterDismiss).isLessThan(dialogBottomAfterDismiss)
385+
onNode(isDialog()).assertExists()
386+
onNodeWithTag("sheet").assertExists()
387+
mainClock.autoAdvance = true
388+
}
389+
345390
testCase(
346391
"modal sheet unmounts after outside tap dismiss animation and modal fragment exit complete",
347392
) {

0 commit comments

Comments
 (0)