Files
Reflect/Shared/Views/CelebrationAnimations.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

865 lines
31 KiB
Swift

//
// CelebrationAnimations.swift
// Reflect
//
// Full-view celebration animations that play after voting.
//
import SwiftUI
// MARK: - Animation Type Enum
enum CelebrationAnimationType: String, CaseIterable, Identifiable {
case confettiCannon = "Confetti"
case vortexCheckmark = "Vortex"
case explosionReveal = "Explosion"
case flipReveal = "Flip"
case shatterReform = "Shatter"
case pulseWave = "Pulse Wave"
case fireworks = "Fireworks"
case morphBlob = "Morph"
case zoomTunnel = "Tunnel"
case gravityDrop = "Gravity"
var id: String { rawValue }
/// Int index for AppStorage persistence (enum uses String raw values)
var index: Int {
Self.allCases.firstIndex(of: self) ?? 0
}
/// Restore from persisted Int index
static func fromIndex(_ index: Int) -> CelebrationAnimationType {
guard index >= 0, index < allCases.count else { return .confettiCannon }
return allCases[index]
}
var icon: String {
switch self {
case .confettiCannon: return "party.popper.fill"
case .vortexCheckmark: return "tornado"
case .explosionReveal: return "sparkles"
case .flipReveal: return "rectangle.portrait.rotate"
case .shatterReform: return "square.grid.3x3"
case .pulseWave: return "dot.radiowaves.left.and.right"
case .fireworks: return "fireworks"
case .morphBlob: return "drop.fill"
case .zoomTunnel: return "tunnel.circle"
case .gravityDrop: return "arrow.down.to.line"
}
}
var description: String {
switch self {
case .confettiCannon: return "Party confetti"
case .vortexCheckmark: return "Sucked into center"
case .explosionReveal: return "Explodes outward"
case .flipReveal: return "3D card flip"
case .shatterReform: return "Shatters & reforms"
case .pulseWave: return "Expanding waves"
case .fireworks: return "Celebration burst"
case .morphBlob: return "Liquid transform"
case .zoomTunnel: return "Warp speed zoom"
case .gravityDrop: return "Falls with bounce"
}
}
var accentColor: Color {
switch self {
case .confettiCannon: return .pink
case .vortexCheckmark: return .purple
case .explosionReveal: return .orange
case .flipReveal: return .blue
case .shatterReform: return .red
case .pulseWave: return .green
case .fireworks: return .yellow
case .morphBlob: return .cyan
case .zoomTunnel: return .indigo
case .gravityDrop: return .mint
}
}
var duration: Double {
switch self {
case .confettiCannon: return 2.2
case .vortexCheckmark: return 2.0
case .explosionReveal: return 1.8
case .flipReveal: return 1.5
case .shatterReform: return 2.2
case .pulseWave: return 1.6
case .fireworks: return 2.5
case .morphBlob: return 2.0
case .zoomTunnel: return 1.8
case .gravityDrop: return 2.0
}
}
static var random: CelebrationAnimationType {
allCases.randomElement() ?? .confettiCannon
}
}
// MARK: - Celebration Overlay View
struct CelebrationOverlayView: View {
let animationType: CelebrationAnimationType
let mood: Mood
let onComplete: () -> Void
@State private var hasAppeared = false
var body: some View {
ZStack {
if hasAppeared {
animationView
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
hasAppeared = true
Task { @MainActor in
try? await Task.sleep(for: .seconds(animationType.duration))
onComplete()
}
}
}
@ViewBuilder
private var animationView: some View {
switch animationType {
case .vortexCheckmark:
VortexCheckmarkAnimation(mood: mood)
case .explosionReveal:
ExplosionRevealAnimation(mood: mood)
case .flipReveal:
FlipRevealAnimation(mood: mood)
case .shatterReform:
ShatterReformAnimation(mood: mood)
case .pulseWave:
PulseWaveAnimation(mood: mood)
case .fireworks:
FireworksAnimation(mood: mood)
case .confettiCannon:
ConfettiCannonAnimation(mood: mood)
case .morphBlob:
MorphBlobAnimation(mood: mood)
case .zoomTunnel:
ZoomTunnelAnimation(mood: mood)
case .gravityDrop:
GravityDropAnimation(mood: mood)
}
}
}
// MARK: - Animation 1: Vortex Checkmark
struct VortexCheckmarkAnimation: View {
let mood: Mood
@State private var spiralRotation: Double = 0
@State private var checkmarkScale: CGFloat = 0
@State private var checkmarkOpacity: Double = 0
@State private var ringScale: CGFloat = 0.5
@State private var ringOpacity: Double = 0
var body: some View {
ZStack {
ForEach(0..<8, id: \.self) { i in
Circle()
.stroke(mood.color.opacity(0.3), lineWidth: 2)
.frame(width: CGFloat(30 + i * 25), height: CGFloat(30 + i * 25))
.rotationEffect(.degrees(spiralRotation + Double(i * 15)))
}
Circle()
.stroke(mood.color, lineWidth: 4)
.frame(width: 100, height: 100)
.scaleEffect(ringScale)
.opacity(ringOpacity)
Image(systemName: "checkmark")
.font(.system(size: 50, weight: .bold))
.foregroundColor(mood.color)
.scaleEffect(checkmarkScale)
.opacity(checkmarkOpacity)
}
.onAppear {
withAnimation(.easeOut(duration: 0.5)) { spiralRotation = 180 }
withAnimation(.easeOut(duration: 0.3).delay(0.3)) { ringScale = 1.2; ringOpacity = 1 }
withAnimation(.easeIn(duration: 0.2).delay(0.6)) { ringScale = 1.0 }
withAnimation(.spring(response: 0.4, dampingFraction: 0.5).delay(0.5)) { checkmarkScale = 1.2; checkmarkOpacity = 1 }
withAnimation(.spring(response: 0.2, dampingFraction: 0.6).delay(0.7)) { checkmarkScale = 1.0 }
withAnimation(.easeOut(duration: 0.3).delay(1.5)) { checkmarkOpacity = 0; ringOpacity = 0 }
}
}
}
// MARK: - Animation 2: Explosion Reveal
struct ExplosionRevealAnimation: View {
let mood: Mood
@State private var particles: [ExplosionParticle] = []
@State private var checkmarkOffset: CGFloat = -200
@State private var checkmarkScale: CGFloat = 1.5
@State private var checkmarkOpacity: Double = 0
@State private var glowOpacity: Double = 0
struct ExplosionParticle: Identifiable {
let id = UUID()
let angle: Double
let distance: CGFloat
let size: CGFloat
let color: Color
var offset: CGFloat = 0
var opacity: Double = 1
var rotation: Double = 0
}
var body: some View {
ZStack {
ForEach(particles) { particle in
RoundedRectangle(cornerRadius: 4)
.fill(particle.color)
.frame(width: particle.size, height: particle.size)
.rotationEffect(.degrees(particle.rotation))
.offset(x: cos(particle.angle) * particle.offset, y: sin(particle.angle) * particle.offset)
.opacity(particle.opacity)
}
Circle()
.fill(RadialGradient(colors: [mood.color.opacity(0.5), .clear], center: .center, startRadius: 0, endRadius: 100))
.frame(width: 200, height: 200)
.opacity(glowOpacity)
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 70))
.foregroundColor(mood.color)
.offset(y: checkmarkOffset)
.scaleEffect(checkmarkScale)
.opacity(checkmarkOpacity)
}
.onAppear {
for i in 0..<20 {
let angle = Double(i) * (2 * .pi / 20) + Double.random(in: -0.2...0.2)
particles.append(ExplosionParticle(angle: angle, distance: CGFloat.random(in: 100...180), size: CGFloat.random(in: 8...20), color: [mood.color, mood.color.opacity(0.7), .white, .orange][i % 4]))
}
withAnimation(.easeOut(duration: 0.6)) { for i in particles.indices { particles[i].offset = particles[i].distance; particles[i].rotation = Double.random(in: 0...360) } }
withAnimation(.easeOut(duration: 0.4).delay(0.4)) { for i in particles.indices { particles[i].opacity = 0 } }
withAnimation(.easeInOut(duration: 0.3).delay(0.2)) { glowOpacity = 1 }
withAnimation(.easeOut(duration: 0.5).delay(0.6)) { glowOpacity = 0 }
withAnimation(.spring(response: 0.5, dampingFraction: 0.6).delay(0.3)) { checkmarkOffset = 0; checkmarkScale = 1.0; checkmarkOpacity = 1 }
withAnimation(.easeOut(duration: 0.3).delay(1.4)) { checkmarkOpacity = 0 }
}
}
}
// MARK: - Animation 3: Flip Reveal
struct FlipRevealAnimation: View {
let mood: Mood
@State private var showBack = false
@State private var checkmarkScale: CGFloat = 0.5
@State private var shimmerOffset: CGFloat = -200
var body: some View {
GeometryReader { geo in
ZStack {
RoundedRectangle(cornerRadius: 20)
.fill(LinearGradient(colors: [mood.color, mood.color.opacity(0.7)], startPoint: .topLeading, endPoint: .bottomTrailing))
.overlay(
Rectangle()
.fill(LinearGradient(colors: [.clear, .white.opacity(0.4), .clear], startPoint: .leading, endPoint: .trailing))
.frame(width: 60)
.offset(x: shimmerOffset)
.blur(radius: 5)
)
.clipShape(RoundedRectangle(cornerRadius: 20))
.overlay(
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 60))
.foregroundColor(.white)
.scaleEffect(checkmarkScale)
Text("Logged!")
.font(.title2.weight(.bold))
.foregroundColor(.white)
}
)
.opacity(showBack ? 1 : 0)
}
.frame(width: geo.size.width, height: geo.size.height)
}
.onAppear {
withAnimation(.easeInOut(duration: 0.3).delay(0.2)) { showBack = true }
withAnimation(.spring(response: 0.4, dampingFraction: 0.6).delay(0.4)) { checkmarkScale = 1.1 }
withAnimation(.spring(response: 0.2, dampingFraction: 0.7).delay(0.6)) { checkmarkScale = 1.0 }
withAnimation(.easeInOut(duration: 0.8).delay(0.5)) { shimmerOffset = 200 }
}
}
}
// MARK: - Animation 4: Shatter Reform
struct ShatterReformAnimation: View {
let mood: Mood
@State private var shardOffsets: [CGSize] = []
@State private var shardRotations: [Double] = []
@State private var shardOpacities: [Double] = []
@State private var phase: AnimationPhase = .initial
@State private var checkmarkOpacity: Double = 0
enum AnimationPhase { case initial, shatter, reform, complete }
private let shardCount = 12
var body: some View {
ZStack {
// Shards that explode outward
ForEach(0..<shardCount, id: \.self) { i in
Rectangle()
.fill(mood.color.opacity(Double.random(in: 0.5...1.0)))
.frame(width: 20, height: 20)
.rotationEffect(.degrees(shardRotations.indices.contains(i) ? shardRotations[i] : 0))
.offset(shardOffsets.indices.contains(i) ? shardOffsets[i] : .zero)
.opacity(shardOpacities.indices.contains(i) ? shardOpacities[i] : 1)
}
// Checkmark
Image(systemName: "checkmark")
.font(.system(size: 60, weight: .bold))
.foregroundColor(mood.color)
.opacity(checkmarkOpacity)
}
.onAppear {
// Initialize arrays
shardOffsets = Array(repeating: .zero, count: shardCount)
shardRotations = Array(repeating: 0, count: shardCount)
shardOpacities = Array(repeating: 1, count: shardCount)
// Phase 1: Explode outward
withAnimation(.easeOut(duration: 0.5)) {
for i in 0..<shardCount {
let angle = Double(i) * (2 * .pi / Double(shardCount))
let distance: CGFloat = CGFloat.random(in: 80...140)
shardOffsets[i] = CGSize(
width: cos(angle) * distance,
height: sin(angle) * distance
)
shardRotations[i] = Double.random(in: -180...180)
}
}
// Phase 2: Converge to center and fade
Task { @MainActor in
try? await Task.sleep(for: .seconds(0.6))
phase = .reform
withAnimation(.easeInOut(duration: 0.5)) {
for i in 0..<shardCount {
shardOffsets[i] = .zero
shardRotations[i] = 0
shardOpacities[i] = 0
}
}
}
// Phase 3: Show checkmark
Task { @MainActor in
try? await Task.sleep(for: .seconds(1.1))
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
checkmarkOpacity = 1
}
}
// Phase 4: Fade out
Task { @MainActor in
try? await Task.sleep(for: .seconds(1.8))
withAnimation(.easeOut(duration: 0.3)) {
checkmarkOpacity = 0
}
}
}
}
}
// MARK: - Animation 5: Pulse Wave
struct PulseWaveAnimation: View {
let mood: Mood
@State private var wave1Scale: CGFloat = 0.1
@State private var wave1Opacity: Double = 1
@State private var wave2Scale: CGFloat = 0.1
@State private var wave2Opacity: Double = 1
@State private var wave3Scale: CGFloat = 0.1
@State private var wave3Opacity: Double = 1
@State private var checkmarkScale: CGFloat = 3.0
@State private var checkmarkOpacity: Double = 0
@State private var stampRotation: Double = -15
var body: some View {
ZStack {
Circle().stroke(mood.color, lineWidth: 8).scaleEffect(wave1Scale).opacity(wave1Opacity)
Circle().stroke(mood.color.opacity(0.7), lineWidth: 6).scaleEffect(wave2Scale).opacity(wave2Opacity)
Circle().stroke(mood.color.opacity(0.4), lineWidth: 4).scaleEffect(wave3Scale).opacity(wave3Opacity)
ZStack {
Circle().fill(mood.color).frame(width: 80, height: 80)
Image(systemName: "checkmark").font(.system(size: 40, weight: .bold)).foregroundColor(.white)
}
.scaleEffect(checkmarkScale)
.opacity(checkmarkOpacity)
.rotationEffect(.degrees(stampRotation))
}
.frame(width: 300, height: 300)
.onAppear {
withAnimation(.easeOut(duration: 0.6)) { wave1Scale = 1.5; wave1Opacity = 0 }
withAnimation(.easeOut(duration: 0.6).delay(0.1)) { wave2Scale = 1.5; wave2Opacity = 0 }
withAnimation(.easeOut(duration: 0.6).delay(0.2)) { wave3Scale = 1.5; wave3Opacity = 0 }
withAnimation(.spring(response: 0.3, dampingFraction: 0.5).delay(0.2)) { checkmarkScale = 1.0; checkmarkOpacity = 1; stampRotation = 0 }
withAnimation(.easeOut(duration: 0.3).delay(1.2)) { checkmarkOpacity = 0 }
}
}
}
// MARK: - Animation 6: Fireworks
struct FireworksAnimation: View {
let mood: Mood
@State private var bursts: [[FireworkParticle]] = []
@State private var checkmarkScale: CGFloat = 0
@State private var checkmarkOpacity: Double = 0
struct FireworkParticle: Identifiable {
let id = UUID()
let angle: Double
let color: Color
var distance: CGFloat = 0
var opacity: Double = 1
var scale: CGFloat = 1
}
var body: some View {
ZStack {
ForEach(0..<bursts.count, id: \.self) { burstIndex in
let offset = burstOffsets[burstIndex]
ZStack {
ForEach(bursts[burstIndex]) { particle in
Circle()
.fill(particle.color)
.frame(width: 6, height: 6)
.scaleEffect(particle.scale)
.offset(
x: cos(particle.angle) * particle.distance,
y: sin(particle.angle) * particle.distance
)
.opacity(particle.opacity)
}
}
.offset(x: offset.x, y: offset.y)
}
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 60))
.foregroundStyle(
LinearGradient(colors: [mood.color, mood.color.opacity(0.7)], startPoint: .top, endPoint: .bottom)
)
.scaleEffect(checkmarkScale)
.opacity(checkmarkOpacity)
}
.onAppear {
let colors: [Color] = [mood.color, .yellow, .orange, .pink, .white]
for _ in 0..<4 {
var particles: [FireworkParticle] = []
for i in 0..<12 {
let angle = Double(i) * (2 * .pi / 12)
particles.append(FireworkParticle(angle: angle, color: colors.randomElement()!))
}
bursts.append(particles)
}
for burstIndex in 0..<4 {
let delay = Double(burstIndex) * 0.15
withAnimation(.easeOut(duration: 0.5).delay(delay)) {
for i in bursts[burstIndex].indices {
bursts[burstIndex][i].distance = CGFloat.random(in: 40...70)
}
}
withAnimation(.easeOut(duration: 0.4).delay(delay + 0.3)) {
for i in bursts[burstIndex].indices {
bursts[burstIndex][i].opacity = 0
bursts[burstIndex][i].scale = 0.3
}
}
}
withAnimation(.spring(response: 0.4, dampingFraction: 0.6).delay(0.6)) {
checkmarkScale = 1.1
checkmarkOpacity = 1
}
withAnimation(.spring(response: 0.2, dampingFraction: 0.7).delay(0.8)) {
checkmarkScale = 1.0
}
withAnimation(.easeOut(duration: 0.3).delay(2.0)) {
checkmarkOpacity = 0
}
}
}
private var burstOffsets: [CGPoint] {
[CGPoint(x: -60, y: -50), CGPoint(x: 70, y: -40), CGPoint(x: -50, y: 60), CGPoint(x: 60, y: 50)]
}
}
// MARK: - Animation 7: Confetti Cannon
struct ConfettiCannonAnimation: View {
let mood: Mood
@State private var confetti: [ConfettiPiece] = []
@State private var checkmarkScale: CGFloat = 0
@State private var checkmarkOpacity: Double = 0
@State private var glowScale: CGFloat = 0.5
@State private var glowOpacity: Double = 0
struct ConfettiPiece: Identifiable {
let id = UUID()
let color: Color
let size: CGSize
let xVelocity: CGFloat
var yOffset: CGFloat = 200
var rotation: Double = 0
var opacity: Double = 1
}
var body: some View {
ZStack {
Circle()
.fill(RadialGradient(colors: [mood.color.opacity(0.4), .clear], center: .center, startRadius: 0, endRadius: 80))
.frame(width: 160, height: 160)
.scaleEffect(glowScale)
.opacity(glowOpacity)
ForEach(confetti) { piece in
RoundedRectangle(cornerRadius: 2)
.fill(piece.color)
.frame(width: piece.size.width, height: piece.size.height)
.rotationEffect(.degrees(piece.rotation))
.offset(x: piece.xVelocity, y: piece.yOffset)
.opacity(piece.opacity)
}
Image(systemName: "checkmark")
.font(.system(size: 50, weight: .bold))
.foregroundColor(mood.color)
.scaleEffect(checkmarkScale)
.opacity(checkmarkOpacity)
}
.onAppear {
let colors: [Color] = [mood.color, .yellow, .pink, .orange, .cyan, .white]
for i in 0..<30 {
confetti.append(ConfettiPiece(
color: colors[i % colors.count],
size: CGSize(width: CGFloat.random(in: 6...12), height: CGFloat.random(in: 8...16)),
xVelocity: CGFloat.random(in: -120...120)
))
}
withAnimation(.easeOut(duration: 0.5)) {
for i in confetti.indices {
confetti[i].yOffset = CGFloat.random(in: -150 ... -80)
confetti[i].rotation = Double.random(in: -180...180)
}
}
withAnimation(.easeIn(duration: 1.0).delay(0.5)) {
for i in confetti.indices {
confetti[i].yOffset = 200
confetti[i].rotation += Double.random(in: 180...540)
}
}
withAnimation(.easeIn(duration: 0.3).delay(1.3)) {
for i in confetti.indices { confetti[i].opacity = 0 }
}
withAnimation(.easeOut(duration: 0.3).delay(0.1)) { glowScale = 1.2; glowOpacity = 1 }
withAnimation(.easeIn(duration: 0.4).delay(0.5)) { glowOpacity = 0 }
withAnimation(.spring(response: 0.4, dampingFraction: 0.5).delay(0.3)) { checkmarkScale = 1.2; checkmarkOpacity = 1 }
withAnimation(.spring(response: 0.2, dampingFraction: 0.6).delay(0.5)) { checkmarkScale = 1.0 }
withAnimation(.easeOut(duration: 0.3).delay(1.8)) { checkmarkOpacity = 0 }
}
}
}
// MARK: - Animation 8: Morph Blob
struct MorphBlobAnimation: View {
let mood: Mood
@State private var blobScale: CGFloat = 0.3
@State private var blobOffset1: CGSize = .zero
@State private var blobOffset2: CGSize = .zero
@State private var blobOffset3: CGSize = .zero
@State private var checkmarkOpacity: Double = 0
var body: some View {
ZStack {
ZStack {
Circle()
.fill(mood.color.opacity(0.6))
.frame(width: 100, height: 100)
.offset(blobOffset1)
.blur(radius: 20)
Circle()
.fill(mood.color.opacity(0.5))
.frame(width: 80, height: 80)
.offset(blobOffset2)
.blur(radius: 15)
Circle()
.fill(mood.color.opacity(0.7))
.frame(width: 60, height: 60)
.offset(blobOffset3)
.blur(radius: 10)
}
.scaleEffect(blobScale)
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 60))
.foregroundColor(.white)
.shadow(color: mood.color, radius: 10)
.opacity(checkmarkOpacity)
}
.onAppear {
withAnimation(.easeOut(duration: 0.4)) {
blobScale = 1.5
blobOffset1 = CGSize(width: -30, height: -40)
blobOffset2 = CGSize(width: 40, height: 20)
blobOffset3 = CGSize(width: -20, height: 50)
}
withAnimation(.easeInOut(duration: 0.5).delay(0.4)) {
blobOffset1 = CGSize(width: 40, height: 30)
blobOffset2 = CGSize(width: -30, height: -30)
blobOffset3 = CGSize(width: 20, height: -40)
}
withAnimation(.easeInOut(duration: 0.4).delay(0.9)) {
blobOffset1 = .zero
blobOffset2 = .zero
blobOffset3 = .zero
blobScale = 0.8
}
withAnimation(.spring(response: 0.3, dampingFraction: 0.6).delay(1.2)) {
checkmarkOpacity = 1
}
withAnimation(.easeOut(duration: 0.3).delay(1.7)) {
checkmarkOpacity = 0
blobScale = 0
}
}
}
}
// MARK: - Animation 9: Zoom Tunnel
struct ZoomTunnelAnimation: View {
let mood: Mood
@State private var rings: [TunnelRing] = []
@State private var checkmarkScale: CGFloat = 0
@State private var checkmarkOpacity: Double = 0
@State private var starburstRotation: Double = 0
struct TunnelRing: Identifiable {
let id = UUID()
var scale: CGFloat
var opacity: Double
}
var body: some View {
ZStack {
ForEach(rings) { ring in
Circle()
.stroke(mood.color.opacity(0.5), lineWidth: 3)
.scaleEffect(ring.scale)
.opacity(ring.opacity)
}
ForEach(0..<8, id: \.self) { i in
Rectangle()
.fill(LinearGradient(colors: [mood.color, .clear], startPoint: .center, endPoint: .trailing))
.frame(width: 150, height: 4)
.offset(x: 75)
.rotationEffect(.degrees(Double(i) * 45 + starburstRotation))
.opacity(checkmarkOpacity * 0.6)
}
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 70))
.foregroundStyle(
LinearGradient(colors: [.white, mood.color], startPoint: .topLeading, endPoint: .bottomTrailing)
)
.shadow(color: mood.color, radius: 15)
.scaleEffect(checkmarkScale)
.opacity(checkmarkOpacity)
}
.onAppear {
for i in 0..<6 {
rings.append(TunnelRing(scale: CGFloat(3.0 - Double(i) * 0.4), opacity: 0))
}
for i in rings.indices {
let delay = Double(i) * 0.1
withAnimation(.easeIn(duration: 0.6).delay(delay)) {
rings[i].scale = 0.1
rings[i].opacity = 1
}
withAnimation(.easeIn(duration: 0.2).delay(delay + 0.4)) {
rings[i].opacity = 0
}
}
withAnimation(.linear(duration: 2.0)) {
starburstRotation = 45
}
withAnimation(.spring(response: 0.4, dampingFraction: 0.6).delay(0.5)) {
checkmarkScale = 1.1
checkmarkOpacity = 1
}
withAnimation(.spring(response: 0.2, dampingFraction: 0.7).delay(0.7)) {
checkmarkScale = 1.0
}
withAnimation(.easeOut(duration: 0.3).delay(1.5)) {
checkmarkOpacity = 0
}
}
}
}
// MARK: - Animation 10: Gravity Drop
struct GravityDropAnimation: View {
let mood: Mood
@State private var checkmarkOffset: CGFloat = -200
@State private var checkmarkRotation: Double = -30
@State private var checkmarkScale: CGFloat = 0.5
@State private var checkmarkOpacity: Double = 0
@State private var impactRingScale: CGFloat = 0.3
@State private var impactRingOpacity: Double = 0
@State private var dustParticles: [DustParticle] = []
struct DustParticle: Identifiable {
let id = UUID()
var xOffset: CGFloat
var yOffset: CGFloat = 0
var opacity: Double = 0
}
var body: some View {
ZStack {
Circle()
.stroke(mood.color.opacity(0.5), lineWidth: 3)
.scaleEffect(impactRingScale)
.opacity(impactRingOpacity)
.offset(y: 30)
ForEach(dustParticles) { particle in
Circle()
.fill(mood.color.opacity(0.4))
.frame(width: 8, height: 8)
.offset(x: particle.xOffset, y: particle.yOffset + 30)
.opacity(particle.opacity)
}
ZStack {
Circle()
.fill(mood.color)
.frame(width: 80, height: 80)
.shadow(color: mood.color.opacity(0.5), radius: 10, y: 5)
Image(systemName: "checkmark")
.font(.system(size: 40, weight: .bold))
.foregroundColor(.white)
}
.offset(y: checkmarkOffset)
.rotationEffect(.degrees(checkmarkRotation))
.scaleEffect(checkmarkScale)
.opacity(checkmarkOpacity)
}
.onAppear {
for i in 0..<8 {
dustParticles.append(DustParticle(xOffset: CGFloat(i - 4) * 20))
}
withAnimation(.easeIn(duration: 0.1)) {
checkmarkOpacity = 1
}
withAnimation(.easeIn(duration: 0.4)) {
checkmarkOffset = 30
checkmarkRotation = 10
checkmarkScale = 1.0
}
withAnimation(.easeOut(duration: 0.2).delay(0.4)) {
checkmarkOffset = -20
checkmarkRotation = -5
}
withAnimation(.easeIn(duration: 0.15).delay(0.6)) {
checkmarkOffset = 30
checkmarkRotation = 3
}
withAnimation(.easeOut(duration: 0.1).delay(0.75)) {
checkmarkOffset = 20
checkmarkRotation = 0
}
withAnimation(.easeInOut(duration: 0.1).delay(0.85)) {
checkmarkOffset = 30
}
withAnimation(.easeOut(duration: 0.4).delay(0.4)) {
impactRingScale = 1.5
impactRingOpacity = 0.8
}
withAnimation(.easeOut(duration: 0.3).delay(0.6)) {
impactRingOpacity = 0
}
withAnimation(.easeOut(duration: 0.4).delay(0.4)) {
for i in dustParticles.indices {
dustParticles[i].yOffset = CGFloat.random(in: -30 ... -10)
dustParticles[i].opacity = 0.8
}
}
withAnimation(.easeIn(duration: 0.4).delay(0.7)) {
for i in dustParticles.indices {
dustParticles[i].yOffset = 20
dustParticles[i].opacity = 0
}
}
withAnimation(.easeOut(duration: 0.3).delay(1.6)) {
checkmarkOpacity = 0
}
}
}
}