playbook/antigravity-awesome-skills/skills/swiftui-ui-patterns/references/scroll-reveal.md

134 lines
5.2 KiB
Markdown

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