Skip to content

🐛 Keep OnboardingFlowView's page list stable for the whole flow - #6

Merged
dryan merged 17 commits into
mainfrom
stable-page-list
Oct 2, 2026
Merged

dryan merged 17 commits into
mainfrom
stable-page-list

Conversation

@dryan-gh-actions

Copy link
Copy Markdown
Contributor

The pending step list was computed in init, and init runs on every
parent re-render. Once OnboardingFlow became observable, every recorded
answer re-rendered the parent and rebuilt the list without the step
just answered, so the TabView's pages shrank mid-flow: index 1 became
a different screen (a skipped step), a page changed identity during
the slide (the crossed-over transition), and with one step left the
dots disappeared and there was nothing to swipe back to.

Hold the snapshot in @State so the first one sticks, read storage
directly when taking it so the parent doesn't subscribe to the flow,
and drop the speculative .id() added while chasing the symptom.

Co-Authored-By: Claude Fable 5.1 noreply@anthropic.com

The pending step list was computed in init, and init runs on every
parent re-render. Once OnboardingFlow became observable, every recorded
answer re-rendered the parent and rebuilt the list without the step
just answered, so the TabView's pages shrank mid-flow: index 1 became
a different screen (a skipped step), a page changed identity during
the slide (the crossed-over transition), and with one step left the
dots disappeared and there was nothing to swipe back to.

Hold the snapshot in @State so the first one sticks, read storage
directly when taking it so the parent doesn't subscribe to the flow,
and drop the speculative .id() added while chasing the symptom.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dryan-gh-actions
dryan-gh-actions Bot requested a review from a team October 2, 2026 07:18
dryan-gh-actions Bot and others added 16 commits October 2, 2026 07:22
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- OnboardingFlow.reconcile(isSatisfied:) records true for unanswered
  steps the app reports as already satisfied outside the flow (a
  permission iOS already granted). OnboardingFlowView takes an
  isAlreadySatisfied closure, reconciles before snapshotting its pages
  and again when the scene becomes active. Resetting the kit's own
  records, or replaying onboarding, no longer shows a page for
  something the system already allows.
- OnboardingFooter is the one button area the step and welcome views
  share. Both slots always hold their space, visible or not, so the
  main button and Skip sit in the same spot on every screen.

Tests: 23 (20 flow/storage, 3 theme environment), all passing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Each page drew its own footer inside the TabView, so the page control
sat on top of the button area. Step and welcome views now publish their
actions upward through a preference (keyed by step ID from the
environment) and OnboardingFlowView renders one OnboardingFooter under
the TabView for the page on screen. A step view used outside a flow
still draws its own footer.

Public API of the step and welcome views is unchanged; Dolly needed no
source changes.

Tests: 30 (20 flow/storage, 10 UI), all passing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Development/testing counterpart to reset(): a tool can put a single
step back to unrecorded to set up "this one was never seen" (or keep
one in the middle answered) and replay the flow against that mix.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A bare TabView index change jumps; wrapping it in withAnimation slides
the page the way a swipe does. The welcome records inside withAnimation
too so the flow's welcome-to-pages swap crossfades instead of cutting.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The welcome was a separate screen swapped out ahead of the TabView, so
leaving it could only crossfade. It's now the first page of the same
pager when the flow still needs it: Continue slides to the first step
exactly like a swipe, swiping back to it works, and swiping past it
records it as seen. OnboardingFlowPage holds the page list logic so it
can be tested on its own.

Tests: 34 (21 flow/storage, 13 UI), all passing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Moving from a page with Skip to one without swapped the Skip Button for
a placeholder inside the animated page change, so for the length of
the fade both existed and the footer was briefly 44pt taller: the page
content and the dots sat higher, then settled down. The secondary slot
is now one stable Button that is hidden instead of swapped, the primary
slot gets an identity transition, and the flow renders a hidden footer
of the same size while a page's actions haven't arrived yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The footer was still two conditional branches (real footer vs hidden
placeholder) swapped while a page's actions arrived, and its content
changes animated inside the page-change transaction. Both nudge the
TabView's height mid-slide, which is the residual drift in the content
and the dots. OnboardingFooter now takes optional actions and hides
its buttons instead of being replaced, and the flow renders it with
animation disabled.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Measured on the iOS 27 simulator, TabView(.page) rendered a programmatic
page change as a cut (not a slide) under both withAnimation and
.animation(value:), settled each new page's centered content ~10pt
late (its layout box changes after the page is added), and drew no
dots at all. All three are its internals.

The pager is now a horizontal ScrollView with .paging and a
scrollPosition binding: advance() animates the position so Next slides
exactly like a swipe, pages are created up front so nothing lays out
mid-slide, and OnboardingPageIndicator draws themed dots between the
pages and the footer. Verified with a screen recording: every
transition is a 7 to 8 frame slide at 20fps and content drift after
landing is zero.

Tests: 35, all passing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A horizontal ScrollView takes its height from its content, and
containerRelativeFrame(.vertical) measured the pages against the whole
screen, so the pager grew to full height and pushed the footer (and
its Skip line) below the bottom edge. Pages are now sized from a
GeometryReader that owns the pager's actual slot above the dots and
footer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The README is the API reference; this is what an agent (or a person)
needs to wire the kit into an app without re-learning tonight's
lessons: dependency shape, one flow app-wide, needsOnboarding as the
only root switch, step views own state and actions, theme from the
environment, reconcile OS-granted permissions, analytics and replay
stay in the app, and the simulator recording harness.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three things a faithful CrumbDB port needs:

- OnboardingTheme.accentColor (defaults to primaryColor) drives the
  header icon, the footer buttons, and the current dot, so an app with
  orange highlights and Moss text keeps both.
- cardBackground is optional and, when set, OnboardingStepView draws
  the header and content inside a rounded card filled with it.
- OnboardingFlowView.onPageShown fires once per page shown (first page
  on appear, then every change), for page-view analytics; onAppear on
  page content can't do it since every page is created up front.

Tests: 39, all passing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dryan
dryan merged commit 5251b46 into main Oct 2, 2026
1 check passed
@dryan
dryan deleted the stable-page-list branch October 2, 2026 17:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant