Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@ target that only needs "is setup done" can depend on just this.
Separate from `HowdyKit` so a watchOS target depending only on the storage
layer never compiles SwiftUI it doesn't use.

- **`OnboardingTheme`**: primary color, card background (`ShapeStyle`, any
material or color), fonts, and a custom background view, all defaulted to
- **`OnboardingTheme`**: primary color (text) and accent color (header icon, buttons, Skip, page dot; defaults to primary), optional card background (`AnyShapeStyle`, any
material or color; nil means no card), fonts, and a custom background view, all defaulted to
plain system styling. Provided through the environment with
`.onboardingTheme(_:)` at the root. CrumbDB's brand (custom font, topo
background) and Marie's plain look are the same view with different themes,
Expand All @@ -75,7 +75,7 @@ layer never compiles SwiftUI it doesn't use.
which `OnboardingAction` to pass on each render, the view itself just
renders whatever it's given right now.
- **`OnboardingStepView`**: header + arbitrary `@ViewBuilder` content +
primary/optional-secondary actions, themed. Renders one step. Inside a flow
primary/optional-secondary actions, themed, drawn inside a rounded card when the theme sets `cardBackground`. Renders one step. Inside a flow
it publishes its actions to `OnboardingFlowView`'s shared footer; on its own
it draws the footer itself.
- **`OnboardingWelcomeView`**: the first-run screen. No header/content split
Expand All @@ -96,7 +96,7 @@ layer never compiles SwiftUI it doesn't use.
swipe-past backfill (`flow.recordIfNeeded(leaving: step)`, driven by
`step.isSkippable`; swiping past a required step is allowed and records
`false`), and calls `onFinished` once
the last step advances. Pass `isAlreadySatisfied:` (for example, "is the
the last step advances and `onPageShown` with the step ID each time a page is shown (for analytics). Pass `isAlreadySatisfied:` (for example, "is the
OS permission already granted") and steps it accepts are recorded `true`
through `flow.reconcile` before paging, and again when the app becomes
active, so they never get a page. This is the turnkey piece: CrumbDB and Marie get
Expand Down
7 changes: 7 additions & 0 deletions Sources/HowdyKitUI/OnboardingFlowPage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ struct OnboardingFlowPage: Equatable {
new > old ? old..<new : 0..<0
}

/// The id to report as shown, or `nil` when there is nothing new to
/// report (no page, or the same page as last time).
static func nextShown(_ id: OnboardingStepID?, after last: OnboardingStepID?) -> OnboardingStepID? {
guard let id, id != last else { return nil }
return id
}

/// The pages a flow still needs, welcome first when it's wanted and
/// unrecorded, then every unanswered step in declaration order. Reads
/// `storage` directly rather than the flow's tracked queries: this runs
Expand Down
19 changes: 19 additions & 0 deletions Sources/HowdyKitUI/OnboardingFlowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public struct OnboardingFlowView<Welcome: View, StepContent: View>: View {
private let flow: OnboardingFlow
private let welcome: (() -> Welcome)?
private let onFinished: () -> Void
private let onPageShown: ((OnboardingStepID) -> Void)?
private let isAlreadySatisfied: (OnboardingStepConfig) -> Bool
private let stepContent: (OnboardingStepConfig, @escaping () -> Void) -> StepContent

Expand All @@ -36,6 +37,7 @@ public struct OnboardingFlowView<Welcome: View, StepContent: View>: View {
@State private var actions: [OnboardingStepID: OnboardingActions] = [:]
@State private var currentPageID: OnboardingStepID?
@State private var highestVisitedIndex = 0
@State private var lastShownPageID: OnboardingStepID?
// `init` runs on every parent re-render (and every recorded answer
// re-renders the parent, the flow being observable); `@State` keeps the
// first snapshot and ignores the later `initialValue`s.
Expand All @@ -44,6 +46,11 @@ public struct OnboardingFlowView<Welcome: View, StepContent: View>: View {
/// - Parameter welcome: builds the welcome page, shown first while the
/// flow's welcome is unrecorded. Typically an `OnboardingWelcomeView`,
/// which records the welcome itself; this view then slides on.
/// - Parameter onPageShown: called with the page's ID each time a page
/// comes on screen (the first page on appear, then every page change),
/// never twice in a row for the same page. For analytics: the IDs are
/// the flow's step IDs (the welcome reports `welcomeID`), and the app
/// maps them to whatever names its funnel uses.
/// - Parameter isAlreadySatisfied: reports whether a step is already
/// satisfied outside the flow (a permission the OS already granted).
/// Such unanswered steps are recorded `true` via
Expand All @@ -56,13 +63,15 @@ public struct OnboardingFlowView<Welcome: View, StepContent: View>: View {
public init(
flow: OnboardingFlow,
onFinished: @escaping () -> Void = {},
onPageShown: ((OnboardingStepID) -> Void)? = nil,
isAlreadySatisfied: @escaping (OnboardingStepConfig) -> Bool = { _ in false },
@ViewBuilder welcome: @escaping () -> Welcome,
@ViewBuilder stepContent: @escaping (OnboardingStepConfig, @escaping () -> Void) -> StepContent
) {
self.flow = flow
self.welcome = welcome
self.onFinished = onFinished
self.onPageShown = onPageShown
self.isAlreadySatisfied = isAlreadySatisfied
self.stepContent = stepContent
flow.reconcile(isSatisfied: isAlreadySatisfied)
Expand Down Expand Up @@ -132,7 +141,9 @@ public struct OnboardingFlowView<Welcome: View, StepContent: View>: View {
backfill(pages[passedIndex])
}
highestVisitedIndex = max(highestVisitedIndex, newIndex)
reportShown(currentPageID)
}
.onAppear { reportShown(currentPageID) }
// The welcome records itself (`OnboardingWelcomeView`); slide on
// once it has, if it's still the page on screen.
.onChange(of: flow.needsWelcome) { _, needsWelcome in
Expand All @@ -143,6 +154,12 @@ public struct OnboardingFlowView<Welcome: View, StepContent: View>: View {
}
}

private func reportShown(_ id: OnboardingStepID?) {
guard let shown = OnboardingFlowPage.nextShown(id, after: lastShownPageID) else { return }
lastShownPageID = shown
onPageShown?(shown)
}

private var footer: some View {
// One permanent view, never swapped for another, and never animated:
// a page change is wrapped in `withAnimation`, and any animated change
Expand Down Expand Up @@ -187,12 +204,14 @@ extension OnboardingFlowView where Welcome == EmptyView {
public init(
flow: OnboardingFlow,
onFinished: @escaping () -> Void = {},
onPageShown: ((OnboardingStepID) -> Void)? = nil,
isAlreadySatisfied: @escaping (OnboardingStepConfig) -> Bool = { _ in false },
@ViewBuilder stepContent: @escaping (OnboardingStepConfig, @escaping () -> Void) -> StepContent
) {
self.flow = flow
self.welcome = nil
self.onFinished = onFinished
self.onPageShown = onPageShown
self.isAlreadySatisfied = isAlreadySatisfied
self.stepContent = stepContent
flow.reconcile(isSatisfied: isAlreadySatisfied)
Expand Down
4 changes: 2 additions & 2 deletions Sources/HowdyKitUI/OnboardingFooter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,15 @@ struct OnboardingFooter: View {
Button(secondary?.title ?? "", action: secondary?.handler ?? {})
.buttonStyle(.plain)
.font(.subheadline)
.foregroundStyle(theme.primaryColor.opacity(0.85))
.foregroundStyle(theme.accentColor.opacity(0.85))
.opacity(secondary == nil ? 0 : 1)
.disabled(secondary == nil)
.accessibilityHidden(secondary == nil)
.frame(height: 44)
}
// `.borderedProminent`/`.bordered` otherwise fall back to the
// system accent color, not the theme.
.tint(theme.primaryColor)
.tint(theme.accentColor)
.padding(.horizontal, 28)
.padding(.top, 8)
// The page control no longer overlaps this: the footer sits below
Expand Down
2 changes: 1 addition & 1 deletion Sources/HowdyKitUI/OnboardingPageIndicator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ struct OnboardingPageIndicator: View {
HStack(spacing: 8) {
ForEach(0..<count, id: \.self) { index in
Circle()
.fill(index == current ? theme.primaryColor : Color.secondary.opacity(0.35))
.fill(index == current ? theme.accentColor : Color.secondary.opacity(0.35))
.frame(width: 7, height: 7)
}
}
Expand Down
29 changes: 22 additions & 7 deletions Sources/HowdyKitUI/OnboardingStepView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,7 @@ public struct OnboardingStepView<Content: View>: View {
VStack(spacing: 0) {
GeometryReader { geometry in
ScrollView {
VStack(spacing: 20) {
if let header {
headerView(header)
}
content
}
card
.padding(24)
.frame(maxWidth: 560)
.frame(maxWidth: .infinity, minHeight: geometry.size.height)
Expand All @@ -52,13 +47,33 @@ public struct OnboardingStepView<Content: View>: View {
.preference(key: OnboardingActionsKey.self, value: publishedActions)
}

@ViewBuilder
private var card: some View {
if let cardBackground = theme.cardBackground {
stack
.padding(24)
.background(RoundedRectangle(cornerRadius: 20).fill(cardBackground))
} else {
stack
}
}

private var stack: some View {
VStack(spacing: 20) {
if let header {
headerView(header)
}
content
}
}

@ViewBuilder
private func headerView(_ header: OnboardingHeader) -> some View {
VStack(spacing: 10) {
if let icon = header.icon {
Image(systemName: icon)
.font(.system(size: 64))
.foregroundStyle(theme.primaryColor)
.foregroundStyle(theme.accentColor)
}
Text(header.title)
.font(theme.titleFont)
Expand Down
20 changes: 15 additions & 5 deletions Sources/HowdyKitUI/OnboardingTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,31 +13,41 @@ import SwiftUI
/// Not `Sendable`: this is SwiftUI configuration data, read on the main
/// actor as part of rendering, the same as the views it configures.
public struct OnboardingTheme {
/// Text color (titles). See `accentColor` for the interactive highlights.
public var primaryColor: Color
public var cardBackground: AnyShapeStyle
/// Interactive and brand highlights: the header icon, the primary button,
/// the Skip text, the current page dot. Primary is for text. Defaults to
/// `primaryColor` when not given.
public var accentColor: Color
/// When set, each step's header and content are drawn inside a rounded
/// card filled with it; `nil` means no card. Pass one as
/// `AnyShapeStyle(.regularMaterial)` or `AnyShapeStyle(Color.x.opacity(0.85))`.
public var cardBackground: AnyShapeStyle?
public var titleFont: Font
public var headlineFont: Font
public var bodyFont: Font
public var background: AnyView

public init(
primaryColor: Color = .primary,
cardBackground: some ShapeStyle = .regularMaterial,
accentColor: Color? = nil,
cardBackground: AnyShapeStyle? = nil,
titleFont: Font = .title2.bold(),
headlineFont: Font = .headline,
bodyFont: Font = .subheadline,
@ViewBuilder background: () -> some View = { Color.clear }
) {
self.primaryColor = primaryColor
self.cardBackground = AnyShapeStyle(cardBackground)
self.accentColor = accentColor ?? primaryColor
self.cardBackground = cardBackground
self.titleFont = titleFont
self.headlineFont = headlineFont
self.bodyFont = bodyFont
self.background = AnyView(background())
}

/// Plain system styling: no custom font, no custom background, a
/// materials-based card. What an app reaches for until it wants to
/// Plain system styling: no custom font, no custom background, no
/// card. What an app reaches for until it wants to
/// brand the flow.
public static let `default` = OnboardingTheme()
}
Expand Down
9 changes: 9 additions & 0 deletions Tests/HowdyKitUITests/OnboardingFlowPageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,4 +57,13 @@ struct OnboardingFlowPageTests {
#expect(OnboardingFlowPage.indicesPassed(from: 2, to: 1).isEmpty)
#expect(OnboardingFlowPage.indicesPassed(from: 2, to: 2).isEmpty)
}

@Test("A page is reported when it is new, never twice in a row, and never when absent")
func nextShownGuard() {
#expect(OnboardingFlowPage.nextShown(first, after: nil) == first)
#expect(OnboardingFlowPage.nextShown(middle, after: first) == middle)
#expect(OnboardingFlowPage.nextShown(first, after: first) == nil)
#expect(OnboardingFlowPage.nextShown(nil, after: first) == nil)
#expect(OnboardingFlowPage.nextShown(nil, after: nil) == nil)
}
}
18 changes: 18 additions & 0 deletions Tests/HowdyKitUITests/OnboardingThemeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,22 @@ struct OnboardingThemeTests {
#expect(theme.headlineFont == OnboardingTheme.default.headlineFont)
#expect(theme.bodyFont == OnboardingTheme.default.bodyFont)
}

@Test func accentDefaultsToPrimary() {
#expect(OnboardingTheme(primaryColor: .red).accentColor == .red)
#expect(OnboardingTheme().accentColor == Color.primary)
}

@Test func explicitAccentIsKept() {
let theme = OnboardingTheme(primaryColor: .red, accentColor: .orange)
#expect(theme.primaryColor == .red)
#expect(theme.accentColor == .orange)
}

@Test func cardBackgroundIsNilByDefaultAndSetWhenGiven() {
#expect(OnboardingTheme().cardBackground == nil)
#expect(OnboardingTheme.default.cardBackground == nil)
#expect(OnboardingTheme(cardBackground: AnyShapeStyle(.regularMaterial)).cardBackground != nil)
#expect(OnboardingTheme(cardBackground: AnyShapeStyle(Color.white.opacity(0.85))).cardBackground != nil)
}
}
Loading