From a366ca9188270f0c621d6c78f081113e412efbdf Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 8 Aug 2026 21:43:57 +0200 Subject: [PATCH 1/3] feat(components): navigation slot and full-bleed content for the onboarding shell A step that can be stepped back from passes navigationIcon. The slot's width is reserved whether or not it is filled, and mirrored on the other side, so the progress stays centred as steps come and go. contentPadding lets a step built from grouped rows drop the column's own inset, since those rows carry their own. --- .../floret/components/Onboarding.kt | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/Onboarding.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/Onboarding.kt index f8eb277..7e72158 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/Onboarding.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/Onboarding.kt @@ -33,6 +33,9 @@ import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +/** Standard touch target, reserved for the shell's navigation slot. */ +private val NavigationSlotSize = 48.dp + /** MD3 8dp spacing scale shared by the onboarding screens. */ object OnboardingSpace { val xs = 8.dp @@ -50,8 +53,14 @@ object OnboardingSpace { * as one family while keeping their own mark. * * A multi-step flow passes [progress] (see [OnboardingProgress]), which is - * pinned above the scrolling content. [topSpacing] gives a step whose hero is a - * large picture rather than a mark room to breathe without overflowing. + * pinned above the scrolling content, and [navigationIcon] for a step that can + * be stepped back from. The navigation slot's width is reserved whether or not + * it is filled, and mirrored on the other side, so the progress stays centred + * and does not shift as steps come and go. + * + * [topSpacing] gives a step whose hero is a large picture rather than a mark + * room to breathe. [contentPadding] is the scrolling column's own horizontal + * inset — a step built from grouped rows passes 0, since those carry their own. */ @Composable fun OnboardingScaffold( @@ -59,21 +68,26 @@ fun OnboardingScaffold( actions: @Composable ColumnScope.() -> Unit, modifier: Modifier = Modifier, progress: (@Composable () -> Unit)? = null, + navigationIcon: (@Composable () -> Unit)? = null, topSpacing: Dp = OnboardingSpace.xl, + contentPadding: Dp = OnboardingSpace.md, body: @Composable ColumnScope.() -> Unit, ) { Scaffold( modifier = modifier, containerColor = MaterialTheme.colorScheme.surface, topBar = { - if (progress != null) { - Box( + if (progress != null || navigationIcon != null) { + Row( modifier = Modifier .fillMaxWidth() .statusBarsPadding() - .padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm), + .padding(horizontal = OnboardingSpace.xs, vertical = OnboardingSpace.sm), + verticalAlignment = Alignment.CenterVertically, ) { - progress() + Box(Modifier.size(NavigationSlotSize)) { navigationIcon?.invoke() } + Box(Modifier.weight(1f)) { progress?.invoke() } + Spacer(Modifier.width(NavigationSlotSize)) } } }, @@ -94,7 +108,7 @@ fun OnboardingScaffold( .fillMaxSize() .padding(innerPadding) .verticalScroll(rememberScrollState()) - .padding(horizontal = OnboardingSpace.md), + .padding(horizontal = contentPadding), horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(Modifier.height(topSpacing)) From 98dcaf09ada25500b8006542a462830174807345 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 8 Aug 2026 22:08:17 +0200 Subject: [PATCH 2/3] feat(components): let the onboarding actions scroll with the content A step whose content is taller than the screen loses room to a pinned bar, and the bar hides that there is more below it. scrollingActions moves the call-to-action stack to the end of the scrolling column instead. --- .../floret/components/Onboarding.kt | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/Onboarding.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/Onboarding.kt index 7e72158..2bfdefe 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/Onboarding.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/Onboarding.kt @@ -61,6 +61,11 @@ object OnboardingSpace { * [topSpacing] gives a step whose hero is a large picture rather than a mark * room to breathe. [contentPadding] is the scrolling column's own horizontal * inset — a step built from grouped rows passes 0, since those carry their own. + * + * [scrollingActions] moves the actions off the bottom bar and onto the end of + * the scrolling content, for a step whose content is taller than the screen: a + * pinned bar there eats the room the content needs and hides the fact that + * there is more of it below. */ @Composable fun OnboardingScaffold( @@ -71,6 +76,7 @@ fun OnboardingScaffold( navigationIcon: (@Composable () -> Unit)? = null, topSpacing: Dp = OnboardingSpace.xl, contentPadding: Dp = OnboardingSpace.md, + scrollingActions: Boolean = false, body: @Composable ColumnScope.() -> Unit, ) { Scaffold( @@ -92,15 +98,9 @@ fun OnboardingScaffold( } }, bottomBar = { - Column( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - content = actions, - ) + // Pinned actions carry the navigation-bar inset themselves; scrolling + // ones get it from the scaffold's own content padding. + if (!scrollingActions) ActionsColumn(insetBottom = true, content = actions) }, ) { innerPadding -> Column( @@ -115,11 +115,29 @@ fun OnboardingScaffold( hero() Spacer(Modifier.height(OnboardingSpace.lg)) body() + if (scrollingActions) ActionsColumn(insetBottom = false, content = actions) Spacer(Modifier.height(OnboardingSpace.md)) } } } +/** The call-to-action stack, wherever the shell has put it. */ +@Composable +private fun ActionsColumn( + insetBottom: Boolean, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .then(if (insetBottom) Modifier.navigationBarsPadding() else Modifier) + .padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + content = content, + ) +} + /** * Where a multi-step onboarding flow has got to: one capsule per step, filled up * to and including [step] (1-based), with [label] — the caller's own localised From 1705e67eebdb7f0d0673ad52e0fe15017f44553f Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sat, 8 Aug 2026 22:11:35 +0200 Subject: [PATCH 3/3] feat(components): let scrolling actions inherit the content inset Pinned actions keep their own 24dp margin; scrolling ones sit inside the content column, so the caller's inset decides how wide they run. --- .../floret/components/Onboarding.kt | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/Onboarding.kt b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/Onboarding.kt index 2bfdefe..b82e6fd 100644 --- a/components/src/main/kotlin/de/jeanlucmakiola/floret/components/Onboarding.kt +++ b/components/src/main/kotlin/de/jeanlucmakiola/floret/components/Onboarding.kt @@ -100,7 +100,13 @@ fun OnboardingScaffold( bottomBar = { // Pinned actions carry the navigation-bar inset themselves; scrolling // ones get it from the scaffold's own content padding. - if (!scrollingActions) ActionsColumn(insetBottom = true, content = actions) + if (!scrollingActions) { + ActionsColumn( + horizontalPadding = OnboardingSpace.md, + insetBottom = true, + content = actions, + ) + } }, ) { innerPadding -> Column( @@ -115,7 +121,15 @@ fun OnboardingScaffold( hero() Spacer(Modifier.height(OnboardingSpace.lg)) body() - if (scrollingActions) ActionsColumn(insetBottom = false, content = actions) + // No inset of its own: scrolling actions sit inside the content + // column, so they line up with whatever is above them. + if (scrollingActions) { + ActionsColumn( + horizontalPadding = 0.dp, + insetBottom = false, + content = actions, + ) + } Spacer(Modifier.height(OnboardingSpace.md)) } } @@ -124,6 +138,7 @@ fun OnboardingScaffold( /** The call-to-action stack, wherever the shell has put it. */ @Composable private fun ActionsColumn( + horizontalPadding: Dp, insetBottom: Boolean, content: @Composable ColumnScope.() -> Unit, ) { @@ -131,7 +146,7 @@ private fun ActionsColumn( modifier = Modifier .fillMaxWidth() .then(if (insetBottom) Modifier.navigationBarsPadding() else Modifier) - .padding(horizontal = OnboardingSpace.md, vertical = OnboardingSpace.sm), + .padding(horizontal = horizontalPadding, vertical = OnboardingSpace.sm), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp), content = content,