This is a post in a series on how I tackled Nav3 concepts and wired things together.
I needed a sheet that can be closed by dragging it down – it surprised me a bit that the implementation isn’t that difficult. It uses the overlay scene concept and a few basic helpers from the Compose Foundation.
Ideally, I’d like to skip the overlay scene infrastructure and make it a full scene. However, Navigation3 is missing an API that would allow me to signal that (as the user starts dragging) we are navigating back, and that the underlying scene needs to be rendered – similar to how it is rendered during the predictive back gesture.
The initial implementation used a Popup composable, but that didn’t work very well – it had various issues that were practically impossible to fix. The solution was to switch to a simple Box, but that also requires another Box around the NavDisplay itself – don’t forget to add that as well.
internal class DraggableSheetScene<T : Any>(
override val key: Any,
private val entry: NavEntry<T>,
override val previousEntries: List<NavEntry<T>>,
override val overlaidEntries: List<NavEntry<T>>,
private val onBack: () -> Unit,
) : OverlayScene<T> {
private var state: AnchoredDraggableState<Boolean>? = null
override val entries: List<NavEntry<T>> = listOf(entry)
@OptIn(ExperimentalMaterial3Api::class)
override val content: @Composable (() -> Unit) = {
Box {
val window = LocalWindowInfo.current
val anchors = DraggableAnchors {
false at 0f
true at window.containerSize.height.toFloat()
}
val state = remember {
AnchoredDraggableState(
initialValue = true,
anchors = anchors,
)
}.also {
[email protected] = it
}
DisposableEffect(Unit) {
onDispose { [email protected] = null }
}
SideEffect {
state.updateAnchors(anchors)
}
LaunchedEffect(state) {
// open animation
state.animateTo(false)
}
LaunchedEffect(state.settledValue, state.isAnimationRunning) {
if (state.settledValue && !state.isAnimationRunning) {
onBack()
}
}
val density = LocalDensity.current
PredictiveBackHandler { progress ->
try {
val startOffset = state.requireOffset()
val targetOffset = with(density) { 60.dp.toPx() }
progress.collect { backEvent ->
val progressValue = backEvent.progress
val desiredOffset = startOffset + targetOffset * progressValue
val currentOffset = state.requireOffset()
val delta = desiredOffset - currentOffset
state.dispatchRawDelta(delta)
}
// Gesture completed - animate to final position
state.animateTo(true)
onBack()
} catch (e: CancellationException) {
// Gesture cancelled - animate back to current position
state.animateTo(state.currentValue)
throw e
}
}
Box(
Modifier
.anchoredDraggable(
orientation = Orientation.Vertical,
state = state,
)
.offset { IntOffset(0, state.offset.toInt()) },
) {
entry.Content()
}
}
}
override suspend fun onRemove() {
state?.animateTo(true)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as DraggableSheetScene<*>
return key == other.key &&
previousEntries == other.previousEntries &&
overlaidEntries == other.overlaidEntries &&
entry == other.entry
}
override fun hashCode(): Int {
return key.hashCode() * 31 +
previousEntries.hashCode() * 31 +
overlaidEntries.hashCode() * 31 +
entry.hashCode() * 31
}
override fun toString(): String {
return "DraggableSheetScene(key=$key, entry=$entry, previousEntries=$previousEntries, overlaidEntries=$overlaidEntries)"
}
}
class DraggableSheetSceneStrategy<T : Any>() : SceneStrategy<T> {
override fun SceneStrategyScope<T>.calculateScene(
entries: List<NavEntry<T>>,
): Scene<T>? {
val lastEntry = entries.lastOrNull()
val _ = lastEntry?.metadata?.get(DRAGGABLE_KEY) ?: return null
return DraggableSheetScene(
key = lastEntry.contentKey,
entry = lastEntry,
previousEntries = entries.dropLast(1),
overlaidEntries = entries.dropLast(1),
onBack = onBack,
)
}
@PublishedApi
internal companion object {
@PublishedApi
internal fun draggableSheet(): Map<String, Any> = mapOf(DRAGGABLE_KEY to Unit)
internal const val DRAGGABLE_KEY = "draggable"
}
}
To use, you may utilize an extension function:
inline fun <reified R : NavKey> EntryProviderScope<NavKey>.entryDraggableSheet(noinline content: @Composable (R) -> Unit) =
entry<R>(metadata = DraggableSheetSceneStrategy.draggableSheet(), content = content)