swiftuisprite-kitmetal

Apple Activity Sparkle ring effect with SwiftUI


I'm trying to recreate the sparkle effect from Apple's activity ring animation using SwiftUI. I've found Paul Hudson's Vortex library, which includes a sparkle effect, but as a beginner in SwiftUI animations, I'm struggling to modify it to match my vision. Can anyone offer guidance on how to achieve this effect?

Here's the Vortex project I'm referring to: Vortex project

This is what I envision it should look like: YouTube Link

This YouTube video shows the effect I'm aiming for: YouTube Link

I have attempted to implement it, but the result isn't what I expected. Here's my current code:

import SwiftUI
import Foundation
import Vortex

struct ContentView: View {
    
    @State private var isAnimatingFast = false
    var foreverAnimationFast: Animation {
        Animation.linear(duration: 1.0)
            .repeatForever(autoreverses: false)
    }
    
    @State private var isAnimatingSlow = false
    var foreverAnimationSlow: Animation {
        Animation.linear(duration: 1.5)
            .repeatForever(autoreverses: false)
    }
    
    var body: some View {
        ZStack {
            VortexView(customMagic) {
                Circle()
                    .fill(.blue)
                    .frame(width: 10, height: 10)
                    .tag("sparkle")
            }
            .frame(width: 250, height: 250)
            .rotationEffect(Angle(degrees: isAnimatingFast ? 360 : 0.0))
            .onAppear {
                withAnimation(foreverAnimationFast) {
                    isAnimatingFast = true
                }
            }
            .onDisappear { isAnimatingFast = false }
            
            VortexView(customSpark) {
                Circle()
                    .fill(.white)
                    .frame(width: 20, height: 20)
                    .tag("circle")
            }
            .rotationEffect(Angle(degrees: isAnimatingSlow ? 360 : 0.0))
            .onAppear {
                withAnimation(foreverAnimationSlow) {
                    isAnimatingSlow = true
                }
            }
            .onDisappear { isAnimatingSlow = false }
            
            VortexView(customSpark) {
                Circle()
                    .fill(.white)
                    .frame(width: 20, height: 20)
                    .tag("circle")
            }
            .rotationEffect(Angle(degrees: isAnimatingSlow ? 180 : -180))
            
            VortexView(customSpark) {
                Circle()
                    .fill(.white)
                    .frame(width: 20, height: 20)
                    .tag("circle")
            }
            .rotationEffect(Angle(degrees: isAnimatingSlow ? 90 : -370))
            
            VortexView(customSpark) {
                Circle()
                    .fill(.white)
                    .frame(width: 20, height: 20)
                    .tag("circle")
            }
            .rotationEffect(Angle(degrees: isAnimatingSlow ? 370 : -90))
        }
    }
}

let customMagic =
    VortexSystem(
        tags: ["sparkle"],
        shape: .ring(radius: 0.5),
        lifespan: 1.5,
        speed: 0,
        angleRange: .degrees(360),
        colors: .random(.red, .pink, .orange, .blue, .green, .white),
        size: 0.5
    )

let customSpark = VortexSystem(
    tags: ["circle"],
    birthRate: 150,
    emissionDuration: 0.2,
    idleDuration: 0,
    lifespan: 0.75,
    speed: 1,
    speedVariation: 0.2,
    angle: .degrees(330),
    angleRange: .degrees(20),
    acceleration: [0, 3],
    dampingFactor: 4,
    colors: .ramp(.white, .yellow, .yellow.opacity(0)),
    size: 0.1,
    sizeVariation: 0.1,
    stretchFactor: 8
)

#Preview {
    ContentView()
}

Any insights or suggestions on how to better match the desired animation effect would be greatly appreciated!


Solution

  • i'm guessing you will have a hard time reproducing that video using Vortex inside SwiftUI (as opposed to doing the whole thing using particles in spritekit). but here's a rough approximation

    import SwiftUI
    import Foundation
    import Vortex
    
    struct ContentView: View {
        @State private var isAnimating = false
        
        var body: some View {
            ZStack {
                
                Color.black
                 
                Group {
                    ForEach(0..<18) { index in
                        //a single pinwheel sparkler
                        VortexView(customSpark) {
                            Circle()
                                .fill(.white)
                                .blendMode(.plusLighter)
                                .frame(width: 32)
                                .tag("circle")
                        }
                        .frame(width:200, height:200)
                        .offset(y:-100)
                        .rotationEffect(Angle(degrees: Double(index) * 20))
                        .opacity(isAnimating ? 1 : 0)
                        .animation(
                             Animation.easeInOut(duration: 0.2)
                                 .delay(Double(index) * 0.075),
                             value: isAnimating
                         )
                    }
                    .onAppear {
                        withAnimation {
                            isAnimating = true
                        }
                        
                        //disappear in a ring
                        Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in
                            withAnimation {
                                isAnimating = false
                            }
                        }
                    }
                }
                .onAppear {
                    withAnimation {
                        isAnimating = true
                    }
                }
            }
        }
    }
    
    //pinwheel sparkler
    let customSpark = VortexSystem(
        tags: ["circle"],
        birthRate: 20,
        emissionDuration: 5,
        lifespan: 2,
        speed: 0.75,
        speedVariation: 0.5,
        angle: .degrees(90),
        angleRange: .degrees(8),
        colors: .ramp(.white, .red, .red.opacity(0)),
        size: 0.06
    )
    
    #Preview {
        ContentView()
    }