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 revisit the topic of staggered animations, this time allowing staggering of views in different branches of the view tree.

00:06 A while ago, we implemented three variants of a staggered animation in three episodes, starting from #330. We'll revisit this topic today.

00:29 We want to think of a way to have views appear in a staggered fashion, even if they're in different parts of our view tree. For example, we could have a header view and a few grid cells or buttons that aren't part of the same container view. It'd be great if we could simply call stagger on any view, and have those views appear one by one.

Setting Up

01:08 Let's first create some color values, which we'll use in our sample view. We write our own struct to wrap a SwiftUI Color, and we make it Identifiable. We also create an array of sample values by mapping a range of integers to hues:

import SwiftUI

struct MyColor: Identifiable {
    var id = UUID()
    var color: Color
}

let sampleColors = (0..<10).map { ix in
    MyColor(color: .init(hue: .init(ix) / 20, saturation: 0.8, brightness: 0.8))
}

02:07 In our ContentView, we define a rounded rectangle, and we use it to create a large blue shape. We place this shape in a VStack together with a LazyVGrid — which works kind of like a flow layout — in which we use the same rounded rectangle to create smaller shapes filled with the sample colors:

struct ContentView: View {
    var body: some View {
        let rect = RoundedRectangle(cornerRadius: 16)
        VStack {
            rect.fill(.blue)
                .frame(height: 120)
            LazyVGrid(columns: [.init(.adaptive(minimum: 80))]) {
                ForEach(sampleColors) { color in
                    rect.fill(color.color)
                        .frame(height: 80)
                }
            }
        }
    }
}

03:22 The .gradient modifier on a Color value gives some nice depth to our shapes. We also add spacing to the VStack, the LazyVGrid, and the columns inside the grid, and we add padding to the VStack:

struct ContentView: View {
    var body: some View {
        let rect = RoundedRectangle(cornerRadius: 16)
        VStack(spacing: 16) {
            rect.fill(.blue.gradient)
                .frame(height: 120)
            LazyVGrid(columns: [.init(.adaptive(minimum: 80), spacing: 16)], spacing: 16) {
                ForEach(sampleColors) { color in
                    rect.fill(color.color.gradient)
                        .frame(height: 80)
                }
            }
        }
        .padding()
    }
}

Stagger Modifier

03:57 To enable a stagger call on the rectangles, we want to write a modifier that propagates an ID of a view up the view hierarchy. At some common ancestor view, we'll collect these IDs into an array and use them to create a staggered animation:

struct ContentView: View {
    var body: some View {
        let rect = RoundedRectangle(cornerRadius: 16)
        VStack(spacing: 16) {
            rect.fill(.blue.gradient)
                .frame(height: 120)
                .stagger()
            LazyVGrid(columns: [.init(.adaptive(minimum: 80), spacing: 16)], spacing: 16) {
                ForEach(sampleColors) { color in
                    rect.fill(color.color.gradient)
                        .frame(height: 80)
                        .stagger()
                }
            }
        }
        .padding()
    }
}

05:13 The stagger method applies a Stagger view modifier. For now, we can see that it works by changing the opacity of the modifier's content view:

struct Stagger: ViewModifier {
    func body(content: Content) -> some View {
        content
            .opacity(0.5)
    }
}

extension View {
    func stagger() -> some View {
        modifier(Stagger())
    }
}

06:12 In Stagger, we want to create an ID for the view. We can use the @Namespace property wrapper for this. It gives us a value of type Namespace.ID, a unique ID that's stable throughout the view's lifetime, which is exactly what we need. An alternative to using a namespace could be an @State property wrapper with a UUID:

struct Stagger: ViewModifier {
    @Namespace var id

    func body(content: Content) -> some View {
        content
            .opacity(0.5)
    }
}

06:53 To propagate this ID up the view tree, we need a preference key. The value of this key will be an array of Namespace.ID, and in the reduce method, we combine the arrays from sibling views into a single array:

struct StaggerKey: PreferenceKey {
    static let defaultValue: [Namespace.ID] = []
    static func reduce(value: inout [Namespace.ID], nextValue: () -> [Namespace.ID]) {
        value.append(contentsOf: nextValue())
    }
}

07:28 In the Stagger modifier, we pass the ID, wrapped in an array, to the preference system:

struct Stagger: ViewModifier {
    @Namespace var id

    func body(content: Content) -> some View {
        content
            .opacity(0.5)
            .preference(key: StaggerKey.self, value: [id])
    }
}

