
Baton Pass
From syxUI — written for both platforms, not translated between them.
InteractiveA 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
- Flutter
- No external dependencies
Requires iOS 17
Requires Flutter 3.27

