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
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ the pages. The step's primary action must `flow.record(...)` then call
Theme comes from the environment (`.onboardingTheme(_:)`), set once at the
root. Never pass a theme into a view; never hard-code brand colors in a step.

The kit balances header titles and headlines itself. A step's own message
paragraph is app content, so apply
`Text(message).multilineTextAlignment(.center).balancedText()` to it; do not
reach for `lineBreakStrategy`, which leaves a one-word last line alone.

## 5. Reconcile system permissions

A permission the OS has already granted must not get a page. Pass
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,13 @@ layer never compiles SwiftUI it doesn't use.
`.onboardingTheme(_:)` at the root. A brand (custom font, custom
background) and a plain look are the same view with different themes,
not different views.
- **`OnboardingHeader`**: icon (optional), title, headline (optional).
- **`OnboardingHeader`**: icon (optional), title, headline (optional). The
title and headline are balanced (even lines, no one-word last line).
- **`.balancedText()`**: the same balancing for any `Text`, for a step's own
paragraph: `Text(message).multilineTextAlignment(.center).balancedText()`.
`lineBreakStrategy = .pushOut` does not fix a one-word last line; this
does, by laying the text out at the narrowest width that keeps its line
count.
- **`OnboardingAction`**: a title and a handler, not a state machine.
A permission step's primary button changes label as its state changes (Allow
Location → Open Settings → Next); that's the app's own logic recomputing
Expand Down
60 changes: 60 additions & 0 deletions Sources/HowdyKitUI/BalancedText.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import SwiftUI

/// The narrowest width that lays text out in the same number of lines as it
/// has at `maxWidth`. Wrapping text greedily leaves one long line and a stub
/// ("taken." alone); at the narrowest width that still fits the same line
/// count the lines come out even, which is what CSS `text-wrap: balance`
/// does. `measure` returns the text's height at a width and must not grow
/// as the width grows (greedy wrapping satisfies that).
enum BalancedWidth {
static func narrowest(maxWidth: CGFloat, measure: (CGFloat) -> CGFloat) -> CGFloat {
let fullHeight = measure(maxWidth)
var low: CGFloat = 0
var high = maxWidth
while high - low > 1 {
let mid = (low + high) / 2
if measure(mid) <= fullHeight + 0.5 {
high = mid
} else {
low = mid
}
}
return high
}
}

struct BalancedTextLayout: Layout {
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
guard let text = subviews.first else { return .zero }
// No finite width to balance against (an unconstrained measure).
guard let maxWidth = proposal.width, maxWidth.isFinite, maxWidth > 0 else {
return text.sizeThatFits(proposal)
}
let width = BalancedWidth.narrowest(maxWidth: maxWidth) {
text.sizeThatFits(ProposedViewSize(width: $0, height: nil)).height
}
return text.sizeThatFits(ProposedViewSize(width: width, height: nil))
}

func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
subviews.first?.place(
at: bounds.origin,
anchor: .topLeading,
proposal: ProposedViewSize(width: bounds.width, height: bounds.height)
)
}
}

public extension View {
/// Wraps a multi-line `Text` so its lines come out even instead of
/// leaving a one-word last line. Apply it to the text itself, after
/// `multilineTextAlignment`, so the narrower block is centered by its
/// parent and each line is centered inside it:
///
/// Text(message).multilineTextAlignment(.center).balancedText()
///
/// The kit applies it to every step header's title and headline.
func balancedText() -> some View {
BalancedTextLayout { self }
}
}
2 changes: 2 additions & 0 deletions Sources/HowdyKitUI/OnboardingStepView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,12 @@ public struct OnboardingStepView<Content: View>: View {
.font(theme.titleFont)
.foregroundStyle(theme.primaryColor)
.multilineTextAlignment(.center)
.balancedText()
if let headline = header.headline {
Text(headline)
.font(theme.headlineFont)
.multilineTextAlignment(.center)
.balancedText()
}
}
}
Expand Down
74 changes: 74 additions & 0 deletions Tests/HowdyKitUITests/BalancedTextTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import CoreGraphics
import Testing
@testable import HowdyKitUI

@Suite("BalancedWidth")
struct BalancedTextTests {
private let charWidth: CGFloat = 10
private let lineHeight: CGFloat = 20

/// Greedy word wrap with fixed-width characters, the same shape of
/// problem a real text layout solves.
private func lines(_ text: String, at width: CGFloat) -> [String] {
let perLine = max(1, Int(width / charWidth))
var result: [String] = []
var current = ""
for word in text.split(separator: " ") {
let candidate = current.isEmpty ? String(word) : current + " " + word
if candidate.count <= perLine || current.isEmpty {
current = candidate
} else {
result.append(current)
current = String(word)
}
}
if !current.isEmpty { result.append(current) }
return result
}

private func measure(_ text: String) -> (CGFloat) -> CGFloat {
{ width in CGFloat(lines(text, at: width).count) * lineHeight }
}

private let sentence = "Dolly lines up your location history with each photo's timestamp and suggests where it was taken."

/// At 49 characters per line the sentence wraps to three lines with
/// "taken." alone on the last (at 50 it fits on two).
private let orphanWidth: CGFloat = 490

@Test("Keeps the line count the text has at full width")
func keepsLineCount() {
let full = lines(sentence, at: orphanWidth)
let width = BalancedWidth.narrowest(maxWidth: orphanWidth, measure: measure(sentence))
#expect(lines(sentence, at: width).count == full.count)
#expect(width <= orphanWidth)
}

@Test("Pulls a one-word last line up into an even block")
func fixesOrphan() throws {
let greedy = lines(sentence, at: orphanWidth)
#expect(greedy.count == 3)
#expect(greedy.last == "taken.")

let width = BalancedWidth.narrowest(maxWidth: orphanWidth, measure: measure(sentence))
let balanced = lines(sentence, at: width)
let last = try #require(balanced.last)
#expect(last.split(separator: " ").count > 1)
#expect(last.count * 2 >= (balanced.map(\.count).max() ?? 0))
}

@Test("A single line shrinks to its own width, no narrower")
func singleLine() {
let text = "Photo Library"
let width = BalancedWidth.narrowest(maxWidth: 340, measure: measure(text))
#expect(lines(text, at: width).count == 1)
#expect(width >= CGFloat(text.count) * charWidth - 1)
#expect(width < 340)
}

@Test("A width too small for any wrap change returns it unchanged")
func tinyWidth() {
let width = BalancedWidth.narrowest(maxWidth: 1, measure: measure(sentence))
#expect(width == 1)
}
}
Loading