diff --git a/swiftui-ui-patterns/SKILL.md b/swiftui-ui-patterns/SKILL.md index 2383d7a..2bb90c8 100644 --- a/swiftui-ui-patterns/SKILL.md +++ b/swiftui-ui-patterns/SKILL.md @@ -15,6 +15,7 @@ Choose a track based on your goal: - Find a nearby example in the repo with `rg "TabView\("` or similar, then read the closest SwiftUI view. - Apply local conventions: prefer SwiftUI-native state, keep state local when possible, and use environment injection for shared dependencies. - Choose the relevant component reference from `references/components-index.md` and follow its guidance. +- If the interaction reveals secondary content by dragging or scrolling the primary content away, read `references/scroll-reveal.md` before implementing gestures manually. - Build the view with small, focused subviews and SwiftUI-native data flow. ### New project scaffolding @@ -32,6 +33,7 @@ Choose a track based on your goal: - Maintain existing legacy patterns only when editing legacy files. - Follow the project's formatter and style guide. - **Sheets**: Prefer `.sheet(item:)` over `.sheet(isPresented:)` when state represents a selected model. Avoid `if let` inside a sheet body. Sheets should own their actions and call `dismiss()` internally instead of forwarding `onCancel`/`onConfirm` closures. +- **Scroll-driven reveals**: Prefer deriving a normalized progress value from scroll offset and driving the visual state from that single source of truth. Avoid parallel gesture state machines unless scroll alone cannot express the interaction. ## Workflow for a new SwiftUI view @@ -50,6 +52,8 @@ Use `references/components-index.md` as the entry point. Each component referenc - Pitfalls and performance notes. - Paths to existing examples in the current repo. +For detail surfaces that progressively reveal actions, metadata, or contextual panels, use `references/scroll-reveal.md`. + ## Sheet patterns ### Item-driven sheet (preferred) diff --git a/swiftui-ui-patterns/references/components-index.md b/swiftui-ui-patterns/references/components-index.md index bb9b573..4d38ba1 100644 --- a/swiftui-ui-patterns/references/components-index.md +++ b/swiftui-ui-patterns/references/components-index.md @@ -13,6 +13,7 @@ Use this file to find component-specific guidance. Each entry lists when to use - Split views and columns: `references/split-views.md` — Use for iPad/macOS multi-column layouts or custom secondary columns. - List and Section: `references/list.md` — Use for feed-style content and settings rows. - ScrollView and Lazy stacks: `references/scrollview.md` — Use for custom layouts, horizontal scrollers, or grids. +- Scroll-reveal detail surfaces: `references/scroll-reveal.md` — Use when a detail screen reveals secondary content or actions as the user scrolls or swipes between full-screen sections. - Grids: `references/grids.md` — Use for icon pickers, media galleries, and tiled layouts. - Theming and dynamic type: `references/theming.md` — Use for app-wide theme tokens, colors, and type scaling. - Controls (toggles, pickers, sliders): `references/controls.md` — Use for settings controls and input selection. diff --git a/swiftui-ui-patterns/references/scroll-reveal.md b/swiftui-ui-patterns/references/scroll-reveal.md new file mode 100644 index 0000000..a278ece --- /dev/null +++ b/swiftui-ui-patterns/references/scroll-reveal.md @@ -0,0 +1,133 @@ +# Scroll-reveal detail surfaces + +## Intent + +Use this pattern when a detail screen has a primary surface first and secondary content behind it, and you want the user to reveal that secondary layer by scrolling or swiping instead of tapping a separate button. + +Typical fits: + +- media detail screens that reveal actions or metadata +- maps, cards, or canvases that transition into structured detail +- full-screen viewers with a second "actions" or "insights" page + +## Core pattern + +Build the interaction as a paged vertical `ScrollView` with two sections: + +1. a primary section sized to the viewport +2. a secondary section below it + +Derive a normalized `progress` value from the vertical content offset and drive all visual changes from that one value. + +Avoid treating the reveal as a separate gesture system unless scroll alone cannot express it. + +## Minimal structure + +```swift +private enum DetailSection: Hashable { + case primary + case secondary +} + +struct DetailSurface: View { + @State private var revealProgress: CGFloat = 0 + @State private var secondaryHeight: CGFloat = 1 + + var body: some View { + GeometryReader { geometry in + ScrollViewReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + PrimaryContent(progress: revealProgress) + .frame(height: geometry.size.height) + .id(DetailSection.primary) + + SecondaryContent(progress: revealProgress) + .id(DetailSection.secondary) + .onGeometryChange(for: CGFloat.self) { geo in + geo.size.height + } action: { newHeight in + secondaryHeight = max(newHeight, 1) + } + } + .scrollTargetLayout() + } + .scrollTargetBehavior(.paging) + .onScrollGeometryChange(for: CGFloat.self, of: { scroll in + scroll.contentOffset.y + scroll.contentInsets.top + }) { _, offset in + revealProgress = (offset / secondaryHeight).clamped(to: 0...1) + } + .safeAreaInset(edge: .bottom) { + ChevronAffordance(progress: revealProgress) { + withAnimation(.smooth) { + let target: DetailSection = revealProgress < 0.5 ? .secondary : .primary + proxy.scrollTo(target, anchor: .top) + } + } + } + } + } + } +} +``` + +## Design choices to keep + +- Make the primary section exactly viewport-sized when the interaction should feel like paging between states. +- Compute `progress` from real scroll offset, not from duplicated booleans like `isExpanded`, `isShowingSecondary`, and `isSnapped`. +- Use `progress` to drive `offset`, `opacity`, `blur`, `scaleEffect`, and toolbar state so the whole surface stays synchronized. +- Use `ScrollViewReader` for programmatic snapping from taps on the primary content or chevron affordances. +- Use `onScrollTargetVisibilityChange` when you need a settled section state for haptics, tooltip dismissal, analytics, or accessibility announcements. + +## Morphing a shared control + +If a control appears to move from the primary surface into the secondary content, do not render two fully visible copies. + +Instead: + +- expose a source anchor in the primary area +- expose a destination anchor in the secondary area +- render one overlay that interpolates position and size using `progress` + +```swift +Color.clear + .anchorPreference(key: ControlAnchorKey.self, value: .bounds) { anchor in + ["source": anchor] + } + +Color.clear + .anchorPreference(key: ControlAnchorKey.self, value: .bounds) { anchor in + ["destination": anchor] + } + +.overlayPreferenceValue(ControlAnchorKey.self) { anchors in + MorphingControlOverlay(anchors: anchors, progress: revealProgress) +} +``` + +This keeps the motion coherent and avoids duplicate-hit-target bugs. + +## Haptics and affordances + +- Use light threshold haptics when the reveal begins and stronger haptics near the committed state. +- Keep a visible affordance like a chevron or pill while `progress` is near zero. +- Flip, fade, or blur the affordance as the secondary section becomes active. + +## Interaction guards + +- Disable vertical scrolling when a conflicting mode is active, such as pinch-to-zoom, crop, or full-screen media manipulation. +- Disable hit testing on overlays that should disappear once the secondary content is revealed. +- Avoid same-axis nested scroll views unless the inner view is effectively static or disabled during the reveal. + +## Pitfalls + +- Do not hard-code the progress divisor. Measure the secondary section height or another real reveal distance. +- Do not mix multiple animation sources for the same property. If `progress` drives it, keep other animations off that property. +- Do not store derived state like `isSecondaryVisible` unless another API requires it. Prefer deriving it from `progress` or visible scroll targets. +- Beware of layout feedback loops when measuring heights. Clamp zero values and update only when the measured height actually changes. + +## Concrete example + +- Pool iOS tile detail reveal: `/Users/dimillian/Documents/Dev/Pool/pool-ios/Pool/Sources/Features/Tile/Detail/TileDetailView.swift` +- Secondary content anchor example: `/Users/dimillian/Documents/Dev/Pool/pool-ios/Pool/Sources/Features/Tile/Detail/TileDetailIntentListView.swift`