Last active
May 27, 2026 14:59
-
-
Save nikolaknez/4b1f049c3500785f2869b3b3a1c6123d to your computer and use it in GitHub Desktop.
AnimatedPresence.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import SwiftUI | |
| /// A narrow layout that replaces `VStack` in row scenes — when some children may collapse | |
| /// to zero height, **adjacent spacing is also continuously scaled by presence progress**, | |
| /// so that the final stage of collapse does not produce an un-animated spacing jump. | |
| /// | |
| /// ## Why not just use VStack | |
| /// | |
| /// `VStack(spacing: x)` spacing is an unconditional promise from the parent to all children: | |
| /// regardless of whether a child's current height is zero, adjacent gaps are always | |
| /// hard-inserted at `x`. When a child finishes collapsing (height animates to 0), | |
| /// it still occupies a slot in the parent, and spacing stays at full `x` until the last | |
| /// frame — visually, it "disappears" one beat later than the child's own height. | |
| /// | |
| /// ## How it works | |
| /// | |
| /// 1. Children explicitly opt in via `.ignoredWhenCollapsed()`, telling this layout | |
| /// "I may collapse to 0; account for my surrounding spacing when I do." | |
| /// 2. Children expose their current presence progress (0…1) as a layoutValue via | |
| /// `.collapsibleSpacingProgress(_:)`; that modifier conforms to `Animatable`, | |
| /// giving the otherwise-static layoutValue indirect animatable capability, | |
| /// so progress changes drive the parent layout to recompute spacing each frame. | |
| /// 3. This layout takes `min(prevScale, nextScale) × baseSpacing` as the effective | |
| /// spacing between each adjacent pair of children — the closer scale is to 0, | |
| /// the smaller the spacing. | |
| /// | |
| /// This way spacing and child height walk the same animation timeline: on the same frame | |
| /// that collapse finishes, spacing also reaches 0, perfectly synchronized — no | |
| /// "height hits 0 first, spacing disappears later" two-step feel. | |
| /// | |
| /// ## Design boundaries | |
| /// | |
| /// - Only covers `.leading` / `.center` / `.trailing` alignments. When `alignmentGuide` | |
| /// customization is needed, wrap that content in an inner `VStack` to handle alignment | |
| /// there; this layout only handles collapse-aware spacing behavior. | |
| /// - Does not attempt to cover `AnyLayout` switching or `VStackLayout` compatibility; | |
| /// those scenarios continue to use native SwiftUI. | |
| /// - When a child is not marked `.ignoredWhenCollapsed()`, scale is always 1 and | |
| /// behavior degrades to a normal fixed-spacing VStack; no side effects for non-collapsing cases. | |
| struct CollapsibleSpacingVStack: Layout { | |
| enum Alignment { | |
| case leading | |
| case center | |
| case trailing | |
| } | |
| var alignment: Alignment | |
| var spacing: CGFloat? | |
| /// Fallback threshold when there is no explicit `collapsibleSpacingProgress`: | |
| /// height below this value is treated as collapsed. Only takes effect in the edge case | |
| /// where a child is marked `.ignoredWhenCollapsed()` but does not expose a progress value; | |
| /// normal AnimatedPresence consumers always push progress and never fall into this binary branch. | |
| private let collapsedThreshold: CGFloat = 0.5 | |
| init(alignment: Alignment = .center, spacing: CGFloat? = nil) { | |
| self.alignment = alignment | |
| self.spacing = spacing | |
| } | |
| func sizeThatFits( | |
| proposal: ProposedViewSize, | |
| subviews: Subviews, | |
| cache: inout () | |
| ) -> CGSize { | |
| let snapshot = currentSnapshot(subviews: subviews, width: proposal.width) | |
| // Children that are fully collapsed (scale == 0) do not contribute to width: | |
| // their content should be treated as "temporarily absent" in the parent container. | |
| // This is a known simplification — if the only widest child in a row happens to be | |
| // collapsed, the parent width will narrow on the frame collapse completes; | |
| // in scenes like NoteSwitcherRow where child widths are roughly equal this effect | |
| // is invisible. | |
| let width = snapshot.reduce(into: CGFloat(0)) { partial, item in | |
| if item.scale > 0 { | |
| partial = max(partial, item.size.width) | |
| } | |
| } | |
| // height = Σ(item.size.height + item.spacingAfter). spacingAfter is computed | |
| // once inside currentSnapshot (including scale shrinkage and bridge compensation); | |
| // placeSubviews uses the same data, so the two phases cannot produce different heights. | |
| let height = snapshot.reduce(into: CGFloat(0)) { partial, item in | |
| partial += item.size.height + item.spacingAfter | |
| } | |
| return CGSize(width: width, height: height) | |
| } | |
| func placeSubviews( | |
| in bounds: CGRect, | |
| proposal: ProposedViewSize, | |
| subviews: Subviews, | |
| cache: inout () | |
| ) { | |
| let snapshot = currentSnapshot(subviews: subviews, width: bounds.width) | |
| var y = bounds.minY | |
| for index in snapshot.indices { | |
| let item = snapshot[index] | |
| subviews[index].place( | |
| at: CGPoint(x: xOrigin(for: item.size, in: bounds), y: y), | |
| anchor: .topLeading, | |
| proposal: ProposedViewSize(width: bounds.width, height: item.size.height) | |
| ) | |
| // The same size.height + spacingAfter accumulation formula as sizeThatFits — | |
| // the height reported by sizeThatFits is the endpoint of y advancement in | |
| // placeSubviews; structurally impossible to drift. | |
| y += item.size.height + item.spacingAfter | |
| } | |
| } | |
| /// Computes a one-shot snapshot of each child's `(size, scale, spacingAfter)`, | |
| /// shared between `sizeThatFits` and `placeSubviews`. | |
| /// | |
| /// `spacingAfter` attaches "the spacing between this child and the next" to the child itself: | |
| /// both layout methods accumulate using `size.height + spacingAfter`, the formula is | |
| /// symmetric, and physically cannot yield different heights. The last child has spacingAfter = 0. | |
| /// | |
| /// Processing is two steps: first scale base spacing by adjacent pair scales; | |
| /// then call `addBridgeSpacings` to restore base spacing between visible siblings | |
| /// that are separated by a collapsed child. Together the two steps cover the full semantics. | |
| /// | |
| /// **This layout does not implement application-level cache**: | |
| /// | |
| /// - Internal state changes in a child (e.g. `AnimatedPresence.visibleHeight` changing | |
| /// frame-by-frame within an animation transaction) change the intrinsic the child reports, | |
| /// but SwiftUI only calls `updateCache` when the subview list is added/removed/reordered — | |
| /// there is no suitable signal to invalidate sizes cached by width. If the cache hits | |
| /// but child intrinsics have changed, the outer layer would advance `y` by stale heights, | |
| /// causing the next child to overlap the current child's render area. | |
| /// - `subview.sizeThatFits` already has measurement caching inside the SwiftUI framework; | |
| /// repeated calls with the same proposal do not actually re-measure the underlying | |
| /// view tree intrinsics. Adding another application-level cache layer is neither safe | |
| /// nor provides meaningful benefit. | |
| private func currentSnapshot( | |
| subviews: Subviews, | |
| width: CGFloat? | |
| ) -> [Item] { | |
| let sizes = subviews.map { subview in | |
| sanitized(subview.sizeThatFits(ProposedViewSize(width: width, height: nil))) | |
| } | |
| let scales = subviews.indices.map { index in | |
| spacingScale( | |
| forHeight: sizes[index].height, | |
| isCollapsible: subviews[index][CollapsedIgnorableKey.self], | |
| progress: subviews[index][CollapsibleSpacingProgressKey.self] | |
| ) | |
| } | |
| var spacingAfterValues = subviews.indices.map { index in | |
| let nextIndex = subviews.index(after: index) | |
| return nextIndex < subviews.endIndex | |
| ? scaledSpacingDistance( | |
| previous: subviews[index], | |
| next: subviews[nextIndex], | |
| previousScale: scales[index], | |
| nextScale: scales[nextIndex] | |
| ) | |
| : 0 | |
| } | |
| addBridgeSpacings( | |
| to: &spacingAfterValues, | |
| subviews: subviews, | |
| scales: scales | |
| ) | |
| return subviews.indices.map { index in | |
| Item( | |
| size: sizes[index], | |
| scale: scales[index], | |
| spacingAfter: spacingAfterValues[index] | |
| ) | |
| } | |
| } | |
| /// Adds bridge spacing between two visible siblings that are separated by a collapsed child. | |
| /// | |
| /// `scaledSpacingDistance` using `min(prev, next)` to scale base spacing is too aggressive | |
| /// in the `visible / collapsed / visible` pattern: spacing on both sides of the collapsed | |
| /// child is compressed to 0, causing the two visible siblings to fully abut. But once | |
| /// collapse completes, prevVisible and nextVisible are effectively new adjacent siblings | |
| /// and should have normal base spacing. | |
| /// | |
| /// Bridge formula: | |
| /// - For each contiguous collapsed run `[runStart, runEnd]`, | |
| /// - If the run has visible siblings on both sides, compute | |
| /// `baseSpacingDistance(prevVisible, nextVisible) × (1 - maxRunScale)`, | |
| /// - **Split evenly between prevVisible and runEnd** — so that during the collapse | |
| /// intermediate state the left/right spacing distribution is symmetric, and AP | |
| /// visually "grows from the center" during expand rather than biasing to one side. | |
| /// | |
| /// `1 - maxRunScale` interpolates the bridge in sync with collapse progress. `maxRunScale` | |
| /// rather than `minRunScale`: when any child in the run is still relatively visible | |
| /// (larger scale), the run still acts as a visual break, so the bridge is weakened. | |
| private func addBridgeSpacings( | |
| to spacingAfterValues: inout [CGFloat], | |
| subviews: Subviews, | |
| scales: [CGFloat] | |
| ) { | |
| var index = subviews.startIndex | |
| while index < subviews.endIndex { | |
| guard scales[index] < 1 else { | |
| index = subviews.index(after: index) | |
| continue | |
| } | |
| // Expand collapsed run [runStart, runEnd] | |
| let runStart = index | |
| var runEnd = index | |
| var maxRunScale = scales[index] | |
| var nextIndex = subviews.index(after: index) | |
| while nextIndex < subviews.endIndex, scales[nextIndex] < 1 { | |
| runEnd = nextIndex | |
| maxRunScale = max(maxRunScale, scales[nextIndex]) | |
| nextIndex = subviews.index(after: nextIndex) | |
| } | |
| // Only add bridge when the run has visible siblings on both sides. | |
| // A collapsed run at an endpoint (at the very start or end) has no | |
| // "new adjacent pair" that needs bridging; normal scale-based spacing is sufficient. | |
| if runStart > subviews.startIndex, nextIndex < subviews.endIndex { | |
| let previousVisible = subviews.index(before: runStart) | |
| let bridgeProgress = 1 - maxRunScale | |
| let halfBridge = | |
| baseSpacingDistance( | |
| previous: subviews[previousVisible], | |
| next: subviews[nextIndex] | |
| ) * bridgeProgress / 2 | |
| spacingAfterValues[previousVisible] += halfBridge | |
| spacingAfterValues[runEnd] += halfBridge | |
| } | |
| index = nextIndex | |
| } | |
| } | |
| /// Adjacent spacing after applying scale. base spacing × min(prevScale, nextScale). | |
| /// | |
| /// Either side collapsing proportionally shrinks spacing — this is the core of | |
| /// "height and spacing converging in sync." | |
| /// In the `visible / collapsed / visible` pattern both sides are compressed to 0, | |
| /// compensated externally by `addBridgeSpacings`. | |
| private func scaledSpacingDistance( | |
| previous: LayoutSubview, | |
| next: LayoutSubview, | |
| previousScale: CGFloat, | |
| nextScale: CGFloat | |
| ) -> CGFloat { | |
| baseSpacingDistance(previous: previous, next: next) | |
| * min(previousScale, nextScale) | |
| } | |
| /// Base spacing without scale. Explicit `spacing` takes priority; otherwise falls | |
| /// back to SwiftUI's default `ViewSpacing.distance(to:along:)`, letting adjacent | |
| /// children's spacing preferences jointly decide. This function returns the "raw distance" | |
| /// shared by bridge computation and `scaledSpacingDistance`. | |
| private func baseSpacingDistance( | |
| previous: LayoutSubview, | |
| next: LayoutSubview | |
| ) -> CGFloat { | |
| let distance = | |
| spacing | |
| ?? previous.spacing.distance( | |
| to: next.spacing, | |
| along: .vertical | |
| ) | |
| guard distance.isFinite else { return 0 } | |
| return max(0, distance) | |
| } | |
| /// Maps "is collapsed" to a continuous scale in 0…1. | |
| /// | |
| /// - Child not opted in: scale is always 1, spacing is not shrunk (degrades to normal VStack). | |
| /// - Opted in and child exposes progress: use progress directly, syncing spacing | |
| /// with the presence animation — this is the main path used by AnimatedPresence. | |
| /// - Opted in but no progress exposed: use height threshold as binary fallback, | |
| /// preventing spacing from never shrinking when the layoutValue contract is incomplete. | |
| private func spacingScale( | |
| forHeight height: CGFloat, | |
| isCollapsible: Bool, | |
| progress: CGFloat? | |
| ) -> CGFloat { | |
| guard isCollapsible else { return 1 } | |
| if let progress, progress.isFinite { | |
| return min(1, max(0, progress)) | |
| } | |
| return height > collapsedThreshold ? 1 : 0 | |
| } | |
| private func sanitized(_ size: CGSize) -> CGSize { | |
| CGSize( | |
| width: size.width.isFinite ? max(0, size.width) : 0, | |
| height: size.height.isFinite ? max(0, size.height) : 0 | |
| ) | |
| } | |
| private func xOrigin(for size: CGSize, in bounds: CGRect) -> CGFloat { | |
| switch alignment { | |
| case .trailing: | |
| return bounds.maxX - size.width | |
| case .center: | |
| return bounds.midX - size.width / 2 | |
| case .leading: | |
| return bounds.minX | |
| } | |
| } | |
| private struct Item { | |
| let size: CGSize | |
| let scale: CGFloat | |
| /// Spacing between this child and the next; 0 for the last child. | |
| /// Already includes scale shrinkage and bridge compensation. Lets sizeThatFits | |
| /// and placeSubviews share the same values; the formula `size.height + spacingAfter` | |
| /// is symmetric across both methods and physically cannot be inconsistent. | |
| let spacingAfter: CGFloat | |
| } | |
| } | |
| /// Marks a child so that when its height collapses to 0, the parent layout treats it | |
| /// as "absent" in spacing calculations. Only layouts that explicitly read `CollapsedIgnorableKey` | |
| /// — such as `CollapsibleSpacingVStack` — will consume it; standard `VStack` does not | |
| /// read layoutValues and is unaffected. | |
| private struct CollapsedIgnorableKey: LayoutValueKey { | |
| nonisolated static let defaultValue = false | |
| } | |
| /// The child exposes its presence progress (0 = collapsed, 1 = expanded) to the parent | |
| /// `CollapsibleSpacingVStack` via this key. Written by `CollapsibleSpacingProgressModifier`; | |
| /// that modifier conforms to `Animatable`, giving the otherwise non-animatable layoutValue | |
| /// indirect per-frame interpolation capability. | |
| private struct CollapsibleSpacingProgressKey: LayoutValueKey { | |
| nonisolated static let defaultValue: CGFloat? = nil | |
| } | |
| /// Key technique: use ViewModifier+Animatable to turn a LayoutValueKey into an animatable channel. | |
| /// | |
| /// LayoutValueKey itself is a static property read during layout passes and cannot be | |
| /// directly interpolated by the SwiftUI animation system. But ViewModifier can conform | |
| /// to `Animatable`: within a `withAnimation` transaction SwiftUI interpolates | |
| /// `animatableData` frame-by-frame and calls `body` each frame, so the `layoutValue` | |
| /// written each frame is the current frame's progress. The parent layout reads layoutValue | |
| /// on each layout pass and gets the per-frame interpolated value. | |
| /// | |
| /// This path lets us lock the spacing collapse and the height collapse to the same | |
| /// `withAnimation` transaction, sharing curve and duration, naturally synchronized. | |
| private struct CollapsibleSpacingProgressModifier: ViewModifier, Animatable { | |
| var progress: CGFloat | |
| var animatableData: CGFloat { | |
| get { progress } | |
| set { progress = newValue } | |
| } | |
| func body(content: Content) -> some View { | |
| content.layoutValue( | |
| key: CollapsibleSpacingProgressKey.self, | |
| value: min(1, max(0, progress)) | |
| ) | |
| } | |
| } | |
| extension View { | |
| /// Causes this view to be treated as absent in spacing calculations by | |
| /// `CollapsibleSpacingVStack` when it collapses to zero height. | |
| /// Has no side effects on parent containers that do not consume `CollapsedIgnorableKey`. | |
| func ignoredWhenCollapsed(_ enabled: Bool = true) -> some View { | |
| layoutValue(key: CollapsedIgnorableKey.self, value: enabled) | |
| } | |
| /// Exposes a 0…1 presence progress to the parent `CollapsibleSpacingVStack` via layoutValue. | |
| /// Typically called internally by components like `AnimatedPresence` that manage their | |
| /// own collapse animation; regular callers do not need to interact with this API directly. | |
| func collapsibleSpacingProgress(_ progress: CGFloat) -> some View { | |
| modifier(CollapsibleSpacingProgressModifier(progress: progress)) | |
| } | |
| } | |
| /// Provides continuous height transitions for "appearing / disappearing / content-size changing" | |
| /// of optional inline data in a row. | |
| /// | |
| /// ## Overall mechanism | |
| /// | |
| /// The component is coordinated by three synchronized timelines: | |
| /// | |
| /// 1. **content timeline** — `displayValue` is decoupled from the external `value`. | |
| /// `value` can be overwritten at any time by external data sync flows; `displayValue` | |
| /// is a retained copy kept by the component for rendering, switched only at the right | |
| /// moment (immediately on appear/update, delayed until collapse completes on disappear). | |
| /// This is the key to "the disappear animation always has render material." | |
| /// | |
| /// 2. **height timeline** — `visibleHeight` is a push-based animatable state, | |
| /// exposed to SwiftUI as `animatableData` by the custom `VisibleHeightLayout`. | |
| /// The content's true intrinsic height is pushed to `handleMeasurement` via | |
| /// `background { GeometryReader }` + a private preference, then `withAnimation` | |
| /// drives `visibleHeight` to the new target. The `nil <-> value` and `value -> newValue` | |
| /// transitions share the same interpolation channel, not relying on SwiftUI's | |
| /// implicit layout animation. | |
| /// | |
| /// 3. **spacing timeline** — `spacingProgress` is updated in sync with `visibleHeight`: | |
| /// assigned directly on first settle, then updated in the same `withAnimation` block | |
| /// for subsequent changes. Progress is exposed to the parent `CollapsibleSpacingVStack` | |
| /// via `.collapsibleSpacingProgress(_:)`. The parent continuously scales adjacent spacing | |
| /// based on this, so the moment collapse to 0 is complete, spacing also hits 0 — | |
| /// no "height hits 0 first, spacing disappears later" two-step feel. | |
| /// | |
| /// ## Key invariants | |
| /// | |
| /// - `visibleHeight` and `spacingProgress` are created and destroyed together: either | |
| /// both assigned immediately (first settle), or both animated within the same transaction. | |
| /// Any code path that updates only one will break synchronization. | |
| /// - `displayValue` retains the last visible payload during collapse, cleared only after | |
| /// collapse completion; `collapseToken` prevents an old completion from erroneously | |
| /// clearing a newly submitted value. | |
| /// - `handleMeasurement` uses `targetHeight` (the most recently "desired" height) rather | |
| /// than `visibleHeight` (the current interpolation midpoint) as the deduplication key — | |
| /// during animation `visibleHeight` changes every frame, so comparing against it would | |
| /// make every frame's measurement look like a "new target," triggering a measurement | |
| /// feedback loop. | |
| struct AnimatedPresence<Value: Equatable, Content: View>: View { | |
| private let value: Value? | |
| private let animation: Animation? | |
| private let contentTransition: ContentTransition | |
| private let content: (Value) -> Content | |
| /// Retained payload used by the render layer. External `value` may be overwritten | |
| /// or cleared mid-animation by a data sync flow; `displayValue` is controlled by the | |
| /// component state machine, ensuring the collapse animation always has material to render. | |
| @State private var displayValue: Value? | |
| /// Animatable state for the external dimensions. Driven by `withAnimation`, working | |
| /// with `VisibleHeightLayout.animatableData` to interpolate external height frame-by-frame. | |
| @State private var visibleHeight: CGFloat = 0 | |
| /// The target intrinsic height from the most recent measurement push, used as the | |
| /// deduplication anchor inside `handleMeasurement`. The comments repeatedly emphasize | |
| /// "compare against target, not visibleHeight" to avoid a measurement feedback loop. | |
| @State private var targetHeight: CGFloat = 0 | |
| /// Presence progress (0 = fully collapsed, 1 = fully expanded). Exposed to the parent | |
| /// layout via `.collapsibleSpacingProgress(_:)` so adjacent spacing scales in sync | |
| /// with height. **Must be updated in sync with `visibleHeight`; animated paths must | |
| /// update within the same `withAnimation` block**. | |
| @State private var spacingProgress: CGFloat = 0 | |
| /// One-time flag for when the component mounts with an existing value: the first | |
| /// measurement should settle directly to natural height without an "expand" animation — | |
| /// otherwise every time a row scrolls into view it would re-enter, which is unintuitive. | |
| @State private var awaitingInitialMeasurement = false | |
| /// Collapse completion anti-corruption token. Incremented on every value change; | |
| /// the completion handler checks whether the token still matches the value at launch | |
| /// time, preventing an old completion from erroneously clearing a new value in the | |
| /// scenario where value is changed again mid-animation. | |
| @State private var collapseToken = 0 | |
| init( | |
| value: Value?, | |
| animation: Animation?, | |
| contentTransition: ContentTransition = .identity, | |
| @ViewBuilder content: @escaping (Value) -> Content | |
| ) { | |
| self.value = value | |
| self.animation = animation | |
| self.contentTransition = contentTransition | |
| self.content = content | |
| } | |
| var body: some View { | |
| // VisibleHeightLayout takes over the external size contract: layout output is | |
| // visibleHeight, content renders at its intrinsic size inside the layout, | |
| // outer .clipped() clips the overflow. | |
| // Collapse is "cut" not "squish" — content does not deform and the background | |
| // measurement continues to produce a stable intrinsic. | |
| VisibleHeightLayout(visibleHeight: visibleHeight) { | |
| if let displayValue { | |
| content(displayValue) | |
| // .fixedSize locks content to its intrinsic height in the vertical | |
| // direction; this modifier and the background GeometryReader are bound | |
| // together: the reader sees exactly this intrinsic size. Wrapping the | |
| // component externally with .frame(height:) or .fixedSize(vertical:) | |
| // will break the measurement chain. | |
| .fixedSize(horizontal: false, vertical: true) | |
| .contentTransition(contentTransition) | |
| .animation(animation, value: displayValue) | |
| .background { | |
| // Measurement push channel: background follows the post-.fixedSize | |
| // intrinsic geometry; when displayValue switches to a different size, | |
| // GeometryReader immediately sees the changed size.height, the preference | |
| // pushes the new value to handleMeasurement, which drives withAnimation. | |
| GeometryReader { proxy in | |
| Color.clear.preference( | |
| key: AnimatedPresenceHeightKey.self, | |
| value: proxy.size.height | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| .clipped() | |
| .onAppear { handleAppearance() } | |
| .onChange(of: value) { _, newValue in handleValueChange(newValue) } | |
| .onPreferenceChange(AnimatedPresenceHeightKey.self) { measured in | |
| handleMeasurement(measured) | |
| } | |
| // Writes spacingProgress into layoutValue via ViewModifier+Animatable, | |
| // giving the otherwise non-animatable LayoutValueKey indirect per-frame | |
| // interpolation capability. The parent CollapsibleSpacingVStack uses this | |
| // to scale adjacent spacing in sync with height; | |
| // see CollapsibleSpacingProgressModifier comments for details. | |
| .collapsibleSpacingProgress(spacingProgress) | |
| } | |
| /// Handles component mount. | |
| /// | |
| /// Sole responsibility: handle the case where a value already exists at mount time. | |
| /// Syncs displayValue to value and sets awaitingInitialMeasurement so that the | |
| /// first measurement to arrive settles directly (rather than "expanding" from 0). | |
| /// Without this, every time a row scrolls into view it would replay the appear animation. | |
| /// | |
| /// If value is nil at mount, nothing is done: displayValue defaults to nil, | |
| /// the state machine stays at the collapsed starting point, waiting for onChange to drive it. | |
| private func handleAppearance() { | |
| if let value, displayValue == nil { | |
| displayValue = value | |
| awaitingInitialMeasurement = true | |
| } | |
| } | |
| /// Handles external value changes — the entry point for the three-timeline coordination. | |
| /// | |
| /// - `nil -> value` / `value -> newValue`: immediately commit the new displayValue. | |
| /// The next measurement to arrive will animate visibleHeight and spacingProgress to | |
| /// the new target. `collapseToken += 1` invalidates any not-yet-fired old collapse | |
| /// completion (scenario: value came back while collapsing). awaitingInitialMeasurement | |
| /// is explicitly reset to ensure runtime changes always take the animated path, | |
| /// even if they occur immediately after onAppear. | |
| /// | |
| /// - `value -> nil`: initiate collapse. visibleHeight and spacingProgress both animate | |
| /// to 0 simultaneously; displayValue is cleared only after animation completes — | |
| /// "disappear animation has render material" is implemented by this step. | |
| /// The completion handler checks collapseToken and current value state to prevent | |
| /// an old path from clearing a new value if value was changed again mid-animation. | |
| /// | |
| /// targetHeight is explicitly reset to 0 in the collapse branch: ensures that on the | |
| /// next appearance the difference between measurement and targetHeight will necessarily | |
| /// be > 0.5, correctly triggering a new animation rather than being blocked by the | |
| /// deduplication guard. | |
| private func handleValueChange(_ newValue: Value?) { | |
| if let newValue { | |
| awaitingInitialMeasurement = false | |
| displayValue = newValue | |
| collapseToken += 1 | |
| } else { | |
| let token = collapseToken + 1 | |
| collapseToken = token | |
| targetHeight = 0 | |
| withAnimation(animation, completionCriteria: .logicallyComplete) { | |
| visibleHeight = 0 | |
| spacingProgress = 0 | |
| } completion: { | |
| guard collapseToken == token, value == nil else { return } | |
| displayValue = nil | |
| } | |
| } | |
| } | |
| /// Receives intrinsic height pushed up by the background `GeometryReader`, | |
| /// driving visibleHeight and spacingProgress into a new animation. | |
| /// | |
| /// Three guards each serve a purpose: | |
| /// | |
| /// 1. `measured.isFinite` filters NaN / Inf — SwiftUI may briefly return non-finite | |
| /// values during certain layout transition frames; must avoid polluting the animation target. | |
| /// 2. `displayValue != nil`: the subview does not exist when not rendered; but | |
| /// onPreferenceChange still fires once with the default value at the moment content | |
| /// disappears — needs to be ignored. | |
| /// 3. `value != nil`: during a collapse animation, handleValueChange has already pushed | |
| /// visibleHeight toward 0. At this point measurement still reports the true intrinsic | |
| /// per displayValue (displayValue is still around during collapse) and should not | |
| /// pull visibleHeight back up. | |
| /// | |
| /// `targetHeight` rather than `visibleHeight` as the deduplication anchor: during animation | |
| /// visibleHeight is an interpolated midpoint; comparing against it would make every frame's | |
| /// measurement look like a "new target," triggering fresh withAnimation calls and ultimately | |
| /// causing a measurement feedback loop. targetHeight only updates when we truly "want to | |
| /// reach a new value," making it a stable anchor. | |
| /// | |
| /// Both state values (visibleHeight, spacingProgress) must be updated together: first settle | |
| /// assigns directly, the animated path puts both in the same withAnimation block, sharing | |
| /// the same animation curve / duration, naturally synchronized — the key constraint | |
| /// emphasized in the type-level invariants. | |
| private func handleMeasurement(_ measured: CGFloat) { | |
| guard | |
| measured.isFinite, | |
| displayValue != nil | |
| else { return } | |
| guard value != nil else { return } | |
| guard abs(measured - targetHeight) > 0.5 else { return } | |
| targetHeight = measured | |
| if awaitingInitialMeasurement { | |
| // First settle: both state values assigned directly, no withAnimation. | |
| // Avoids playing an "expand" animation the moment a row appears. | |
| awaitingInitialMeasurement = false | |
| visibleHeight = measured | |
| spacingProgress = 1 | |
| } else { | |
| withAnimation(animation) { | |
| visibleHeight = measured | |
| spacingProgress = 1 | |
| } | |
| } | |
| } | |
| } | |
| /// Inner Layout for AnimatedPresence — uses visibleHeight as the animatable external size, | |
| /// but does **not** constrain the subview's render size. | |
| /// | |
| /// Key decomposition: | |
| /// | |
| /// - `sizeThatFits` reports `(intrinsic.width, visibleHeight)` to the parent container: | |
| /// the external view is the height the layout currently wants to occupy, | |
| /// driven frame-by-frame via animatableData by SwiftUI. | |
| /// - `placeSubviews` places the subview using **intrinsic height** as the proposal: | |
| /// the subview renders at its natural size without being compressed by visibleHeight. | |
| /// The difference between the two heights (external height vs subview height) is | |
| /// handled by AnimatedPresence's outer `.clipped()` — clipping the portion that | |
| /// overflows visibleHeight. | |
| /// | |
| /// This strategy makes collapse visually a "reveal/clip from bottom up" rather than | |
| /// "content squishing," and the subview's intrinsic remains stable during animation, | |
| /// keeping the background GeometryReader measurement uncontaminated by the layout height change. | |
| private struct VisibleHeightLayout: Layout { | |
| var visibleHeight: CGFloat | |
| /// Exposes visibleHeight as animatableData: within a `withAnimation` transaction, | |
| /// SwiftUI calls sizeThatFits + placeSubviews each frame with a new interpolated value — | |
| /// this is the underlying mechanism for smooth height transitions. | |
| var animatableData: CGFloat { | |
| get { visibleHeight } | |
| set { visibleHeight = newValue } | |
| } | |
| func sizeThatFits( | |
| proposal: ProposedViewSize, | |
| subviews: Subviews, | |
| cache: inout () | |
| ) -> CGSize { | |
| guard let subview = subviews.first else { return .zero } | |
| // Query the subview's true intrinsic at unconstrained height; must not pass | |
| // proposal.height down, otherwise the subview would compress along with the | |
| // currently-collapsing visibleHeight, distorting both measurement and rendering. | |
| let intrinsic = subview.sizeThatFits( | |
| ProposedViewSize(width: proposal.width, height: nil) | |
| ) | |
| return CGSize( | |
| width: intrinsic.width.isFinite ? intrinsic.width : 0, | |
| // visibleHeight may briefly go slightly negative during animation | |
| // (spring curve overshoot); max(0, ...) ensures we never report a | |
| // negative size to the parent container. | |
| height: max(0, visibleHeight) | |
| ) | |
| } | |
| func placeSubviews( | |
| in bounds: CGRect, | |
| proposal: ProposedViewSize, | |
| subviews: Subviews, | |
| cache: inout () | |
| ) { | |
| guard let subview = subviews.first else { return } | |
| let intrinsic = subview.sizeThatFits( | |
| ProposedViewSize(width: bounds.width, height: nil) | |
| ) | |
| // Place with intrinsic proposal: subview renders at its natural size. Outer .clipped() | |
| // clips overflow beyond visibleHeight. During collapse the subview's content is not | |
| // compressed, and the background measurement continues to produce a stable intrinsic | |
| // (key: measurement is not contaminated by the visible area during collapse). | |
| subview.place( | |
| at: bounds.origin, | |
| anchor: .topLeading, | |
| proposal: ProposedViewSize(width: intrinsic.width, height: intrinsic.height) | |
| ) | |
| } | |
| /// Does not force spacing to `.zero` here to eliminate parent implicit spacing. | |
| /// | |
| /// Reason: `ViewSpacing` itself is not animatable; a binary switch would cause spacing | |
| /// to hard-jump on the frame visibleHeight crosses the threshold, violating the design | |
| /// goal of "height and spacing converge in sync." Continuous spacing control should be | |
| /// handled by the parent layout via the progress channel — see | |
| /// `CollapsibleSpacingVStack` + `collapsibleSpacingProgress`. | |
| /// | |
| /// Returns the subview's own spacing so that this layout, when appearing as a subview, | |
| /// transparently passes through the child's spacing preferences. | |
| func spacing(subviews: Subviews, cache: inout ()) -> ViewSpacing { | |
| subviews.first?.spacing ?? .zero | |
| } | |
| } | |
| /// Content pushes its own intrinsic height to the state machine's private channel via | |
| /// a background `GeometryReader`. `fileprivate` isolation ensures it cannot be read by | |
| /// code outside the component, preventing it from spreading as a general-purpose frame | |
| /// measurement API — issue 176 explicitly constrains this. | |
| /// | |
| /// `reduce` uses `value = nextValue()` rather than union: there is only one measurement | |
| /// source (single subview), so subsequent values directly overwrite; no need to aggregate | |
| /// multiple candidates. | |
| private struct AnimatedPresenceHeightKey: PreferenceKey { | |
| static var defaultValue: CGFloat { 0 } | |
| static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { | |
| value = nextValue() | |
| } | |
| } | |
| #if DEBUG | |
| private struct AnimatedPresencePreviewRow: View { | |
| let title: String | |
| let detail: String? | |
| let animation: Animation | |
| init( | |
| title: String, | |
| detail: String?, | |
| animation: Animation = .smooth | |
| ) { | |
| self.title = title | |
| self.detail = detail | |
| self.animation = animation | |
| } | |
| var body: some View { | |
| CollapsibleSpacingVStack(alignment: .leading, spacing: 4) { | |
| Text(verbatim: title) | |
| .lineLimit(1) | |
| .font(.headline) | |
| AnimatedPresence( | |
| value: detail, | |
| animation: animation, | |
| contentTransition: .opacity | |
| ) { detail in | |
| Text(verbatim: detail) | |
| .font(.caption) | |
| .foregroundStyle(.secondary) | |
| .lineLimit(3) | |
| } | |
| .ignoredWhenCollapsed() | |
| Text("Footer") | |
| } | |
| .frame(maxWidth: .infinity, alignment: .leading) | |
| } | |
| } | |
| private struct AnimatedPresencePreview: View { | |
| @State private var isVisible = true | |
| @State private var sampleIndex = 0 | |
| private let samples = [ | |
| "Short change.", | |
| "A much longer change that wraps across several lines and forces the row to recalculate its height without the explicit visible-height channel.", | |
| "Medium change that still uses the same plain Text lifecycle.", | |
| ] | |
| var body: some View { | |
| List { | |
| AnimatedPresencePreviewRow( | |
| title: "Smooth motion timing", | |
| detail: isVisible ? samples[sampleIndex] : nil, | |
| animation: .smooth | |
| ) | |
| Button(isVisible ? "Hide" : "Show") { | |
| isVisible.toggle() | |
| } | |
| Button("Next value") { | |
| isVisible = true | |
| sampleIndex = (sampleIndex + 1) % samples.count | |
| } | |
| } | |
| } | |
| } | |
| private struct SwiftUINativeHeightChangePreview: View { | |
| @State private var isVisible = true | |
| @State private var sampleIndex = 0 | |
| private let optionalDetail = | |
| "Native SwiftUI removes and inserts this payload directly. In List, row height tends to update as a discrete layout change." | |
| private let samples = [ | |
| "Short native change.", | |
| "A much longer native change that wraps across several lines and forces the row to recalculate its height without the explicit visible-height channel.", | |
| "Medium native change that still uses the same plain Text lifecycle.", | |
| ] | |
| var body: some View { | |
| List { | |
| VStack(alignment: .leading) { | |
| Text("Native on/off") | |
| .lineLimit(1) | |
| .font(.headline) | |
| if isVisible { | |
| Text(verbatim: optionalDetail) | |
| .font(.caption) | |
| .foregroundStyle(.secondary) | |
| .lineLimit(3) | |
| .transition(.opacity) | |
| } | |
| Text("Footer") | |
| .font(.caption) | |
| .foregroundStyle(.secondary) | |
| } | |
| .frame(maxWidth: .infinity, alignment: .leading) | |
| VStack(alignment: .leading) { | |
| Text("Native Change") | |
| .lineLimit(1) | |
| .font(.headline) | |
| Text(verbatim: samples[sampleIndex]) | |
| .font(.caption) | |
| .foregroundStyle(.secondary) | |
| .lineLimit(3) | |
| .contentTransition(.opacity) | |
| .animation(.smooth, value: sampleIndex) | |
| Text("Footer") | |
| .font(.caption) | |
| .foregroundStyle(.secondary) | |
| } | |
| .frame(maxWidth: .infinity, alignment: .leading) | |
| Button(isVisible ? "Hide" : "Show") { | |
| withAnimation(.smooth) { | |
| isVisible.toggle() | |
| } | |
| } | |
| Button("Next Change") { | |
| withAnimation(.smooth) { | |
| sampleIndex = (sampleIndex + 1) % samples.count | |
| } | |
| } | |
| } | |
| } | |
| } | |
| private struct AnimatedPresenceNumericChangePreview: View { | |
| @State private var sampleIndex = 0 | |
| private let samples = [ | |
| 98, | |
| 104, | |
| 1_248, | |
| 99_300, | |
| 42, | |
| ] | |
| var body: some View { | |
| List { | |
| VStack(alignment: .leading, spacing: 4) { | |
| Text("Numeric content") | |
| .lineLimit(1) | |
| .font(.headline) | |
| AnimatedPresence( | |
| value: samples[sampleIndex], | |
| animation: .smooth, | |
| contentTransition: .numericText() | |
| ) { value in | |
| Text(value, format: .number) | |
| .font(.title2.monospacedDigit()) | |
| .foregroundStyle(.secondary) | |
| .lineLimit(1) | |
| } | |
| } | |
| .frame(maxWidth: .infinity, alignment: .leading) | |
| Button("Next number") { | |
| sampleIndex = (sampleIndex + 1) % samples.count | |
| } | |
| } | |
| } | |
| } | |
| // Stack scenario: when detail is nil, no residual spacing should remain. | |
| private struct AnimatedPresenceStackPreview: View { | |
| @State private var isVisible = false | |
| var body: some View { | |
| CollapsibleSpacingVStack(alignment: .leading) { | |
| Text("Header") | |
| .font(.headline) | |
| AnimatedPresencePreviewRow( | |
| title: "Inline in VStack", | |
| detail: isVisible | |
| ? "Should collapse to truly zero height when hidden, leaving no residual space between siblings." | |
| : nil | |
| ) | |
| .ignoredWhenCollapsed() | |
| Text("Footer immediately follows when collapsed.") | |
| .font(.caption) | |
| .foregroundStyle(.secondary) | |
| Button(isVisible ? "Hide" : "Show") { | |
| isVisible.toggle() | |
| } | |
| } | |
| .padding() | |
| } | |
| } | |
| #Preview("AnimatedPresence - smooth motion") { | |
| AnimatedPresencePreview() | |
| } | |
| #Preview("SwiftUI native height change") { | |
| SwiftUINativeHeightChangePreview() | |
| } | |
| #Preview("AnimatedPresence - numeric") { | |
| AnimatedPresenceNumericChangePreview() | |
| } | |
| #Preview("AnimatedPresence - VStack") { | |
| AnimatedPresenceStackPreview() | |
| } | |
| #endif |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment