Files
wehub-resource-sync 869b84f27c
Build / build (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:29:30 +08:00

200 lines
7.7 KiB
Swift

import SwiftUI
/// Tasteful one-shot confetti, redesigned away from the old harsh CAEmitter
/// rectangles. A `Canvas` + `TimelineView` particle system: mixed shapes
/// (streamers, discs, rings), a soft brand palette, gentle gravity with a
/// little horizontal sway, a 3D-style tumble, and a graceful fade. Fire with
/// `ConfettiView(trigger:)` — each rising edge fires one burst that quiets
/// itself. Honors Reduce Motion (renders nothing).
struct ConfettiView: View {
enum Mode {
/// Pieces spawn above the frame and flutter down (ambient rain).
case rain
/// Pieces explode radially from a point (anchored celebration —
/// pair the origin with the visual element being celebrated).
case burst(origin: UnitPoint)
}
/// Flip to fire a burst. Each rising edge fires once.
let trigger: Bool
var mode: Mode = .rain
@State private var particles: [Particle] = []
@State private var start: Date?
@State private var generation = 0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private let duration: TimeInterval = 2.6
var body: some View {
GeometryReader { geo in
TimelineView(.animation(paused: start == nil)) { timeline in
Canvas { ctx, size in
guard let start else { return }
let t = timeline.date.timeIntervalSince(start)
guard t <= duration else { return }
for p in particles {
draw(p, at: t, in: size, ctx: &ctx)
}
}
}
// The parent flips `trigger` (via .toggle()) as a fire pulse, so a
// burst should play on ANY change — not just the rising edge, which
// would skip every other cleanup.
.onChange(of: trigger) { _ in
fire(in: geo.size)
}
.allowsHitTesting(false)
}
}
private func fire(in size: CGSize) {
guard !reduceMotion, size.width > 0 else { return }
switch mode {
case .rain:
particles = (0..<90).map { _ in Particle.random(width: size.width) }
case .burst(let origin):
let point = CGPoint(x: size.width * origin.x, y: size.height * origin.y)
particles = (0..<90).map { _ in Particle.burst(from: point) }
}
start = Date()
generation += 1
let g = generation
// Quiet the timeline once the burst has fully faded so we don't keep
// re-rendering an empty canvas. Guard on `generation` so a later burst
// isn't cut short by an earlier burst's cleanup timer.
DispatchQueue.main.asyncAfter(deadline: .now() + duration + 0.1) {
guard generation == g else { return }
start = nil
particles = []
}
}
private func draw(_ p: Particle, at t: Double, in size: CGSize, ctx: inout GraphicsContext) {
// Vertical: initial downward velocity + gravity. Horizontal: drift +
// a sine sway so pieces flutter instead of dropping like stones.
let x = p.x0 + p.drift * t + p.swayAmp * sin(p.swayFreq * t + p.phase)
let y = p.y0 + p.vy0 * t + 0.5 * p.gravity * t * t
guard y < size.height + 40 else { return }
let fade: Double
if t > p.fadeStart {
fade = max(0, 1 - (t - p.fadeStart) / (duration - p.fadeStart))
} else {
fade = 1
}
guard fade > 0.01 else { return }
let angle = p.spin * t
// Tumble: squash one axis on a cosine so pieces appear to flip in 3D.
let tumble = abs(cos(p.tumbleSpeed * t + p.phase))
let w = p.size.width * (0.25 + 0.75 * tumble)
let h = p.size.height
var sub = ctx
sub.translateBy(x: x, y: y)
sub.rotate(by: .radians(angle))
sub.opacity = fade
let rect = CGRect(x: -w / 2, y: -h / 2, width: w, height: h)
switch p.shape {
case .streamer:
sub.fill(Path(roundedRect: rect, cornerRadius: min(w, h) * 0.45), with: .color(p.color))
case .disc:
sub.fill(Path(ellipseIn: CGRect(x: -h / 2, y: -h / 2, width: h, height: h)), with: .color(p.color))
case .ring:
let r = h / 2
let ring = Path(ellipseIn: CGRect(x: -r, y: -r, width: h, height: h))
sub.stroke(ring, with: .color(p.color), lineWidth: max(1.4, h * 0.22))
}
}
}
private enum ConfettiShape: CaseIterable { case streamer, disc, ring }
private struct Particle {
var x0: CGFloat
var y0: CGFloat
var vy0: Double
var gravity: Double
var drift: Double
var swayAmp: Double
var swayFreq: Double
var phase: Double
var spin: Double
var tumbleSpeed: Double
var fadeStart: Double
var color: Color
var size: CGSize
var shape: ConfettiShape
/// Soft, slightly desaturated brand palette — premium, not garish.
static let palette: [Color] = [
Color(red: 0.27, green: 0.56, blue: 1.00), // blue
Color(red: 0.30, green: 0.80, blue: 0.56), // green
Color(red: 1.00, green: 0.66, blue: 0.30), // amber
Color(red: 0.62, green: 0.45, blue: 0.92), // purple
Color(red: 1.00, green: 0.46, blue: 0.62), // pink
Color(red: 0.36, green: 0.80, blue: 0.94), // cyan
]
/// Radial explosion from a fixed point. Same physics as the rain mode —
/// the polar launch velocity plus the existing gravity term naturally
/// produces the arc — with an upward bias so the burst blooms before it
/// falls, and an earlier fade so pieces don't litter the screen.
static func burst(from point: CGPoint) -> Particle {
let shape = ConfettiShape.allCases.randomElement()!
let dim: CGSize
switch shape {
case .streamer: dim = CGSize(width: .random(in: 5...8), height: .random(in: 11...16))
case .disc: dim = CGSize(width: 9, height: .random(in: 7...10))
case .ring: dim = CGSize(width: 10, height: .random(in: 9...13))
}
let angle = Double.random(in: 0...(2 * .pi))
let speed = Double.random(in: 160...340)
return Particle(
x0: point.x,
y0: point.y,
vy0: sin(angle) * speed - .random(in: 140...220),
gravity: .random(in: 240...340),
drift: cos(angle) * speed,
swayAmp: .random(in: 4...14),
swayFreq: .random(in: 1.4...3.0),
phase: .random(in: 0...(2 * .pi)),
spin: .random(in: -6...6),
tumbleSpeed: .random(in: 2.5...5.5),
fadeStart: .random(in: 1.0...1.5),
color: palette.randomElement()!,
size: dim,
shape: shape
)
}
static func random(width: CGFloat) -> Particle {
let shape = ConfettiShape.allCases.randomElement()!
let dim: CGSize
switch shape {
case .streamer: dim = CGSize(width: .random(in: 5...8), height: .random(in: 11...16))
case .disc: dim = CGSize(width: 9, height: .random(in: 7...10))
case .ring: dim = CGSize(width: 10, height: .random(in: 9...13))
}
return Particle(
x0: .random(in: 0...width),
y0: .random(in: -60 ... -10),
vy0: .random(in: 90...170),
gravity: .random(in: 130...210),
drift: .random(in: -40...40),
swayAmp: .random(in: 8...26),
swayFreq: .random(in: 1.4...3.0),
phase: .random(in: 0...(2 * .pi)),
spin: .random(in: -5...5),
tumbleSpeed: .random(in: 2.5...5.5),
fadeStart: .random(in: 1.3...1.9),
color: palette.randomElement()!,
size: dim,
shape: shape
)
}
}