Merge pull request 'feat(components): navigation slot, full-bleed content and scrolling actions for the onboarding shell' (#4) from feat/onboarding-shell into main

This commit is contained in:
Jean-Luc Makiola
2026-08-10 17:18:47 +02:00
@@ -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,19 @@ 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.
*
* [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(
@@ -59,34 +73,40 @@ 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,
scrollingActions: Boolean = false,
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))
}
}
},
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(
horizontalPadding = OnboardingSpace.md,
insetBottom = true,
content = actions,
)
}
},
) { innerPadding ->
Column(
@@ -94,18 +114,45 @@ fun OnboardingScaffold(
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = OnboardingSpace.md),
.padding(horizontal = contentPadding),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(Modifier.height(topSpacing))
hero()
Spacer(Modifier.height(OnboardingSpace.lg))
body()
// 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))
}
}
}
/** The call-to-action stack, wherever the shell has put it. */
@Composable
private fun ActionsColumn(
horizontalPadding: Dp,
insetBottom: Boolean,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.then(if (insetBottom) Modifier.navigationBarsPadding() else Modifier)
.padding(horizontal = horizontalPadding, 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