Skip to content
Stack Collapse preview
Rendered from the SwiftUI source on this page.

Stack Collapse

From syxUI — written for both platforms, not translated between them.

Interactive

Overlapping alert cards fan into a shallow depth stack and cascade apart on tap, capped at a +N more chip.

Banners · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • banner
  • stack
  • notification
  • collapse
  • cascade
  • depth
  • overlay

The actual source

StackCollapse.swift
// Stack Collapse · syxUI · https://syxui.dev/components/banner-stack-collapse
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

// MARK: - Motion

/// The spring pairs this component uses, named so the intent survives an edit.
///
/// `(response, dampingFraction)` in SwiftUI's terms. At unit mass the Dart
/// equivalents are `stiffness = (2π/response)²` and `damping = 2ζ(2π/response)`,
/// so the Flutter file runs the same springs rather than an approximation.
private let expandSpring: (response: Double, damping: Double) = (0.40, 0.75)
private let collapseSpring: (response: Double, damping: Double) = (0.34, 0.85)
private let exitSpring: (response: Double, damping: Double) = (0.50, 0.85)
private let returnSpring: (response: Double, damping: Double) = (0.34, 0.85)
private let promoteSpring: (response: Double, damping: Double) = (0.42, 0.78)
private let hoverSpring: (response: Double, damping: Double) = (0.30, 0.82)

/// How long a spring takes to be visually settled: `6/(ζω₀)`.
///
/// Not the commonly quoted `4/(ζω₀)` — at 4 the analytic curve below is still
/// ~2 % high at `p = 1`, and 2 % of a 76 pt card is a visible misalignment in a
/// still frame. At 6 it lands inside 0.4 %.
private func settleTime(_ spring: (response: Double, damping: Double)) -> Double {
    6 / (spring.damping * (2 * Double.pi / spring.response))
}

/// The analytic underdamped step response, so a `progress` override samples the
/// same curve the live path actually runs.
///
/// The live path hands gesture velocity to real springs, which cannot be sampled
/// from a `Double`. This is the resolution: one geometry, two drives.
private func springShape(_ p: Double, _ spring: (response: Double, damping: Double)) -> Double {
    let clamped = min(max(p, 0), 1)
    let omega = 2 * Double.pi / spring.response
    let zeta = min(max(spring.damping, 0.05), 0.999)
    let damped = omega * (1 - zeta * zeta).squareRoot()
    let t = clamped * settleTime(spring)
    return 1 - exp(-zeta * omega * t) * (cos(damped * t) + (zeta * omega / damped) * sin(damped * t))
}

/// A spring that continues at the finger's speed instead of restarting.
///
/// `interpolatingSpring` is the only SwiftUI spring that takes a release
/// velocity, and `initialVelocity` is **normalised and signed relative to the
/// remaining travel**: 1.0 means "cover what is left in one second", negative
/// means the finger is still moving away from the target. Passing raw pt/s
/// throws the card clean off the screen.
private func handOff(
    _ spring: (response: Double, damping: Double),
    velocity: Double,
    travel: Double
) -> Animation {
    let omega = 2 * Double.pi / spring.response
    return .interpolatingSpring(
        stiffness: omega * omega,
        damping: 2 * spring.damping * omega,
        initialVelocity: abs(travel) < 1 ? 0 : velocity / travel
    )
}

// MARK: - Content

/// One row of the stack. Swap `StackAlert.sample` for your own feed.
struct StackAlert: Identifiable {
    let id: Int
    let title: String
    let detail: String
    let glyph: String

    static let sample: [StackAlert] = [
        StackAlert(id: 0, title: "3 payments failed",
                   detail: "Retry queued · 2 min ago", glyph: "creditcard.fill"),
        StackAlert(id: 1, title: "Build #4181 failed",
                   detail: "web · main · 40s", glyph: "hammer.fill"),
        StackAlert(id: 2, title: "Storage almost full",
                   detail: "92% of 100 GB used", glyph: "externaldrive.fill"),
        StackAlert(id: 3, title: "TLS cert expires soon",
                   detail: "api.example.com · 6 days", glyph: "lock.fill"),
        StackAlert(id: 4, title: "Webhook retries paused",
                   detail: "12 events waiting", glyph: "arrow.triangle.2.circlepath"),
    ]
}

// MARK: - Component

