Skip to content
Baton Pass preview
An animated render of the SwiftUI source on this page.

Baton Pass

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

Interactive

A single-open accordion where closing one row and opening another is one motion, not two.

Accordions · SwiftUI · Flutter · iOS 17 · Flutter 3.27

  • accordion
  • disclosure
  • faq
  • single-open
  • handoff
  • spring
  • interruptible

The actual source

DiscloseBatonPass.swift
// Baton Pass · syxUI · https://syxui.dev/components/disclose-baton-pass
// Free in any project. Keep this line and credit syxUI where a person can read it.

import SwiftUI

/// A single-open accordion where closing one row and opening another is one
/// motion rather than two.
///
/// Nothing here is sequenced and nothing waits: a tap retargets both panels in
/// the same instant. The one leaving rides a shorter spring than the one
/// arriving, so the close outruns the open and the card's height dips through a
/// shallow trough instead of bulging to hold two panels at once. The whole
/// effect is the ratio `closeResponse / openResponse` — which is why both are
/// controls.
///
/// Because there is no queue, a third tap mid-handoff is free. Every affected
/// row simply picks up a new spring from its current height and current
/// velocity, and the chevrons cannot disagree with the panels because both are
/// read off the same scalar.
struct DiscloseBatonPass: View {
    var accent: Color = Color(red: 0.184, green: 0.435, blue: 0.929)
    var surface: Color = Color(white: 1.0)
    var textColor: Color = Color(red: 0.078, green: 0.086, blue: 0.11)
    /// Seconds for the panel arriving. Opening carries the information, so it
    /// gets the time.
    var openResponse: Double = 0.40
    /// Seconds for the panel leaving. Kept under `openResponse` because the
    /// reader has already decided; a slow close feels like the interface
    /// arguing with the tap.
    var closeResponse: Double = 0.28
    /// One row per line, `question|answer`.
    var items: String = "How do I cancel?|From Settings → Subscription, any time.\nCan I get a refund?|Within 14 days of purchase.\nIs there a student rate?|50% off with a valid .edu address.\nDo you offer teams?|Team plans start at five seats."
    /// Preview override. When set, the component renders the handoff from the
    /// first row to the third at that point of its drive instead of answering
    /// taps — `progress` is the arriving panel's own height fraction.
    var progress: Double? = nil

    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// One scalar per row, keyed by index, and every visual is a pure function
    /// of it. Kept as a dictionary because the row count comes from `items`,
    /// which a reader can edit.
    @State private var live: [Int: Double] = [0: 1]
    @State private var openIndex: Int? = 0

    // Damping is not a control on purpose. A blind that overshoots extends past
    // its own content and flashes a sliver of empty surface before pulling
    // back — the one bounce in this category that always reads as a bug.
    // Overshoot belongs on things with somewhere to go.
    private let openDamping: Double = 0.95
    private let closeDamping: Double = 1.0

    private let corner: CGFloat = 20
    private let inset: CGFloat = 16
    private let headerPadding: CGFloat = 14
    private let questionSize: CGFloat = 15
    private let answerSize: CGFloat = 14
    /// Generous, and load-bearing: the further the answer sits above the panel's
    /// bottom edge, the earlier in the open it is clear of the clip.
    private let panelBottom: CGFloat = 22

    // The content window, in units of the panel's own openness, and strictly
    // shorter than the height window at both ends. Nothing until the blind is
    // 40% parted, which is past the point where the clip edge could cut the
    // answer; everything landed by 62%, while the height is still travelling.
    // Read backwards it is the close: the answer is gone inside the first third
    // of it, so what shuts is an empty blind and never a paragraph sliding
    // under an edge.
    //
    // There is deliberately no vertical rise on the answer. In a blind the clip
    // edge is already moving relative to the content, so translating the text as
    // well only buys a chance of putting glyphs under that edge. The fade is the
    // whole flourish.
    private let contentIn: Double = 0.40
    private let contentOut: Double = 0.62

    var body: some View {
        let list = rows
        let shape = RoundedRectangle(cornerRadius: corner, style: .continuous)
        let arriving = min(2, max(list.count - 1, 0))

        VStack(spacing: 0) {
            ForEach(list) { row in
                panel(row, isLast: row.id == list.count - 1,
                      openness: openness(row.id, arriving: arriving))
            }
        }
        .background(surface, in: shape)
        .clipShape(shape)
        .overlay(shape.strokeBorder(textColor.opacity(0.07), lineWidth: 1))
        .shadow(color: textColor.opacity(0.07), radius: 14, y: 6)
        // One tap, one tick: a row that closes because another opened is a
        // consequence, not an event.
        .sensoryFeedback(trigger: openIndex) { _, new in
            new == nil ? nil : .impact(weight: .light)
        }
    }

