Skip to content

Commit 48627d0

Browse files
authored
feat: single Liquid Glass settings drawer with controllable height animation (#25)
One morphing settings drawer with system Liquid Glass chrome + glass toolbar buttons, a directional push/pop content slide, and a reusable DynamicDetentSheet component that gives the sheet-height resize a dialable curve (locked to the slide, first-open pre-warmed). UI-only; CI runs the StreamCore suite.
1 parent e2100f7 commit 48627d0

2 files changed

Lines changed: 357 additions & 78 deletions

File tree

Stream/DynamicDetentSheet.swift

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import SwiftUI
2+
3+
/// Controllable-speed animation for a bottom sheet's height.
4+
///
5+
/// There are two ways to move a sheet between detents and they honor a custom curve
6+
/// differently. Animating the `CGFloat` behind a single `.height()` detent honors an
7+
/// arbitrary spring/duration reliably (SwiftUI interpolates the value under your
8+
/// transaction and re-applies the detent every frame). Changing a bound
9+
/// `PresentationDetent` (the `selection:` form) only *loosely* honors it — the
10+
/// inter-detent resize uses `UISheetPresentationController`'s own system spring.
11+
///
12+
/// `DynamicDetentSheetModel` therefore owns the measured, content-hugging height and
13+
/// animates every resize with a caller-supplied curve via the value-animated path.
14+
/// `SelectableDetentSheet` wraps the selection form for the fixed `.medium`/`.large`
15+
/// case, where the system spring is fine.
16+
///
17+
/// iOS 17+ (`@Observable`, `withAnimation(_:completion:)`); the scroll measurement
18+
/// primitive is iOS 18+ with a `GeometryReader` fallback for older content.
19+
20+
// MARK: - DynamicDetentSheetModel
21+
22+
@MainActor
23+
@Observable
24+
final class DynamicDetentSheetModel {
25+
26+
/// The height committed to the sheet's `.height()` detent. Only the
27+
/// `DynamicHeightDetent` modifier body reads this, so a resize invalidates just
28+
/// that modifier — not the whole sheet body.
29+
private(set) var detentHeight: CGFloat
30+
31+
/// The single curve that drives every resize. The caller owns it fully — set a
32+
/// slower/heavier spring here to fix a "too fast" resize.
33+
var animation: Animation
34+
35+
/// Fixed chrome (nav bar/header + grabber + home-indicator inset) added on top of
36+
/// measured CONTENT height so the sheet is exactly tall enough and never clips.
37+
var chrome: CGFloat
38+
39+
/// Floor so a nearly-empty pane never collapses to a sliver.
40+
var minimumContentHeight: CGFloat
41+
42+
/// Cached measured content heights, keyed by each pane's OWN identity. Background
43+
/// panes populate this WITHOUT resizing. We store the numeric height because you
44+
/// cannot read the `CGFloat` back out of a `PresentationDetent`, and we need it
45+
/// for the >= 1pt threshold that breaks the measurement feedback loop.
46+
private var measured: [AnyHashable: CGFloat] = [:]
47+
48+
/// Identity of the pane currently ON TOP. Only its measurements resize the sheet.
49+
private var activeKey: AnyHashable?
50+
51+
/// First sizing snaps (clean present at the right height); later ones animate.
52+
private var hasSettled = false
53+
54+
/// True while an animated resize is in flight. Guards against a mid-flight
55+
/// re-measure restarting the spring; cleared (and reconciled) on real completion.
56+
private var isResizing = false
57+
58+
init(initialHeight: CGFloat = 320,
59+
chrome: CGFloat = 0,
60+
minimumContentHeight: CGFloat = 0,
61+
animation: Animation = .spring(duration: 0.45, bounce: 0.1)) {
62+
self.detentHeight = initialHeight
63+
self.chrome = chrome
64+
self.minimumContentHeight = minimumContentHeight
65+
self.animation = animation
66+
}
67+
68+
private func resolved(_ content: CGFloat) -> CGFloat {
69+
max(minimumContentHeight, content) + chrome
70+
}
71+
72+
/// Raw measurement from a pane's scroll/intrinsic geometry. Cheap + idempotent:
73+
/// rounded, thresholded, cached under the pane's key, and it only commits an
74+
/// ANIMATED resize when the reporting pane is the ACTIVE one. A late measurement
75+
/// of an off-screen pane can therefore never kick off a competing resize.
76+
func report<Key: Hashable>(height rawContent: CGFloat, for key: Key) {
77+
let rounded = rawContent.rounded()
78+
let k = AnyHashable(key)
79+
if let existing = measured[k], abs(existing - rounded) < 1 { return } // dedup
80+
measured[k] = rounded
81+
82+
guard k == activeKey else { return } // background pane: cache only
83+
guard !isResizing else { return } // don't restart mid-flight — settle() reconciles
84+
commit(resolved(rounded))
85+
}
86+
87+
/// Switch the on-top pane AND resize to its cached height. Call this INSIDE
88+
/// `animatingResize` together with your content change (e.g. `selected = section`)
89+
/// so the content transition and the resize start as ONE motion. Falls back to the
90+
/// current height when the incoming pane was never measured (first open is a single
91+
/// two-step: transition now, exact fit on the pane's first measurement).
92+
func activate<Key: Hashable>(_ key: Key) {
93+
let k = AnyHashable(key)
94+
activeKey = k
95+
detentHeight = resolved(measured[k] ?? (detentHeight - chrome))
96+
}
97+
98+
/// Run `changes` (your content transition + `activate`, or an in-place resize)
99+
/// under the model's curve, and clear the in-flight guard on the animation's REAL
100+
/// completion, reconciling any measurement that landed mid-flight. Put the detent
101+
/// change AND any in-sheet `.transition` change in the same `changes` closure so
102+
/// they ride one transaction and never fight.
103+
func animatingResize(_ changes: () -> Void) {
104+
isResizing = true
105+
// The completion runs on the main run loop; the model is @MainActor, so hop
106+
// back into isolation to reconcile.
107+
withAnimation(animation) {
108+
changes()
109+
} completion: {
110+
MainActor.assumeIsolated { self.settle() }
111+
}
112+
}
113+
114+
private func commit(_ target: CGFloat) {
115+
guard abs(target - detentHeight) >= 1 else { return }
116+
if hasSettled {
117+
animatingResize { self.detentHeight = target }
118+
} else {
119+
hasSettled = true
120+
detentHeight = target // clean present: snap, no launch animation
121+
}
122+
}
123+
124+
/// Completion hook: drop the in-flight guard, then commit any measurement that
125+
/// arrived (and was cached but skipped) while the last resize was animating.
126+
private func settle() {
127+
isResizing = false
128+
guard let k = activeKey, let latest = measured[k] else { return }
129+
commit(resolved(latest))
130+
}
131+
}
132+
133+
// MARK: - Dynamic-height detent modifier (curve honored)
134+
135+
/// Applies a single content-hugging `.height()` detent driven by the model. Only THIS
136+
/// body reads `model.detentHeight`, so an animated height change re-evaluates only the
137+
/// modifier and rides whatever transaction mutated the height.
138+
struct DynamicHeightDetent: ViewModifier {
139+
let model: DynamicDetentSheetModel
140+
var dragIndicator: Visibility = .visible
141+
142+
func body(content: Content) -> some View {
143+
content
144+
.presentationDetents([.height(model.detentHeight)])
145+
.presentationDragIndicator(dragIndicator)
146+
}
147+
}
148+
149+
extension View {
150+
/// Drive this sheet's height from a `DynamicDetentSheetModel` (curve-controlled).
151+
func dynamicHeightDetents(_ model: DynamicDetentSheetModel,
152+
dragIndicator: Visibility = .visible) -> some View {
153+
modifier(DynamicHeightDetent(model: model, dragIndicator: dragIndicator))
154+
}
155+
156+
/// Report a SCROLLABLE pane's real content height under `key` (iOS 18+). Feedback
157+
/// free: `contentSize` is INTRINSIC, so resizing the sheet does not change it — the
158+
/// "update multiple times per frame" loop cannot form.
159+
@available(iOS 18.0, *)
160+
func measuredDetentHeight<Key: Hashable>(
161+
_ key: Key, into model: DynamicDetentSheetModel
162+
) -> some View {
163+
onScrollGeometryChange(for: CGFloat.self) { $0.contentSize.height } action: { _, height in
164+
model.report(height: height, for: key)
165+
}
166+
}
167+
168+
/// Measure NON-scroll / intrinsic content, or an iOS 16–17 fallback for the scroll
169+
/// case. A background `GeometryReader` never joins layout, so it cannot create a loop.
170+
func measuredIntrinsicDetentHeight<Key: Hashable>(
171+
_ key: Key, into model: DynamicDetentSheetModel
172+
) -> some View {
173+
background(
174+
GeometryReader { proxy in
175+
Color.clear
176+
.onAppear { model.report(height: proxy.size.height, for: key) }
177+
.onChange(of: proxy.size.height) { _, h in
178+
model.report(height: h, for: key)
179+
}
180+
}
181+
)
182+
}
183+
}
184+
185+
// MARK: - Selection-binding detents (fixed .medium/.large; curve NOT precisely honored)
186+
187+
/// Wraps the `presentationDetents(_:selection:)` API for the FIXED-detent case: a
188+
/// `Binding<PresentationDetent>`. Changing `selection` inside `withAnimation` animates,
189+
/// but the resize uses the system spring, so a custom duration/curve is only loosely
190+
/// applied — use `DynamicDetentSheetModel` when you need a dialable curve.
191+
///
192+
/// INVARIANT: the set handed to SwiftUI ALWAYS contains the current selection, so a
193+
/// programmatic selection can never point outside the set and trigger the "selected
194+
/// detent not in Set" fallback-snap.
195+
struct SelectableDetentSheet: ViewModifier {
196+
let detents: Set<PresentationDetent>
197+
@Binding var selection: PresentationDetent
198+
var dragIndicator: Visibility = .visible
199+
200+
private var resolvedDetents: Set<PresentationDetent> { detents.union([selection]) }
201+
202+
func body(content: Content) -> some View {
203+
content
204+
.presentationDetents(resolvedDetents, selection: $selection)
205+
.presentationDragIndicator(dragIndicator)
206+
}
207+
}
208+
209+
extension View {
210+
/// Snap between fixed detents with a `Binding`. The custom curve is delegated to
211+
/// the system spring (see `SelectableDetentSheet`).
212+
func selectableDetents(_ detents: Set<PresentationDetent>,
213+
selection: Binding<PresentationDetent>,
214+
dragIndicator: Visibility = .visible) -> some View {
215+
modifier(SelectableDetentSheet(detents: detents,
216+
selection: selection,
217+
dragIndicator: dragIndicator))
218+
}
219+
}

0 commit comments

Comments
 (0)