Add scroll-reveal pattern reference

Introduce a new scroll-reveal reference describing how to implement detail surfaces that reveal secondary content via vertical scrolling. Adds references/scroll-reveal.md with intent, core pattern, a minimal SwiftUI example that derives a normalized progress from scroll offset, design guidance (morphing controls, haptics, interaction guards), and pitfalls. Also update components-index.md and SKILL.md to point to the new guidance and to recommend driving visuals from a single scroll-derived progress value rather than parallel gesture state machines.
This commit is contained in:
Thomas Ricouard 2026-03-07 07:50:18 +01:00
parent 343f5b3aad
commit 9f3533a773
3 changed files with 138 additions and 0 deletions

View file

@ -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)

View file

@ -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.

View file

@ -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`