    @ViewBuilder
    private func panel(_ row: BatonRow, isLast: Bool, openness: Double) -> some View {
        VStack(spacing: 0) {
            Button {
                toggle(row.id)
            } label: {
                HStack(spacing: 12) {
                    Text(row.question)
                        .font(.system(size: questionSize, weight: .medium))
                        .foregroundStyle(textColor)
                        .multilineTextAlignment(.leading)
                    Spacer(minLength: 8)
                    // Linear in openness, so SwiftUI interpolating the angle and
                    // this expression reading an interpolated openness are the
                    // same number. That is not true of the fade below, which is
                    // why only the fade needs its own `Animatable`.
                    Chevron()
                        .stroke(accent, style: StrokeStyle(lineWidth: 1.8, lineCap: .round, lineJoin: .round))
                        .frame(width: 11, height: 6.5)
                        .rotationEffect(.degrees(180 * openness))
                }
                .padding(.horizontal, inset)
                .padding(.vertical, headerPadding)
                // The hit area stops at the header. A button that reaches over
                // the panel makes anything inside the panel untappable.
                .contentShape(Rectangle())
            }
            .buttonStyle(.plain)
            .accessibilityLabel(row.question)
            .accessibilityValue(openness > 0.5 ? "Expanded" : "Collapsed")
            .accessibilityAddTraits(.isButton)

            PanelBlind(openness: openness) {
                PanelFade(openness: openness, from: contentIn, to: contentOut) {
                    Text(row.answer)
                        .font(.system(size: answerSize))
                        .foregroundStyle(textColor.opacity(0.62))
                        .lineSpacing(2)
                        .padding(.horizontal, inset)
                        .padding(.bottom, panelBottom)
                        .frame(maxWidth: .infinity, alignment: .leading)
                }
            }
            .clipped()
            // Clipped is still in the tree, so hide it from VoiceOver too.
            .accessibilityHidden(openness < 0.5)

            if !isLast {
                Rectangle()
                    .fill(textColor.opacity(0.08))
                    .frame(height: 1)
                    .padding(.horizontal, inset)
            }
        }
    }

    // MARK: - Openness

    private func openness(_ index: Int, arriving: Int) -> Double {
        guard let progress else { return live[index] ?? 0 }
        let p = min(max(progress, 0), 1)
        if index == arriving { return p }
        if index == 0 { return leavingOpenness(at: p) }
        return 0
    }

    /// Where the leaving panel is once the arriving one has parted by `p`.
    ///
    /// The drive is parameterised by the row you tapped, so `p` *is* the
    /// arriving panel's height fraction. Ask the open spring how long it took
    /// to get there, then ask the close spring where it is at that same
    /// instant. The gap between the two answers is the handoff, and it is set
    /// by nothing except the ratio of the two responses.
    private func leavingOpenness(at p: Double) -> Double {
        guard p > 0 else { return 1 }
        let clock = springPhase(reaching: p)
        let ratio = openResponse / max(closeResponse, 0.01)
        return max(0, 1 - springStep(clock * ratio))
    }

    /// Position of a critically damped spring `u` radians of its own natural
    /// period after a 0 → 1 retarget.
    private func springStep(_ u: Double) -> Double {
        u <= 0 ? 0 : 1 - (1 + u) * exp(-u)
    }

    /// Inverse of `springStep`, by Newton. `springStep` is monotone with slope
    /// `u·e⁻ᵘ`, so four or five steps land well inside a pixel.
    private func springPhase(reaching value: Double) -> Double {
        let target = min(max(value, 0), 0.999)
        guard target > 0 else { return 0 }
        var u = 1 - log(1 - target)
        for _ in 0..<6 {
            let slope = u * exp(-u)
            guard slope > 1e-9 else { break }
            u = max(0, u - (springStep(u) - target) / slope)
        }
        return u
    }

    // MARK: - Interaction

