mirror of
https://github.com/Dimillian/Skills.git
synced 2026-03-25 08:55:54 +00:00
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:
parent
343f5b3aad
commit
9f3533a773
3 changed files with 138 additions and 0 deletions
|
|
@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- Build the view with small, focused subviews and SwiftUI-native data flow.
|
||||||
|
|
||||||
### New project scaffolding
|
### New project scaffolding
|
||||||
|
|
@ -32,6 +33,7 @@ Choose a track based on your goal:
|
||||||
- Maintain existing legacy patterns only when editing legacy files.
|
- Maintain existing legacy patterns only when editing legacy files.
|
||||||
- Follow the project's formatter and style guide.
|
- 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.
|
- **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
|
## 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.
|
- Pitfalls and performance notes.
|
||||||
- Paths to existing examples in the current repo.
|
- 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
|
## Sheet patterns
|
||||||
|
|
||||||
### Item-driven sheet (preferred)
|
### Item-driven sheet (preferred)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- Controls (toggles, pickers, sliders): `references/controls.md` — Use for settings controls and input selection.
|
||||||
|
|
|
||||||
133
swiftui-ui-patterns/references/scroll-reveal.md
Normal file
133
swiftui-ui-patterns/references/scroll-reveal.md
Normal 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`
|
||||||
Loading…
Reference in a new issue