
Flick Banner
From syxUI — written for both platforms, not translated between them.
InteractiveA top notification banner that drops in on a spring and can be flicked away, carrying its release velocity.
Banners · SwiftUI · Flutter · iOS 17 · Flutter 3.27
- banner
- notification
- drag
- dismiss
- spring
- velocity
- overlay
The actual source
// Flick Banner · syxUI · https://syxui.dev/components/banner-flick-dismiss
// Free in any project. Keep this line and credit syxUI where a person can read it.
import SwiftUI
// MARK: - Motion
/// One spring, in the `(response, dampingFraction)` terms SwiftUI uses.
///
/// At unit mass these convert exactly to `stiffness = ω₀²` and `damping = 2ζω₀`,
/// which is what both `interpolatingSpring` and Flutter's `SpringDescription`
/// take — so the two files run the same springs rather than two tunings that
/// merely look similar.
private struct Spring {
let response: Double
let damping: Double
var omega: Double { 2 * .pi / response }
var stiffness: Double { omega * omega }
var friction: Double { 2 * damping * omega }
/// How long this spring takes to be visually settled: `6/(ζω₀)`.
///
/// Not the commonly quoted `4/(ζω₀)` — at 4 the analytic curve below is
/// still ~2 % short at `p = 1`, and 2 % of an 84 pt entry is a visible
/// misalignment in a still frame. At 6 it lands inside 0.3 %.
var settle: Double { 6 / (damping * omega) }
}
/// The shadow leads the banner in. 0.30 is stiffer than the 0.42 arrival, so
/// the page below darkens before the banner reaches its resting y — that lead
/// is what gives the banner mass. The same spring on both looks painted on.
private let shadowSpring = Spring(response: 0.30, damping: 0.82)
/// A committed dismissal: leaves without oscillating, because it is leaving,
/// not landing.
private let exitSpring = Spring(response: 0.50, damping: 0.85)
/// Released below the threshold. Springs back — but carrying the release
/// velocity, so a half-flick still looks like it was thrown.
private let returnSpring = Spring(response: 0.34, damping: 0.85)
/// A cancelled gesture. There is no release velocity to carry, so this one is
/// a plain return home.
private let cancelSpring = Spring(response: 0.38, damping: 0.86)
/// Press-down. Dead flat: a finger on glass does not bounce.
private let pressSpring = Spring(response: 0.20, damping: 0.90)
/// The analytic underdamped step response at time `t`, with an optional initial
/// velocity in *fractions of the remaining travel per second* — the same
/// normalisation `interpolatingSpring(initialVelocity:)` uses.
///
/// The live path hands real gesture velocity to real springs, which cannot be
/// sampled from a `Double`. This is the resolution: one geometry, two drives.
/// The preview drive walks the same curve *including the head start* a flick
/// gives it, which is why the throw in the loop does not start from rest.
private func springShape(_ t: Double, _ spring: Spring, velocity v0: Double = 0) -> Double {
guard t > 0 else { return 0 }
let zeta = min(max(spring.damping, 0.05), 0.999)
let decay = zeta * spring.omega
let damped = spring.omega * (1 - zeta * zeta).squareRoot()
// `(decay - v0)` is the whole point: at v0 = 0 this reduces to the textbook
// step response, and a positive v0 flattens the leading edge into a throw.
return 1 - exp(-decay * t)
* (cos(damped * t) + ((decay - v0) / damped) * sin(damped * t))
}
/// A spring that continues at the finger's speed instead of restarting.
///
/// `interpolatingSpring` is the only SwiftUI spring that accepts a release
/// velocity, and `initialVelocity` is **normalised and signed against the
/// remaining travel**: 1.0 means "cover what is left in one second", and a
/// negative value means the finger is still moving *away* from the target.
///
/// So it is `velocity / travel`, never `velocity / abs(travel)`. A banner
/// pulled down and released while still moving down has a negative travel and
/// a positive velocity; dividing by the magnitude would claim it was heading
/// home. Passing raw pt/s launches it clean off the screen.
private func handOff(_ spring: Spring, velocity: CGFloat, travel: CGFloat) -> Animation {
.interpolatingSpring(
stiffness: spring.stiffness,
damping: spring.friction,
initialVelocity: abs(travel) < 1 ? 0 : Double(velocity / travel)
)
}
/// The same spring with no hand-off. Still `interpolatingSpring` rather than
/// `.spring(response:dampingFraction:)`, because interpolating springs compose
/// velocity across an interruption: a flick that lands mid-arrival continues
/// from the arrival's own speed instead of restarting from a standstill.
private func settling(_ spring: Spring) -> Animation {
.interpolatingSpring(stiffness: spring.stiffness, damping: spring.friction)
}
/// UIScrollView's over-travel curve, `c = 0.55`. It asymptotes to `d` and never
/// reaches it, so the edge is felt and never hit. `pow(x, 0.7)` is the usual
/// substitute and it diverges under a hard pull.
private func rubberBand(_ x: CGFloat, limit d: CGFloat) -> CGFloat {
guard x > 0, d > 0 else { return x }
let c: CGFloat = 0.55
return (x * d * c) / (d + c * x)
}
// MARK: - Content
/// The copy one banner shows. Replacing it cross-fades in place rather than
/// letting the banner leave and re-enter.
struct FlickMessage: Hashable {
var title: String
var body: String
var stamp: String
var glyph: String
}
// MARK: - Component
/// A notification banner that drops in on a spring and can be thrown away with
/// the velocity your finger gave it.
///
/// This is an **overlay**: it reserves its own height as a layout footprint and
/// nothing under it ever moves. The shadow is the only thing explaining why it
/// is above the page, which is why the shadow arrives on a stiffer spring than
/// the banner and is fully open before the banner lands.
///
/// The release is the point of the component. It projects the gesture's velocity
/// into a spring rather than restarting as a fixed-duration curve, so a hard
/// flick leaves fast and a lazy drag past the line drifts out. A release below
/// the threshold springs back carrying the *same* velocity.
struct FlickBanner: View {
// MARK: Controls
var height: CGFloat = 72
var cornerRadius: CGFloat = 18
/// Arrival spring response, seconds. Hoisted because a spring written
/// inline is not something a control can reach.
var response: Double = 0.42
/// Release speed that commits a dismissal on its own, pt/s.
var dismissVelocity: CGFloat = 520
var surface: Color = Color(red: 0.090, green: 0.090, blue: 0.102)
var accent: Color = Color(red: 0.039, green: 0.518, blue: 1.0)
// MARK: Copy
var title: String = "Mara Villanueva"
var message: String = "Landing at 6:40 — see you there"
var stamp: String = "now"
var glyph: String = "bubble.left.fill"
// MARK: Feel
var arrivalDamping: Double = 0.78
/// Fraction of the banner's height that commits on distance alone.
var commitFraction: CGFloat = 0.34
/// Seconds before the banner leaves on its own. 0 keeps it until it is
/// dismissed; a system-style message preview wants 4–6.
var dwell: Double = 0
/// Cross-fade for a replacement message, ms.
var crossFade: Double = 140
/// Preview override. One pass over the banner's whole life: 0 is above the
/// frame, ~0.5 is arrived and settled, 1 is thrown away and gone. Rest sits
/// in the middle because a flick banner's story is not over when it lands.
var progress: Double? = nil
// MARK: State
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// Vertical displacement from rest, pt. Negative is up and out.
@State private var drag: CGFloat = 0
@State private var entered = false
@State private var lifted: Double = 0
@State private var pressed = false
@State private var dragging = false
@State private var armed = false
@State private var gone = false
/// Where the banner actually is this frame, published by `FlickTravel`.
/// Without it a finger catching the banner mid-flight would anchor to the
/// animation's *target* and the banner would jump to meet it.
@State private var presented: CGFloat = 0
@State private var anchor: CGFloat = 0
@State private var measured: CGFloat = 0
@State private var shown: FlickMessage? = nil
@State private var holding = false
@State private var dwellToken = 0
/// True only while the gesture is live. A cancelled `DragGesture` never
/// calls `onEnded`, so without this the banner would stay where the finger
/// left it when a system gesture stole the touch.
@GestureState private var held = false
// MARK: Derived
private var arrivalSpring: Spring {
Spring(response: max(response, 0.05), damping: arrivalDamping)
}
private var incoming: FlickMessage {
FlickMessage(title: title, body: message, stamp: stamp, glyph: glyph)
}
private var visible: FlickMessage { shown ?? incoming }
private var box: CGFloat { max(height, measured) }
/// The banner enters from just above its own top edge, not from off-screen:
/// the host decides where the top of the screen is, and 12 pt of clearance
/// is enough for the shadow to be off-page too.
private var entryOffset: CGFloat { -(box + 12) }
private var exitOffset: CGFloat { -(box + 24) }
private var rowInset: CGFloat { 14 }
private var badgeSize: CGFloat { 30 }
private var badgeGap: CGFloat { 12 }
private var titleColor: Color { Color(white: 0.961) }
private var messageColor: Color { Color(white: 0.66) }
private var stampColor: Color { Color(white: 0.55) }
/// Where the preview drive puts the banner and its shadow. Both springs
/// start together and are sampled in *seconds*, so the shadow's lead is
/// emergent — it settles first because it is the stiffer spring, not
/// because a delay was hand-tuned in.
private func staged(_ p: Double) -> (travel: CGFloat, lift: Double, fading: Bool) {
let arriveStart = 0.04, arriveEnd = 0.34
let exitStart = 0.72, exitEnd = 0.95
if p < arriveStart { return (entryOffset, 0, false) }
if p < exitStart {
let u = min((p - arriveStart) / (arriveEnd - arriveStart), 1)
let t = u * arrivalSpring.settle
let y = CGFloat(springShape(t, arrivalSpring))
return (entryOffset + (0 - entryOffset) * y,
springShape(t, shadowSpring),
false)
}
let u = min((p - exitStart) / (exitEnd - exitStart), 1)
// The representative throw is the threshold speed itself, so the loop
// leaves at exactly the pace `dismissVelocity` names — and turning that
// control up visibly shortens the departure.
let v0 = Double(-dismissVelocity / exitOffset)
let y = CGFloat(springShape(u * exitSpring.settle, exitSpring, velocity: v0))
return (y * exitOffset, 1, true)
}
// MARK: Body
var body: some View {
let stage = progress.map(staged)
let travel = stage?.travel ?? ((entered || reduceMotion ? 0 : entryOffset) + drag)
let lift = stage?.lift ?? (reduceMotion ? 1 : lifted)
FlickTravel(offset: travel, exitOffset: exitOffset, fading: stage?.fading ?? gone) {
card(lift: lift)
}
.frame(height: box, alignment: .top)
.onPreferenceChange(TravelKey.self) { presented = $0 }
.onPreferenceChange(BoxHeightKey.self) { measured = $0 }
.onAppear(perform: arrive)
.onChange(of: held) { _, now in if !now { cancelDrag() } }
.onChange(of: incoming) { _, next in replace(with: next) }
.task(id: dwellToken) { await countDown() }
.sensoryFeedback(trigger: armed) { _, now in
now ? .impact(weight: .light, intensity: 0.45) : nil
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(visible.title). \(visible.body)")
.accessibilityAction(named: "Dismiss") { dismiss(velocity: 0) }
.accessibilityHidden(gone)
}
// MARK: Chrome
private func card(lift: Double) -> some View {
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
return contentStack
.frame(maxWidth: .infinity, minHeight: height, alignment: .leading)
.background(shape.fill(surface))
// Clip inside, shadow outside. A clip on a shadowed node eats the
// shadow, and `.clipped()` would clip to the bounding rect and let
// the grabber through the corner.
.clipShape(shape)
.overlay(shape.strokeBorder(Color(white: 1).opacity(0.08), lineWidth: 0.6))
.background {
GeometryReader { proxy in
Color.clear.preference(key: BoxHeightKey.self, value: proxy.size.height)
}
}
.shadow(color: Color.black.opacity(0.30 * lift),
radius: 20 * CGFloat(lift), y: 10 * CGFloat(lift))
.scaleEffect(pressed ? 0.985 : 1)
.contentShape(shape)
.gesture(flick)
.allowsHitTesting(progress == nil && !gone)
}
private var contentStack: some View {
ZStack(alignment: .bottom) {
row(visible)
.id(visible)
.transition(.opacity)
.padding(.horizontal, rowInset)
// Asymmetric on purpose. The bottom inset reserves the
// grabber's strip, so copy long enough to wrap grows the banner
// instead of landing on top of it — and text sits high in its
// line box, so a geometrically centred row reads low anyway.
.padding(.top, 11)
.padding(.bottom, 20)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
grabber.padding(.bottom, 7)
}
}
/// The affordance. A banner that can be thrown has to say so while it is
/// sitting still, and this is the shape the system uses for it.
private var grabber: some View {
Capsule(style: .continuous)
.fill(Color(white: 1).opacity(0.20))
.frame(width: 36, height: 4)
.accessibilityHidden(true)
}
private func row(_ slot: FlickMessage) -> some View {
HStack(alignment: .top, spacing: badgeGap) {
Image(systemName: slot.glyph)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(Color(white: 1))
.frame(width: badgeSize, height: badgeSize)
.background(accent, in: RoundedRectangle(cornerRadius: 9, style: .continuous))
VStack(alignment: .leading, spacing: 3) {
// The stamp is the element that yields, never the copy: if the
// title needs the whole column the timestamp goes, and if the
// copy still does not fit it wraps and the banner grows past
// `height`. Nothing here can truncate.
ViewThatFits(in: .horizontal) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
titleText(slot).fixedSize()
Spacer(minLength: 0)
Text(slot.stamp)
.font(.system(size: 12).monospacedDigit())
.foregroundStyle(stampColor)
.fixedSize()
}
titleText(slot)
}
Text(slot.body)
.font(.system(size: 13))
.foregroundStyle(messageColor)
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
private func titleText(_ slot: FlickMessage) -> some View {
Text(slot.title)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(titleColor)
.fixedSize(horizontal: false, vertical: true)
}
// MARK: Interaction
private var flick: some Gesture {
DragGesture(minimumDistance: 0)
.updating($held) { _, state, _ in state = true }
.onChanged { value in
guard progress == nil, !gone else { return }
if !dragging {
// Catching a moving banner kills its momentum, the way a
// hand closing on a thrown object does. What must not
// happen is a jump: the anchor is where the banner *is*,
// not where its animation was heading.
dragging = true
anchor = presented
entered = true
holding = true
dwellToken += 1
withAnimation(settling(pressSpring)) { pressed = true }
}
let raw = anchor + value.translation.height
// Up is 1:1. Down rubber-bands against three banner heights,
// because pulling down past rest is how a person peeks at a
// second line, not a gesture that should ever complete.
drag = rubberBand(raw, limit: box * 3)
let line = box * commitFraction
// Latched: re-arm only well below the line, or a finger resting
// on it buzzes every frame.
if drag <= -line { armed = true } else if drag > -line * 0.85 { armed = false }
}
.onEnded { value in
guard progress == nil, !gone else { return }
let velocity = value.velocity.height
dragging = false
armed = false
withAnimation(settling(pressSpring)) { pressed = false }
// Decided on the projected stop, not on where the finger
// stopped. 0.20 s of coasting — a banner is a control-scale
// throw, not a page-scale one.
let projected = drag + velocity * 0.20
let commits = value.translation.height <= -box * commitFraction
|| projected <= -box
|| velocity <= -dismissVelocity
if commits {
dismiss(velocity: velocity)
} else {
holding = false
dwellToken += 1
guard !reduceMotion else { drag = 0; return }
withAnimation(handOff(returnSpring, velocity: velocity, travel: -drag)) {
drag = 0
}
}
}
}
private func arrive() {
guard progress == nil, !entered else { return }
guard !reduceMotion else { entered = true; lifted = 1; return }
// Two separate calls, so each state change carries its own spring.
// One `withAnimation` around both would put the banner and its shadow
// on the same curve and the lead would quietly disappear.
withAnimation(settling(arrivalSpring)) { entered = true }
withAnimation(settling(shadowSpring)) { lifted = 1 }
}
private func dismiss(velocity: CGFloat) {
guard !gone else { return }
entered = true
gone = true
holding = true
guard !reduceMotion else { drag = exitOffset; return }
withAnimation(handOff(exitSpring, velocity: velocity, travel: exitOffset - drag)) {
drag = exitOffset
}
}
/// A gesture that ended normally has already cleared `dragging`, so this
/// only fires for a real cancellation.
private func cancelDrag() {
guard dragging else { return }
dragging = false
armed = false
holding = false
dwellToken += 1
withAnimation(settling(pressSpring)) { pressed = false }
guard !reduceMotion else { drag = 0; return }
withAnimation(settling(cancelSpring)) { drag = 0 }
}
/// A second banner retargets the one already on screen: the copy
/// cross-fades in place and the dwell restarts. It does not queue, and it
/// does not leave and re-enter — that stutter is what makes stacked toasts
/// feel cheap.
private func replace(with next: FlickMessage) {
dwellToken += 1
guard progress == nil, !reduceMotion else { shown = next; return }
withAnimation(.easeInOut(duration: crossFade / 1000)) { shown = next }
}
private func countDown() async {
guard progress == nil, dwell > 0, !gone, !holding else { return }
try? await Task.sleep(for: .seconds(dwell))
guard !Task.isCancelled, !gone, !holding else { return }
dismiss(velocity: 0)
}
}
// MARK: - Travel
private struct TravelKey: PreferenceKey {
// `static let`, not `static var`: under Swift 6 a mutable static is shared
// mutable state and a hard error, and a preference default never changes.
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = nextValue()
}
}
private struct BoxHeightKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
/// Carries the banner's vertical travel and everything derived from it.
///
/// It is `Animatable` on the offset for two reasons. The fade has to run over
/// the *last 40 %* of the committed travel, and writing `.opacity(curve(offset))`
/// plainly would make SwiftUI interpolate opacity from its old value to its new
/// one on the banner's own spring — the shorter window evaporates and the frames
/// are wrong while the code looks right. Here the body re-evaluates the curve
/// every frame instead.
///
/// The same per-frame evaluation is what publishes the presented offset, so a
/// finger catching the banner mid-flight has somewhere honest to anchor to.
private struct FlickTravel<Content: View>: View, Animatable {
var offset: CGFloat
var exitOffset: CGFloat
var fading: Bool
@ViewBuilder var content: Content
// `View` is @MainActor and `Animatable` is not, so this needs the
// annotation or it is a hard error under Swift 6.
nonisolated var animatableData: CGFloat {
get { offset }
set { offset = newValue }
}
var body: some View {
// Fades only on the way out. The entry travels almost as far and would
// otherwise arrive nearly transparent.
let covered = fading && exitOffset != 0 ? offset / exitOffset : 0
let fade = min(max((covered - 0.6) / 0.4, 0), 1)
content
.opacity(1 - Double(fade))
.offset(y: offset)
.background(Color.clear.preference(key: TravelKey.self, value: offset))
}
}
#Preview {
FlickBanner()
.padding(16)
.frame(width: 420, height: 170)
}
SwiftUI note. Overlay: it reserves its own height and nothing under it moves, so drop it in an `.overlay(alignment: .top)` rather than a `VStack` — a banner that appears by pushing content down is the failure this one is built against. `interpolatingSpring` everywhere on the travel axis is deliberate: its `initialVelocity` is normalised against the remaining travel (`velocity / travel`, keeping the sign) and it composes velocity across an interruption, so a flick landing mid-arrival continues instead of restarting. `progress` runs the whole life with no touch, which is what the catalog samples.
Dependencies
- SwiftUI
- No external dependencies
- Flutter
- No external dependencies
Requires iOS 17
Requires Flutter 3.27