/// Overlapping alert cards that fan into a shallow depth stack and cascade
/// apart on tap, capped at "+N more".
///
/// Each layer behind the front card sits `sliverOffset` lower, is scaled down by
/// `depthScaleStep` from its top edge and faded by 0.18 — the offset-and-falloff
/// shape a collapsed toast stack has. Anything past `visibleDepth` becomes a
/// single chip, so the view count is flat no matter how many alerts queue up.
///
/// This is an **overlay**: the box stays the height of the collapsed fan and the
/// spread cards draw over whatever is below, the way a notification group does.
/// Nothing under it ever jumps.
struct StackCollapse: View {

    // MARK: Controls

    /// Slivers behind the front card before the rest fold into "+N more".
    var visibleDepth: Int = 2
    var sliverOffset: CGFloat = 8
    var depthScaleStep: Double = 0.05
    var cornerRadius: CGFloat = 16
    var surface: Color = Color(red: 0.122, green: 0.122, blue: 0.137)
    var accent: Color = Color(red: 0.039, green: 0.518, blue: 1.0)

    // MARK: Shape and feel

    var cardHeight: CGFloat = 76
    var expandedGap: CGFloat = 10
    /// Per-card delay down the cascade, ms. One haptic for the whole spread.
    var cascade: Double = 40
    /// How long the survivors' reflow trails the dismissed card's exit, ms.
    /// The lag is what makes the promotion read as physical rather than as a
    /// list re-rendering.
    var reflowDelay: Double = 70
    /// Release speed that commits a dismissal on its own, pt/s.
    var dismissVelocity: CGFloat = 640
    /// Fraction of the card's width that commits on distance alone.
    var commitFraction: CGFloat = 0.34
    var alerts: [StackAlert] = StackAlert.sample

    /// Preview override. 0 is fully stacked, 1 fully spread.
    var progress: Double? = nil

    // MARK: State

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var spread: Double = 0
    @State private var offsetX: [Int: CGFloat] = [:]
    @State private var exits: Set<Int> = []
    @State private var dragging = false
    @State private var armed = false
    @State private var release = Release()
    @State private var hovering = false
    @State private var textFloor: CGFloat = 0
    @State private var chipFloor: CGFloat = 0

    /// What the last finger-lift asked for. Read when the keyed animations
    /// resolve, which is how one state change drives two different springs.
    private struct Release: Equatable {
        var velocity: Double = 0
        var travel: Double = 1
        var committed = false
    }

    private struct Slot: Identifiable {
        let alert: StackAlert
        let rank: Int
        let leaving: Bool
        var id: Int { alert.id }
    }

