feat: ReactFlux-style confetti — ceiling drop, 200 particles, full screen
Exact replica of ReactFlux confetti: - 200 tiny rectangles scattered across full screen width - Drop from above the screen, fall down with gravity - Gentle horizontal drift + 3D rotation (tumbling effect) - 10 vivid colors matching ReactFlux palette - Staggered delays (0-0.4s) for natural rain effect - 1.5-2.5s fall duration, fade at 80% - No overlay/checkmark — just pure confetti rain - Haptic feedback on trigger Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -85,178 +85,108 @@ struct MainTabView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Confetti (canvas-confetti style: burst from bottom-right)
|
// MARK: - Confetti (ReactFlux style: ceiling drop, full screen scatter)
|
||||||
|
|
||||||
struct ConfettiView: View {
|
struct ConfettiView: View {
|
||||||
@State private var particles: [Particle] = []
|
@State private var particles: [ConfettiPiece] = []
|
||||||
@State private var showCheck = false
|
|
||||||
|
|
||||||
static let palette: [Color] = [
|
static let colors: [Color] = [
|
||||||
.red, .orange, .yellow, .green, .blue, .purple, .pink,
|
Color(red: 1, green: 0.22, blue: 0.36), // hot pink
|
||||||
|
Color(red: 0.26, green: 0.63, blue: 1), // sky blue
|
||||||
Color(red: 1, green: 0.84, blue: 0), // gold
|
Color(red: 1, green: 0.84, blue: 0), // gold
|
||||||
Color(red: 0, green: 0.8, blue: 0.6), // teal
|
Color(red: 0.18, green: 0.8, blue: 0.44), // green
|
||||||
Color(red: 0.93, green: 0.35, blue: 0.35), // coral
|
Color(red: 0.61, green: 0.35, blue: 0.96), // purple
|
||||||
Color(red: 0.58, green: 0.44, blue: 0.86), // violet
|
Color(red: 1, green: 0.55, blue: 0), // orange
|
||||||
Color(red: 1, green: 0.6, blue: 0.7), // pink
|
Color(red: 0, green: 0.82, blue: 0.77), // teal
|
||||||
|
Color(red: 1, green: 0.41, blue: 0.71), // pink
|
||||||
|
Color(red: 0.39, green: 0.4, blue: 1), // indigo
|
||||||
|
Color(red: 1, green: 0.92, blue: 0.23), // yellow
|
||||||
]
|
]
|
||||||
|
|
||||||
struct Particle: Identifiable {
|
struct ConfettiPiece: Identifiable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
let color: Color
|
let color: Color
|
||||||
let size: CGFloat
|
let width: CGFloat
|
||||||
let shape: Int
|
let height: CGFloat
|
||||||
// Start position (bottom-right area)
|
let x: CGFloat
|
||||||
let startX: CGFloat
|
|
||||||
let startY: CGFloat
|
let startY: CGFloat
|
||||||
// Velocity
|
let drift: CGFloat
|
||||||
let vx: CGFloat
|
|
||||||
let vy: CGFloat
|
|
||||||
let spin: Double
|
let spin: Double
|
||||||
let delay: Double
|
let delay: Double
|
||||||
|
let fallDuration: Double
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
ZStack {
|
ZStack {
|
||||||
ForEach(particles) { p in
|
ForEach(particles) { p in
|
||||||
particleView(p)
|
RoundedRectangle(cornerRadius: 1)
|
||||||
.modifier(PhysicsModifier(
|
.fill(p.color)
|
||||||
startX: p.startX, startY: p.startY,
|
.frame(width: p.width, height: p.height)
|
||||||
vx: p.vx, vy: p.vy,
|
.modifier(CeilingDropModifier(
|
||||||
spin: p.spin, delay: p.delay,
|
x: p.x, startY: p.startY,
|
||||||
bounds: geo.size
|
endY: geo.size.height + 40,
|
||||||
|
drift: p.drift, spin: p.spin,
|
||||||
|
delay: p.delay, duration: p.fallDuration
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success check
|
|
||||||
if showCheck {
|
|
||||||
VStack(spacing: 6) {
|
|
||||||
Image(systemName: "checkmark.circle.fill")
|
|
||||||
.font(.system(size: 50))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.shadow(color: .black.opacity(0.3), radius: 10)
|
|
||||||
Text("Added!")
|
|
||||||
.font(.subheadline.weight(.bold))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.shadow(color: .black.opacity(0.3), radius: 4)
|
|
||||||
}
|
}
|
||||||
.transition(.scale.combined(with: .opacity))
|
.onAppear { scatter(in: geo.size) }
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear { generate(size: geo.size) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
private func scatter(in size: CGSize) {
|
||||||
private func particleView(_ p: Particle) -> some View {
|
particles = (0..<200).map { _ in
|
||||||
Group {
|
ConfettiPiece(
|
||||||
switch p.shape % 3 {
|
color: Self.colors.randomElement()!,
|
||||||
case 0: Circle().fill(p.color)
|
width: CGFloat.random(in: 4...8),
|
||||||
case 1: Rectangle().fill(p.color)
|
height: CGFloat.random(in: 6...14),
|
||||||
default: Star(corners: 5, smoothness: 0.45).fill(p.color)
|
x: CGFloat.random(in: 0...size.width),
|
||||||
}
|
startY: CGFloat.random(in: (-200)...(-20)),
|
||||||
}
|
drift: CGFloat.random(in: -30...30),
|
||||||
.frame(width: p.size, height: p.shape % 3 == 1 ? p.size * 0.6 : p.size)
|
spin: Double.random(in: -540...540),
|
||||||
}
|
delay: Double.random(in: 0...0.4),
|
||||||
|
fallDuration: Double.random(in: 1.5...2.5)
|
||||||
private func generate(size: CGSize) {
|
|
||||||
let originX = size.width * 0.9
|
|
||||||
let originY = size.height * 0.85
|
|
||||||
|
|
||||||
// canvas-confetti style: angle=120, spread=70, particleCount=100
|
|
||||||
// angle 120° means shooting upper-left from bottom-right
|
|
||||||
let baseAngle: Double = 120
|
|
||||||
let spread: Double = 70
|
|
||||||
|
|
||||||
particles = (0..<100).map { _ in
|
|
||||||
let angle = baseAngle + Double.random(in: -spread/2...spread/2)
|
|
||||||
let radians = angle * .pi / 180
|
|
||||||
let speed = CGFloat.random(in: 15...45)
|
|
||||||
|
|
||||||
return Particle(
|
|
||||||
color: Self.palette.randomElement()!,
|
|
||||||
size: CGFloat.random(in: 5...11),
|
|
||||||
shape: Int.random(in: 0...2),
|
|
||||||
startX: originX + CGFloat.random(in: -20...20),
|
|
||||||
startY: originY + CGFloat.random(in: -20...20),
|
|
||||||
vx: cos(radians) * speed,
|
|
||||||
vy: -sin(radians) * speed, // negative = upward
|
|
||||||
spin: Double.random(in: -360...360),
|
|
||||||
delay: Double.random(in: 0...0.15)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
|
|
||||||
showCheck = true
|
|
||||||
}
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
|
|
||||||
withAnimation(.easeOut(duration: 0.3)) { showCheck = false }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Physics: gravity pulls down, particles drift and fade
|
struct CeilingDropModifier: ViewModifier {
|
||||||
struct PhysicsModifier: ViewModifier {
|
let x: CGFloat
|
||||||
let startX: CGFloat
|
|
||||||
let startY: CGFloat
|
let startY: CGFloat
|
||||||
let vx: CGFloat
|
let endY: CGFloat
|
||||||
let vy: CGFloat
|
let drift: CGFloat
|
||||||
let spin: Double
|
let spin: Double
|
||||||
let delay: Double
|
let delay: Double
|
||||||
let bounds: CGSize
|
let duration: Double
|
||||||
|
|
||||||
@State private var time: CGFloat = 0
|
@State private var progress: CGFloat = 0
|
||||||
@State private var opacity: Double = 1
|
@State private var opacity: Double = 1
|
||||||
|
|
||||||
private let gravity: CGFloat = 0.6
|
|
||||||
private let duration: Double = 1.6
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
let x = startX + vx * time
|
|
||||||
let y = startY + vy * time + 0.5 * gravity * time * time
|
|
||||||
|
|
||||||
content
|
content
|
||||||
.offset(x: x, y: y)
|
.position(
|
||||||
|
x: x + drift * progress,
|
||||||
|
y: startY + (endY - startY) * progress
|
||||||
|
)
|
||||||
.opacity(opacity)
|
.opacity(opacity)
|
||||||
.rotationEffect(Angle.degrees(spin * Double(time / 30)))
|
.rotation3DEffect(
|
||||||
|
Angle.degrees(spin * Double(progress)),
|
||||||
|
axis: (x: Double.random(in: 0...1), y: Double.random(in: 0...1), z: 0.5)
|
||||||
|
)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
withAnimation(.easeOut(duration: duration).delay(delay)) {
|
withAnimation(.easeIn(duration: duration).delay(delay)) {
|
||||||
time = 30
|
progress = 1
|
||||||
}
|
}
|
||||||
withAnimation(.easeIn(duration: 0.5).delay(delay + duration * 0.7)) {
|
withAnimation(.easeIn(duration: 0.3).delay(delay + duration * 0.8)) {
|
||||||
opacity = 0
|
opacity = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Star shape
|
|
||||||
struct Star: Shape {
|
|
||||||
let corners: Int
|
|
||||||
let smoothness: CGFloat
|
|
||||||
|
|
||||||
func path(in rect: CGRect) -> Path {
|
|
||||||
let center = CGPoint(x: rect.midX, y: rect.midY)
|
|
||||||
let outerRadius = min(rect.width, rect.height) / 2
|
|
||||||
let innerRadius = outerRadius * smoothness
|
|
||||||
var path = Path()
|
|
||||||
let step = .pi / CGFloat(corners)
|
|
||||||
|
|
||||||
for i in 0..<corners * 2 {
|
|
||||||
let angle = CGFloat(i) * step - .pi / 2
|
|
||||||
let radius = i.isMultiple(of: 2) ? outerRadius : innerRadius
|
|
||||||
let point = CGPoint(
|
|
||||||
x: center.x + cos(angle) * radius,
|
|
||||||
y: center.y + sin(angle) * radius
|
|
||||||
)
|
|
||||||
if i == 0 { path.move(to: point) }
|
|
||||||
else { path.addLine(to: point) }
|
|
||||||
}
|
|
||||||
path.closeSubpath()
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Assistant Sheet
|
// MARK: - Assistant Sheet
|
||||||
|
|
||||||
struct AssistantSheetView: View {
|
struct AssistantSheetView: View {
|
||||||
|
|||||||
Reference in New Issue
Block a user