Nav3: Draggable Sheet

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)