This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We start building a particle effect, using transitions as the first approach.

00:06 Today, we want to try creating particles in SwiftUI. There's no built-in particle system, so we want to explore a few different approaches of implementing one and see how they perform.

00:33 Particle systems are used in various ways to create effects like snowflakes, rain, or animated meshes. But we want to start simply — we want to build a favorite button, and when we tap the button, it should shoot out a few stars. There are some third-party libraries that can do this, like Pow, but we'd like to see for ourselves how it's done.

01:08 The first thing we need is a button. As its label, we place a system image of a star, a divider, and a text view in an HStack. We also apply the .link button style:

struct ContentView: View {
    var body: some View {
        VStack {
            Button(action: { }, label: {
                HStack {
                    Image(systemName: "star.fill")
                    Divider()
                    Text("Favorite")
                }
            })
        }
        .buttonStyle(.link)
        .padding()
    }
}

02:10 We don't want the divider to grow to fill the available vertical space, so we give it a frame with a height of 18 points. The ScaledMetric property wrapper offers a simple way to adapt this height to the dynamic type setting, meaning it will grow or shrink along with the text:

struct ContentView: View {
    @ScaledMetric var dividerHeight = 18

    var body: some View {
        VStack {
            Button(action: { }, label: {
                HStack {
                    Image(systemName: "star.fill")
                    Divider()
                        .frame(height: dividerHeight)
                    Text("Favorite")
                }
                .contentShape(.rect)
            })
        }
        .buttonStyle(.link)
        .padding()
    }
}

Spray Effect

02:35 For our API, we want to be able to call a sprayEffect modifier on the star image, passing in a trigger value. Whenever the trigger value changes, it should set off an animation. The trigger value can be an integer, which we increment from the button's action closure:

struct ContentView: View {
    @ScaledMetric var dividerHeight = 18
    @State private var trigger = 0

    var body: some View {
        VStack {
            Button(action: {
                trigger += 1
            }, label: {
                HStack {
                    Image(systemName: "star.fill")
                        .sprayEffect(trigger: trigger)
                    Divider()
                        .frame(height: dividerHeight)
                    Text("Favorite")
                }
                .contentShape(.rect)
            })
        }
        .buttonStyle(.link)
        .padding()
    }
}

03:02 We implement sprayEffect in an extension of View. Since we don't want to hardcode the trigger value to be an integer, we make the method generic over any type of Hashable value:

extension View {
    func sprayEffect<Trigger: Hashable>(trigger: Trigger) -> some View {
        self
    }
}

03:35 To build the animation, we can add a number of copies of self in a background layer. And then, we can try using an offset transition, perhaps combined with an opacity transition, to animate the duplicated stars away from the center.

04:19 First, we add a ZStack with 30 copies of self in a background layer:

extension View {
    func sprayEffect<Trigger: Hashable>(trigger: Trigger) -> some View {
        self.background {
            ZStack {
                ForEach(0..<30) { _ in
                    self
                }
            }
        }
    }
}

04:46 We'll need random values for each copy of self, because we want to animate these stars to random positions. So we create a separate view modifier for a single particle. In it, we write a computed property in which we compose the transition we'll apply to the content view. For now, we can just apply a fixed offset transition:

struct ParticleModifier<T: Hashable>: ViewModifier {
    var offset: CGSize {
        .init(width: 50, height: 30)
    }

    var t: AnyTransition {
        .offset(offset)
    }

    func body(content: Content) -> some View {
        content
            .transition(t)
    }
}

06:08 We combine the offset transition with an opacity transition to make the stars fade out as they move away from the center. And since we want this transition to occur only when the view is removed from the view hierarchy, we wrap it in an .asymmetric transition, and we set the insertion transition to .identity, i.e. no transition:

struct ParticleModifier<T: Hashable>: ViewModifier {
    // ...

    var t: AnyTransition {
        .asymmetric(
            insertion: .identity,
            removal: .offset(offset).combined(with: .opacity)
        )
    }

    // ...
}

Triggering the Transition

06:33 Now we need to trigger the transition somehow. One way to do so is by using the trigger value as an explicit ID for the view. This ties the lifetime of the view to the trigger value, so SwiftUI considers it a new view each time the trigger changes, inserting and removing the particle with the transition we defined:

struct ParticleModifier<T: Hashable>: ViewModifier {
    var trigger: T

    var offset: CGSize {
        .init(width: 50, height: 30)
    }

    var t: AnyTransition {
        .asymmetric(
            insertion: .identity,
            removal: .offset(offset).combined(with: .opacity)
        )
    }

    func body(content: Content) -> some View {
        content
            .transition(t)
            .id(trigger)
    }
}

07:27 We still have to apply the modifier to our particle views, passing the trigger value along:

extension View {
    func sprayEffect<Trigger: Hashable>(trigger: Trigger) -> some View {
        self.background {
            ZStack {
                ForEach(0..<30) { _ in
                    self
                        .modifier(ParticleModifier(trigger: trigger))
                }
            }
        }
    }
}