    private func toggle(_ index: Int) {
        let closing = openIndex == index
        let leaving = openIndex

        guard !reduceMotion else {
            // Never a frozen midpoint: straight to the settled state.
            var next = live
            if let leaving { next[leaving] = 0 }
            if !closing { next[index] = 1 }
            live = next
            openIndex = closing ? nil : index
            return
        }

        // Two targets, two springs, one instant. A spring on plain state
        // retargets velocity-preserving, which is the whole reason this is not
        // a keyframe or a phase animator.
        if let leaving {
            withAnimation(.spring(response: closeResponse, dampingFraction: closeDamping)) {
                live[leaving] = 0
            }
        }
        if !closing {
            withAnimation(.spring(response: openResponse, dampingFraction: openDamping)) {
                live[index] = 1
            }
        }
        openIndex = closing ? nil : index
    }

    // MARK: - Content

    private var rows: [BatonRow] {
        var parsed: [BatonRow] = []
        for line in items.split(separator: "\n") {
            let fields = line.split(separator: "|", maxSplits: 1, omittingEmptySubsequences: false)
            let question = fields.first?.trimmingCharacters(in: .whitespaces) ?? ""
            guard !question.isEmpty else { continue }
            let answer = fields.count > 1 ? fields[1].trimmingCharacters(in: .whitespaces) : ""
            parsed.append(BatonRow(id: parsed.count, question: question, answer: answer))
        }
        return parsed
    }
}

private struct BatonRow: Identifiable {
    let id: Int
    let question: String
    let answer: String
}

/// Reports `openness × the child's natural height` while still proposing the
/// child its full height, so the child overflows and the caller's clip does the
/// rest.
///
/// A one-child `Layout` rather than a `GeometryReader` writing a preference:
/// it measures inside the same pass, so it is right on frame one with no state
/// round-trip, and it is `Animatable`, so a spring drives the measured height
/// directly. `anchor: .topLeading` is load-bearing — place the child centred and
/// it slides upward inside the shrinking box, which reads as a squash rather
/// than a blind.
private struct PanelBlind: Layout {
    var openness: Double

    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        // Proposing a nil height is what makes Text report its natural wrapped
        // height instead of accepting whatever it is offered.
        let natural = subviews[0].sizeThatFits(.init(width: proposal.width, height: nil))
        return CGSize(
            width: proposal.width ?? natural.width,
            height: natural.height * max(openness, 0)
        )
    }

    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        subviews[0].place(
            at: CGPoint(x: bounds.minX, y: bounds.minY),
            anchor: .topLeading,
            proposal: .init(width: bounds.width, height: nil)
        )
    }

    var animatableData: Double {
        get { openness }
        set { openness = newValue }
    }
}

/// Fades its child in across a window of the panel's openness — and is
/// `Animatable` so the window is real.
///
/// This matters more than it looks. Written as a plain `.opacity(smoothstep(v))`
/// the modifier would animate from the *old* opacity to the *new* one on the
/// panel's spring, so the content would fade in exactly in step with the height
/// and the window would quietly do nothing. Taking `openness` as
/// `animatableData` instead means `body` is re-evaluated with the interpolated
/// scalar on every frame, so the curve is applied where it was meant to be. The
/// blind's `Layout` interpolates the same scalar under the same transaction, so
/// the two cannot drift apart, mid-flight retarget included.
private struct PanelFade<Content: View>: View, Animatable {
    var openness: Double
    var from: Double
    var to: Double
    @ViewBuilder var content: Content

    // `nonisolated` because `View` is main-actor isolated under Swift 6 and
    // `Animatable` is not; without it the conformance is a data-race error.
    nonisolated var animatableData: Double {
        get { openness }
        set { openness = newValue }
    }

    var body: some View {
        let t = min(max((openness - from) / max(to - from, 0.01), 0), 1)
        content.opacity(t * t * (3 - 2 * t))
    }
}

/// Drawn rather than an SF Symbol so the stroke weight matches the hairline
/// divider at any accent colour.
private struct Chevron: Shape {
    func path(in rect: CGRect) -> Path {
        var path = Path()
        path.move(to: CGPoint(x: rect.minX, y: rect.minY))
        path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
        path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
        return path
    }
}

#Preview {
    DiscloseBatonPass()
        .padding(20)
        .frame(width: 380, height: 300)
}
iOS 17 · No dependencies

SwiftUI note. Panel height comes from a one-child `Layout`, so it is measured rather than assumed — answers of a line or two are what the fade window is tuned for, and a much longer one will show a line crossing the clip edge mid-open. Keep `closeResponse` under `openResponse`; at equal values the handoff stops reading as one motion. Pass `progress` to scrub the first-to-third handoff with no touch.

Dependencies

SwiftUI
No external dependencies

Requires iOS 17

Flutter
No external dependencies

Requires Flutter 3.27