    private struct TextWidthKey: PreferenceKey {
        static let defaultValue: CGFloat = 0
        static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
            value = max(value, nextValue())
        }
    }

    private struct ChipWidthKey: PreferenceKey {
        static let defaultValue: CGFloat = 0
        static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
            value = max(value, nextValue())
        }
    }

    // MARK: Derived geometry

    private var rowInset: CGFloat { 14 }
    private var badgeSize: CGFloat { 30 }
    private var badgeGap: CGFloat { 12 }
    private var chipGap: CGFloat { 10 }
    private var titleColor: Color { Color(white: 0.961) }
    private var detailColor: Color { Color(white: 0.60) }

    private var live: [StackAlert] { alerts.filter { !exits.contains($0.id) } }
    private var depth: Int { max(0, min(visibleDepth, live.count - 1)) }
    private var renderedCount: Int { min(live.count, depth + 1) }
    private var overflow: Int { max(0, live.count - renderedCount) }

    /// The box reserves room for the full `visibleDepth` and never changes, so
    /// dismissing a card cannot reflow the page below. Only the stack's own
    /// members move.
    private var reservedDepth: Int { max(0, min(visibleDepth, alerts.count - 1)) }
    private var boxHeight: CGFloat {
        sliverOffset * CGFloat(reservedDepth) + cardHeight
    }

    /// A Mac cursor gets the same affordance a finger does: the fan opens a
    /// little to say it opens.
    private var peek: CGFloat { hovering && spread < 0.5 && progress == nil ? 3 : 0 }

    private func stackedY(_ rank: Int) -> CGFloat { sliverOffset * CGFloat(rank) }
    private func expandedY(_ rank: Int) -> CGFloat { (cardHeight + expandedGap) * CGFloat(rank) }

    /// 0 stacked, 1 in its own slot. The live path lets SwiftUI interpolate a
    /// plain `spread` with the per-card spring below; the preview path walks the
    /// analytic curve so a still can be sampled from one `Double`.
    private func localSpread(_ rank: Int) -> Double {
        guard let progress else { return spread }
        let stagger = cascade / 1000 / settleTime(expandSpring)
        let span = max(1 - stagger * Double(max(renderedCount - 1, 1)), 0.25)
        return springShape((progress - stagger * Double(rank)) / span, expandSpring)
    }

    private var slots: [Slot] {
        var out: [Slot] = []
        var rank = 0
        for alert in alerts {
            if exits.contains(alert.id) {
                // Kept in the list so its flight animates from where the finger
                // left it instead of popping in at the target.
                out.append(Slot(alert: alert, rank: 0, leaving: true))
            } else if rank <= depth {
                out.append(Slot(alert: alert, rank: rank, leaving: false))
                rank += 1
            }
        }
        return out
    }

    // MARK: Body

    var body: some View {
        GeometryReader { proxy in
            let width = proxy.size.width
            ZStack(alignment: .top) {
                ForEach(slots) { slot in card(slot, width: width) }
                footer(width: width)
            }
            .frame(width: width, height: boxHeight, alignment: .top)
        }
        .frame(height: boxHeight)
        .background(alignment: .topLeading) { probes.hidden() }
        .onPreferenceChange(TextWidthKey.self) { textFloor = $0 }
        .onPreferenceChange(ChipWidthKey.self) { chipFloor = $0 }
        .onContinuousHover(coordinateSpace: .local) { phase in
            hovering = if case .active = phase { true } else { false }
        }
        .sensoryFeedback(.selection, trigger: spread > 0.5)
        .sensoryFeedback(trigger: armed) { _, now in
            now ? .impact(weight: .light, intensity: 0.4) : nil
        }
    }

    // MARK: Cards

    private func card(_ slot: Slot, width: CGFloat) -> some View {
        let rank = slot.rank
        let t = slot.leaving ? min(spread, 1) : localSpread(rank)
        let dx = offsetX[slot.alert.id] ?? 0
        // The card fades as it travels, so the fade is velocity-coupled for
        // free: a hard flick reaches transparent sooner than a slow drag.
        let fade = min(max(abs(dx) / max(width * 0.62, 1), 0), 1)
        let scale = 1 - depthScaleStep * Double(rank) * (1 - t)
        let y = (stackedY(rank) + peek * CGFloat(rank)) * CGFloat(1 - t)
            + expandedY(rank) * CGFloat(t)
        // Alpha alone is the wrong falloff for a card of unknown-background
        // provenance: a dark card fading on a light page recedes *toward* the
        // page, and on a dark page it disappears. So each layer back is a
        // lifted surface — the palette's own idea of depth — and carries only a
        // trace of alpha behind that.
        let dim = 1 - 0.06 * Double(rank) * (1 - t)
        let lift = 0.09 * Double(rank) * (1 - t)
        // One tight shadow on the front card to lift it off the layers, one
        // wider one under the deepest, and nothing in between: the collapsed
        // stack is one object and casts one ground shadow. Both ease to the
        // front card's own values as the cards become separate objects.
        let deep = rank == depth
        let shadowAlpha = (rank == 0 ? 0.18 : (deep ? 0.16 : 0)) * (1 - t) + 0.18 * t
        let shadowBlur = (rank == 0 ? 8.0 : 12.0) * (1 - t) + 8 * t
        let shadowDrop = (rank == 0 ? 3.0 : 6.0) * (1 - t) + 3 * t
        let isFront = !slot.leaving && rank == 0

        return chrome(slot.alert, rank: rank, width: width, spread: t, lift: lift)
            .shadow(color: Color.black.opacity(shadowAlpha),
                    radius: CGFloat(shadowBlur), y: CGFloat(shadowDrop))
            // Width only, anchored at the top edge: each layer keeps its full
            // height, so the lip it shows below the card in front is exactly
            // `sliverOffset` rather than that minus the height it lost.
            .scaleEffect(x: CGFloat(scale), y: 1, anchor: .top)
            .opacity(dim * (1 - Double(fade)))
            .offset(x: dx, y: y)
            .animation(dragging ? nil : releaseAnimation(leaving: slot.leaving), value: offsetX)
            .animation(cascadeAnimation(rank), value: spread)
            .animation(reduceMotion ? nil : spring(hoverSpring), value: hovering)
            .zIndex(slot.leaving ? 100 : Double(renderedCount - rank))
            .allowsHitTesting(!slot.leaving)
            .contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
            .gesture(dragGesture(slot.alert, width: width), including: isFront ? .all : .subviews)
            .onTapGesture { toggle() }
            .accessibilityElement(children: .ignore)
            .accessibilityLabel("\(slot.alert.title). \(slot.alert.detail)")
            .accessibilityAddTraits(isFront ? .isButton : [])
            .accessibilityHint(isFront
                ? (spread > 0.5 ? "Collapses the stack" : "Expands \(live.count) alerts")
                : "")
            .accessibilityHidden(slot.leaving || (rank > 0 && spread < 0.5))
    }

    /// Surface, hairline and clip. Split out because one chain from row to
    /// shadow is more than the type checker will infer in reasonable time.
    private func chrome(
        _ alert: StackAlert,
        rank: Int,
        width: CGFloat,
        spread t: Double,
        lift: Double
    ) -> some View {
        let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
        let edge: Double = 0.07 + 0.09 * Double(rank) * (1 - t)
        return row(alert, rank: rank, width: width, spread: t)
            .frame(width: width, height: cardHeight, alignment: .leading)
            .background(ZStack {
                shape.fill(surface)
                shape.fill(Color(white: 1).opacity(lift))
            })
            // Clip inside, shadow outside: a clip on a shadowed node eats the
            // shadow, and `.clipped()` would let the fill through the corner.
            .clipShape(shape)
            .overlay(shape.strokeBorder(Color(white: 1).opacity(edge), lineWidth: 0.6))
    }

    private func row(
        _ alert: StackAlert,
        rank: Int,
        width: CGFloat,
        spread t: Double
    ) -> some View {
        HStack(spacing: badgeGap) {
            Image(systemName: alert.glyph)
                .font(.system(size: 14, weight: .semibold))
                .foregroundStyle(accent)
                .frame(width: badgeSize, height: badgeSize)
                .background(accent.opacity(0.16), in: Circle())

            VStack(alignment: .leading, spacing: 3) {
                Text(alert.title)
                    .font(.system(size: 15, weight: .semibold))
                    .foregroundStyle(titleColor)
                    .lineLimit(1)
                Text(alert.detail)
                    .font(.system(size: 12).monospacedDigit())
                    .foregroundStyle(detailColor)
                    .lineLimit(1)
            }

            Spacer(minLength: chipGap)

            if rank == 0 && overflow > 0 && fitsChip(width: width) {
                // Fades rather than leaves the row, so the title column never
                // reflows on the way open.
                pill("+\(overflow) more").opacity(1 - min(t / 0.30, 1))
            }
        }
        .padding(.horizontal, rowInset)
    }

    private func pill(_ label: String) -> some View {
        Text(label)
            .font(.system(size: 12, weight: .medium).monospacedDigit())
            .foregroundStyle(accent)
            .padding(.horizontal, 9)
            .padding(.vertical, 5)
            .background(accent.opacity(0.16), in: Capsule())
            .fixedSize()
    }

    /// Once the cards are in their own slots the count has to live somewhere,
    /// so the chip hands off to a pill under the last card.
    private func footer(width: CGFloat) -> some View {
        let last = max(renderedCount - 1, 0)
        let t = localSpread(last)
        let reveal = min(max((t - 0.55) / 0.45, 0), 1)
        return pill("+\(overflow) more")
            .opacity(overflow > 0 ? reveal : 0)
            .offset(y: expandedY(last) + cardHeight + expandedGap + 6 * CGFloat(1 - reveal))
            .animation(cascadeAnimation(last), value: spread)
            .allowsHitTesting(false)
            .accessibilityHidden(true)
    }

    /// The guard against the defect this category ships most often. The chip is
    /// the element that yields, never the copy: if the measured column plus the
    /// chip does not fit, the chip goes and the title keeps its full width.
    private func fitsChip(width: CGFloat) -> Bool {
        width - rowInset * 2 - badgeSize - badgeGap - chipGap - chipFloor >= textFloor
    }

    /// Measured outside the constrained row — `.fixedSize()` makes each probe
    /// report its natural width whatever the row proposes — so the card knows
    /// what the copy needs before it can ever squeeze it. Only the longest
    /// string of each kind is measured, so this is three hidden text runs, not
    /// two per alert.
    private var probes: some View {
        VStack(alignment: .leading, spacing: 0) {
            Text(longest { $0.title })
                .font(.system(size: 15, weight: .semibold))
                .fixedSize()
                .measured(TextWidthKey.self)
            Text(longest { $0.detail })
                .font(.system(size: 12).monospacedDigit())
                .fixedSize()
                .measured(TextWidthKey.self)
            pill("+\(max(overflow, 1)) more")
                .measured(ChipWidthKey.self)
        }
    }

    private func longest(_ field: (StackAlert) -> String) -> String {
        alerts.map(field).max(by: { $0.count < $1.count }) ?? ""
    }

    // MARK: Interaction

    private func toggle() {
        guard progress == nil else { return }
        spread = spread > 0.5 ? 0 : 1
    }

    private func dragGesture(_ alert: StackAlert, width: CGFloat) -> some Gesture {
        DragGesture(minimumDistance: 6)
            .onChanged { value in
                guard progress == nil, alert.id == live.first?.id else { return }
                dragging = true
                offsetX[alert.id] = value.translation.width
                let threshold = width * commitFraction
                // Latched: re-arm only well below the line, or a finger resting
                // on it buzzes on every frame.
                if abs(value.translation.width) >= threshold {
                    armed = true
                } else if abs(value.translation.width) < threshold * 0.85 {
                    armed = false
                }
            }
            .onEnded { value in
                guard progress == nil, alert.id == live.first?.id else { return }
                let dx = value.translation.width
                let velocity = value.velocity.width
                // Decide on the projected stop, not where the finger stopped.
                // 0.20 s of coasting — a banner is a control-scale throw, not a
                // page-scale one.
                let projected = dx + velocity * 0.20
                let commits = abs(dx) >= width * commitFraction
                    || abs(projected) >= width * 0.5
                    || abs(velocity) >= dismissVelocity

                dragging = false
                armed = false

                if commits {
                    let target = (dx < 0 || (dx == 0 && velocity < 0))
                        ? -(width + 48)
                        : (width + 48)
                    release = Release(velocity: Double(velocity),
                                      travel: Double(target - dx),
                                      committed: true)
                    exits.insert(alert.id)
                    offsetX[alert.id] = target
                } else {
                    // A half-flick that does not commit should still look
                    // thrown, so the spring-back carries the same velocity.
                    release = Release(velocity: Double(velocity),
                                      travel: Double(-dx),
                                      committed: false)
                    offsetX[alert.id] = 0
                }
            }
    }

    // MARK: Animations

    private func spring(_ pair: (response: Double, damping: Double)) -> Animation {
        .spring(response: pair.response, dampingFraction: pair.damping)
    }

    /// One state change, two springs. The dismissed card leaves on the release
    /// velocity; its neighbours promote on a *later* spring, so the reflow
    /// trails the exit instead of racing it.
    private func releaseAnimation(leaving: Bool) -> Animation? {
        guard !reduceMotion else { return nil }
        guard release.committed else {
            return handOff(returnSpring, velocity: release.velocity, travel: release.travel)
        }
        return leaving
            ? handOff(exitSpring, velocity: release.velocity, travel: release.travel)
            : spring(promoteSpring).delay(reflowDelay / 1000)
    }

    /// Front-to-back opening, back-to-front closing. The same order both ways
    /// reads like a rewind rather than a stack folding up.
    private func cascadeAnimation(_ rank: Int) -> Animation? {
        guard !reduceMotion else { return nil }
        let opening = spread > 0.5
        let order = opening ? rank : max(renderedCount - 1 - rank, 0)
        return spring(opening ? expandSpring : collapseSpring)
            .delay(cascade / 1000 * Double(order))
    }
}

private extension View {
    /// Reports this view's own width up the preference tree.
    func measured<K: PreferenceKey>(_ key: K.Type) -> some View where K.Value == CGFloat {
        background(
            GeometryReader { proxy in
                Color.clear.preference(key: key, value: proxy.size.width)
            }
        )
    }
}

#Preview {
    StackCollapse()
        .padding(16)
        .frame(width: 420, height: 240)
}
iOS 17 · No dependencies

SwiftUI note. Overlay: the box reserves the full `visibleDepth` height and never changes, so the spread cards draw over the content below and nothing on the page jumps — wrap it in an animated `.frame(height:)` of your own if you want the page to displace instead. When the container narrows it drops the chip rather than truncating a title, so keep your own copy inside the measured column. `progress` drives the whole cascade with no touch, which is what the catalog still samples.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27