Stagger Container

07:43 Next, we want to receive all these IDs somewhere higher up the view tree. We can do this in another view modifier, called StaggerContainer, which we use to wrap the entire body of our ContentView:

extension View {
    func stagger() -> some View {
        modifier(Stagger())
    }

    func staggerContainer() -> some View {
        modifier(StaggerContainer())
    }
}

struct ContentView: View {
    var body: some View {
        let rect = RoundedRectangle(cornerRadius: 16)
        VStack(spacing: 16) {
            // ...
        }
        .padding()
        .staggerContainer()
    }
}

08:22 In StaggerContainer, we call onPreferenceChange to read the array of view IDs from the children and we store it in a state variable:

struct StaggerContainer: ViewModifier {
    @State private var viewIDS: [Namespace.ID] = []

    func body(content: Content) -> some View {
        content
            .onPreferenceChange(StaggerKey.self) { viewIDs in
                self.viewIDS = viewIDs
            }
    }
}

09:08 Next, we can compute a delay for each of the IDs, and pass those delays back down the view tree through the environment. We add an entry to EnvironmentValues for this. For the default value, we use an empty array:

extension EnvironmentValues {
    @Entry var delays: [Namespace.ID: Double] = [:]
}

10:43 In StaggerContainer, we map over an enumeration of the viewIDs array in a computed property. This gives us pairs of offsets and IDs, which we convert to pairs of IDs and delays. These pairs can then be used to initialize a dictionary. Finally, we pass the dictionary from the computed property to the environment:

struct StaggerContainer: ViewModifier {
    @State private var viewIDS: [Namespace.ID] = []
    
    var delays: [Namespace.ID: Double] {
        Dictionary(uniqueKeysWithValues: viewIDS.enumerated().map { (ix, id) in
            (id, Double(ix) * 0.1)
        })
    }
    
    func body(content: Content) -> some View {
        content
            .environment(\.delays, delays)
            .onPreferenceChange(StaggerKey.self) { viewIDs in
                self.viewIDS = viewIDs
            }
    }
}

11:48 In each Stagger instance, we add a property to read the delays from the environment. We can overlay a text view with the delay for this view, just to see whether we're receiving the values correctly:

struct Stagger: ViewModifier {
    @Namespace var id
    @Environment(\.delays) var delays

    func body(content: Content) -> some View {
        content
            .overlay {
                Text("\(delays[id])")
            }
            .preference(key: StaggerKey.self, value: [id])
    }
}

Animating the Opacity

12:32 We now have everything we need to create the staggered animation. The only thing we need to do is observe the delays dictionary. As soon as we see the delay for the current view changing to a non-nil value, we can change the view's opacity with the appropriate delay.

13:13 We want to start out with an opacity of 0, and then later we'll flip it to 1 when the delay has been computed and passed down to us. But instead of directly changing the opacity, we add a Boolean state property to indicate whether or not the view should be visible:

struct Stagger: ViewModifier {
    @Namespace var id
    @Environment(\.delays) var delays
    @State private var visible = false

    func body(content: Content) -> some View {
        content
            .opacity(visible ? 1 : 0)
            .preference(key: StaggerKey.self, value: [id])
    }
}

13:46 Then we call onChange(of:) to observe the delays dictionary's entry for the current view. We can't be sure that the delay will be computed and passed down before the view first renders, so the delay might initially be nil. Therefore, we guard against the value being nil before mutating visible to true with the delay:

struct Stagger: ViewModifier {
    @Namespace var id
    @Environment(\.delays) var delays
    @State private var visible = false

    func body(content: Content) -> some View {
        content
            .opacity(visible ? 1 : 0)
            .preference(key: StaggerKey.self, value: [id])
            .onChange(of: delays[id]) { _, delay in
                guard let delay else { return }
                withAnimation(.default.delay(delay)) {
                    visible = true
                }
            }
    }
}

15:22 Nice! The rectangles now appear one by one.

15:37 As a possible next step, it'd be nice to provide some configurable options for the animation. For one, we might not want to animate the opacity of the view, but choose a different transition for the view. We could also want to change the sequencing of the views — instead of making them appear in the order in which they're placed in the view tree, it'd be nice if we could specify that they appear from left to right, or from top to bottom. Let's take a look at that next time.

Resources

  • Sample Code

    Written in Swift 6.0

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

176 Episodes · 61h15min

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