Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ui/navigation/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ dependencies {

testImplementation(kotlin("test"))
testImplementation(libs.bundles.unit.testing)
testImplementation(libs.bundles.compose.ui.testing)
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.Snapshot
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
Expand Down Expand Up @@ -53,6 +54,7 @@ import com.getcode.theme.CodeTheme
import com.getcode.ui.core.noRippleClickable
import com.getcode.ui.utils.LocalSheetExpansionState
import com.getcode.ui.utils.LocalSheetGesturesState
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch

data class BottomSheetProperties(
Expand Down Expand Up @@ -173,25 +175,54 @@ internal class ModalBottomSheetScene<T : Any> constructor(
}
}

// One presentation of the sheet must pop its entry exactly once. Every dismissal ends
// with the sheet settled at Hidden, and the settle observer below fires for all of
// them — including the explicit paths here and in [pendingSheetDismiss] — so the pop is
// funnelled through this guard. Reset when the sheet is (re-)opened.
var dismissHandled by remember { mutableStateOf(false) }
val finishDismiss = {
if (!dismissHandled) {
dismissHandled = true
handleBackResult()
onBack()
}
}

// Animate the sheet in on first composition and after
// same-route dismiss-replace (sheetGeneration increments).
LaunchedEffect(navigator.sheetGeneration) {
sheetState.animateTo(restingDetent)
}

// [UnstyledBottomSheet] is a plain sheet with no dismissal callback of its own, so
// dragging it closed hides it without telling anyone: the entry stays on the backstack
// as an invisible sheet that still swallows every touch through its full-size scrim,
// and chrome that hides while a sheet is up (the v2 tab bar) stays hidden until the
// next tap lands on that scrim and pops it. Watch the sheet's state rather than the
// gesture, so a drag-dismiss pops the entry the same way a scrim tap does.
LaunchedEffect(navigator.sheetGeneration) {
dismissHandled = false
// The sheet starts out Hidden, so a return to Hidden only means "dismissed" once
// it has actually been presented.
snapshotFlow { sheetState.targetDetent }.first { it != SheetDetent.Hidden }
// Settled, not merely targeted: mid-drag the target flips to Hidden and back as the
// finger crosses the dismiss threshold.
snapshotFlow { sheetState.isIdle && sheetState.currentDetent == SheetDetent.Hidden }
.first { it }
finishDismiss()
}

val composeScope = rememberCoroutineScope()

val dismiss = { hide: Boolean ->
if (hide && sheetState.currentDetent != SheetDetent.Hidden) {
composeScope.launch {
sheetState.animateTo(SheetDetent.Hidden)
}.invokeOnCompletion {
handleBackResult()
onBack()
finishDismiss()
}
} else {
handleBackResult()
onBack()
finishDismiss()
}
}

Expand All @@ -203,8 +234,7 @@ internal class ModalBottomSheetScene<T : Any> constructor(
sheetState.animateTo(SheetDetent.Hidden)
} finally {
Snapshot.withMutableSnapshot {
handleBackResult()
onBack()
finishDismiss()
navigator.pendingSheetDismiss = null
pendingDismiss()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.getcode.navigation.scenes

import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeDown
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.NavKey
import com.getcode.navigation.AppHome
import com.getcode.navigation.DemoSheet
import com.getcode.navigation.core.EmptyCodeNavigator
import com.getcode.navigation.core.LocalCodeNavigator
import com.getcode.navigation.scrim.LocalScrimController
import com.getcode.navigation.scrim.ScrimController
import com.getcode.navigation.testNavigator
import com.getcode.theme.DesignSystem
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.test.assertEquals

private const val SheetContentTag = "sheet-content"
private const val BaseContentTag = "base-content"

/**
* [UnstyledBottomSheet] has no dismissal callback, so the scene has to notice the sheet settling
* back at [SheetDetent.Hidden] itself. If it doesn't, a drag-dismiss leaves the (now invisible)
* sheet entry on the backstack — swallowing touches through its full-screen scrim and keeping
* app chrome that hides for sheets (the v2 tab bar) hidden until the next tap pops it.
*/
@RunWith(RobolectricTestRunner::class)
class ModalBottomSheetSceneTest {

@get:Rule
val composeTestRule = createComposeRule()

private var backCount = 0

private fun showSheet() {
backCount = 0
val navigator = testNavigator(AppHome, DemoSheet)
val baseEntry = NavEntry<NavKey>(key = AppHome) {
Box(Modifier.fillMaxSize().testTag(BaseContentTag))
}
val sheetEntry = NavEntry<NavKey>(key = DemoSheet) {
Box(Modifier.fillMaxSize().testTag(SheetContentTag))
}
val scene = ModalBottomSheetScene(
key = DemoSheet,
previousEntries = listOf(baseEntry),
overlaidEntries = listOf(baseEntry),
entry = sheetEntry,
sheetProperties = BottomSheetProperties(),
onBack = { backCount++ },
metadata = emptyMap(),
navResultStore = EmptyCodeNavigator.resultStore,
lastNavKey = { AppHome },
)

composeTestRule.setContent {
DesignSystem {
CompositionLocalProvider(
LocalCodeNavigator provides navigator,
LocalScrimController provides ScrimController(),
) {
scene.content()
}
}
}
composeTestRule.waitForIdle()
}

@Test
fun `an open sheet does not pop its entry`() {
showSheet()

assertEquals(0, backCount, "sheet popped its entry without being dismissed")
}

@Test
fun `dragging the sheet closed pops its entry`() {
showSheet()

composeTestRule.onNodeWithTag(SheetContentTag).performTouchInput { swipeDown() }
composeTestRule.waitForIdle()

assertEquals(1, backCount, "drag-to-dismiss did not pop the sheet entry")
}
}
Loading