Add fourteen skills from Dimillian/Skills, integrate the merged Snowflake and WordPress updates into the maintainer sync, and refresh registry metadata, attributions, walkthrough notes, and the 8.9.0 release notes while keeping validation warnings within budget.
5.2 KiB
5.2 KiB
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:
- a primary section sized to the viewport
- 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
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
progressfrom real scroll offset, not from duplicated booleans likeisExpanded,isShowingSecondary, andisSnapped. - Use
progressto driveoffset,opacity,blur,scaleEffect, and toolbar state so the whole surface stays synchronized. - Use
ScrollViewReaderfor programmatic snapping from taps on the primary content or chevron affordances. - Use
onScrollTargetVisibilityChangewhen 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
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
progressis 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
progressdrives it, keep other animations off that property. - Do not store derived state like
isSecondaryVisibleunless another API requires it. Prefer deriving it fromprogressor 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