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..b82e6fd 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,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