07:55 Nothing happens when we click the button, because we haven't defined an animation curve for the transition, so we add a slowed-down version of the default animation:

struct ParticleModifier<T: Hashable>: ViewModifier {
    // ...

    func body(content: Content) -> some View {
        content
            .transition(t)
            .id(trigger)
            .animation(.default.speed(0.2), value: trigger)
    }
}

08:25 Now something does happen when we click the button, but all we see is the focus ring of the button changing size. When we apply the offset directly, we can see that it works correctly:

struct ParticleModifier<T: Hashable>: ViewModifier {
    // ...

    func body(content: Content) -> some View {
        content
            .offset(offset)
//            .transition(t)
            .id(trigger)
//            .animation(.default.speed(0.2), value: trigger)
    }
}

08:46 That means that the transition doesn't work. Sometimes when we experience a transition not behaving like we'd expect, it can be fixed by placing it in a stable container view. So, what we can try now is wrapping the content view, including its transition, in a ZStack. The stack view always stays around, while the content view gets removed and inserted:

struct ParticleModifier<T: Hashable>: ViewModifier {
    // ...

    func body(content: Content) -> some View {
        ZStack {
            content
                .transition(t)
                .id(trigger)
        }
        .animation(.default.speed(0.2), value: trigger)
    }
}

09:16 Now we see the background stars moving to their offset positions and changing opacity.

Randomized Offsets

09:43 This is a good start, even though it seems like there's just one animated star, because we set the offset to a static value. Let's generate some random values next.

10:01 We add a state property to ParticleModifier to store a random angle. For the offset's horizontal and vertical axes, we take the cosine and sine of the angle:

struct ParticleModifier<T: Hashable>: ViewModifier {
    var trigger: T

    @State var angle = Angle.degrees(.random(in: 0...360))

    var offset: CGSize {
        CGSize(
            width: cos(angle.radians),
            height: sin(angle.radians)
        )
    }

    // ...
}

10:32 This gives us an offset by one point in a random direction, so we need to multiply it by a certain distance (which could also be randomized in the future):

struct ParticleModifier<T: Hashable>: ViewModifier {
    var trigger: T

    @State var angle = Angle.degrees(.random(in: 0...360))

    var distance: Double = 40
    var offset: CGSize {
        CGSize(
            width: cos(angle.radians) * 40,
            height: sin(angle.radians) * 40
        )
    }

    // ...
}

11:19 Each duplicated star now moves in a random direction. But this direction is the same every time we click the button, because each star's angle is stored in a state property of a view modifier that stays in the view hierarchy, meaning its random value is generated only once. For a more organic effect, we can change the angle every time the trigger value changes:

struct ParticleModifier<T: Hashable>: ViewModifier {
    var trigger: T

    @State var angle = Angle.degrees(.random(in: 0...360))

    var distance: Double = 40
    var offset: CGSize {
        CGSize(
            width: cos(angle.radians) * distance,
            height: sin(angle.radians) * distance
        )
    }

    var t: AnyTransition {
        .asymmetric(
            insertion: .identity,
            removal: .offset(offset).combined(with: .opacity)
        )
    }

    func body(content: Content) -> some View {
        ZStack {
            content
                .transition(t)
                .id(trigger)
        }
        .animation(.default.speed(0.2), value: trigger)
        .onChange(of: trigger) {
            angle = Angle.degrees(.random(in: 0...360))
        }
    }
}

12:04 The star particles now fly off in a different direction each time we click the button. We can also see that the animation starts to become a bit laggy if we click repeatedly.

To prevent the button's focus ring from growing to fit around the particles, we can define the button's content shape:

struct ContentView: View {
    @ScaledMetric var dividerHeight = 18
    @State private var trigger = 0
    
    var body: some View {
        VStack {
            Button(action: {
                trigger += 1
            }, label: {
                HStack {
                    Image(systemName: "star.fill")
                        .sprayEffect(trigger: trigger)
                    Divider()
                        .frame(height: dividerHeight)
                    Text("Favorite")
                }
                .contentShape(.rect)
            })
        }
        .buttonStyle(.link)
        .padding()
    }
}

A More Custom Animation

12:33 This concludes our first attempt at implementing a particle effect for our button. We could fine-tune it with more parameters to also randomize the distance travelled by each star or to add a rotation to each star. But one aspect that definitely doesn't feel quite right is the fact that the duplicated stars are always sitting behind the main icon, waiting to transition away when we click the button. This would be especially problematic if our icon were semitransparent, because we'd see the background copies shining through.

13:26 Another challenging aspect is the fact that we have to use existing transitions, but what if we don't want the stars to move in a straight line, but to zigzag their way out of the center? If we can implement a transition that works with keyframes, we can make the particles move any way we want them to. Let's try that next time.

Resources

  • Sample Code

    Written in Swift 6.0

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

158 Episodes · 55h00min

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes