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 investigate different ways to create staggered animations, starting with delayed animations.

00:06 Today, we'll start a short new series about how to create staggered animations. We'll try out a few different ways of building a menu button from which the items pop up one by one.

Setting Up

00:45 Before we can animate, we need to set up our menu view. We want to display the menu in the bottom-right corner of the screen, so we wrap the existing content view in a flexible frame with a maximum width and height set to .infinity, and we add an overlay with a .bottomTrailing alignment:

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
        }
        .padding()
        .frame(maxWidth: .infinity, maxHeight: .infinity)
        .overlay(alignment: .bottomTrailing) {
            Menu()
                .padding(30)
        }
    }
}

01:25 The Menu view should hold a large button that allows us to toggle the menu. We use a system image as the button's "plus" icon, and we give the button a round background:

struct Menu: View {
    var body: some View {
        Image(systemName: "plus")
            .font(.title)
            .frame(width: 50, height: 50)
            .background {
                Circle()
                    .fill(Color.primary.opacity(0.1))
            }
    }
}

02:08 We wrap the image view in a Button. In the button's action closure, we toggle an open state property:

struct Menu: View {
    @State private var open = false

    var body: some View {
        Button {
            open.toggle()
        } label: {
            Image(systemName: "plus")
                .font(.title)
                .frame(width: 50, height: 50)
                .background {
                    Circle()
                        .fill(Color.primary.opacity(0.1))
                }
        }
    }
}

02:54 When opened, the menu should show its menu items above the menu button. So we wrap the button in a VStack, and depending on the open state, we include a few more icons:

struct Menu: View {
    @State private var open = false

    var body: some View {
        VStack {
            if open {
                Image(systemName: "note.text")
                Image(systemName: "photo")
                Image(systemName: "video")
            }
            Button {
                open.toggle()
            } label: {
                Image(systemName: "plus")
                    .font(.title)
                    .frame(width: 50, height: 50)
                    .background {
                        Circle()
                            .fill(Color.primary.opacity(0.1))
                    }
            }
        }
    }
}

04:35 We want to give each of the menu items some padding, a label, and a circle background that's slightly smaller than the menu button. But instead of writing this out for every item, we can create a reusable label style. First, we turn the menu items into labels:

struct Menu: View {
    @State private var open = false

    var body: some View {
        VStack {
            if open {
                Label("Add Note", systemImage: "note.text")
                Label("Add Photo", systemImage: "photo")
                Label("Add Video", systemImage: "video")
            }
            Button {
                // ...
            }
        }
    }
}

05:42 Then, we create our LabelStyle. To conform to this protocol, we need to return a view from the makeBody method, which receives a configuration struct holding the label's text and image. We place these items in an HStack, and we give the image a fixed frame and a round background:

struct MenuLabelStyle: LabelStyle {
    func makeBody(configuration: Configuration) -> some View {
        HStack {
            configuration.title
            configuration.icon
                .frame(width: 40, height: 40)
                .background {
                    Circle()
                        .foregroundColor(.primary.opacity(0.1))
                }
        }
    }
}

07:36 Finally, we set the new label style on the menu's VStack:

struct Menu: View {
    @State private var open = false

    var body: some View {
        VStack(alignment: .menu) {
            if open {
                Label("Add Note", systemImage: "note.text")
                Label("Add Photo", systemImage: "photo")
                Label("Add Video", systemImage: "video")
            }
            Button {
                // ...
            }
        }
        .labelStyle(MenuLabelStyle())
    }
}

Alignment

07:53 Next, we want to align the menu items to the menu button. When we try out a trailing alignment, we notice that the items need to move just a little bit to the left to vertically align the centers of the circles. Of course, we could manually add a padding of 5 points on the trailing side, but we can avoid using magic numbers by setting a custom alignment guide on the Circle.

09:08 We might also try modifying the .trailing alignment value of the circle, but that value won't propagate up out of the label's HStack, which has its own trailing alignment value. Only a custom alignment ID can propagate all the way up to the menu's VStack.

09:23 So, we write a MenuAlignment struct, and we conform it to AlignmentID. For the default alignment value, we return the horizontal center alignment value. This way, we don't have to define the custom alignment ID for the large menu button, since we'll be using its center guide by default:

struct MenuAlignment: AlignmentID {
    static func defaultValue(in context: ViewDimensions) -> CGFloat {
        context[HorizontalAlignment.center]
    }
}

10:06 We also create a static instance of the custom alignment to make it easier to apply it to a view:

extension HorizontalAlignment {
    static let menu = HorizontalAlignment(MenuAlignment.self)
}

10:20 Choosing the .menu alignment for the menu's VStack results in the default center alignment:

struct Menu: View {
    @State private var open = false

    var body: some View {
        VStack(alignment: .menu) {
            // ...
        }
        .labelStyle(MenuLabelStyle())
    }
}

10:27 But now we can override the .menu alignment value for a menu item's circle and return its center guide. This value then propagates up to the menu's VStack, which consequently aligns the centers of the circles in the menu items to the center of the menu button:

struct MenuLabelStyle: LabelStyle {
    func makeBody(configuration: Configuration) -> some View {
        HStack {
            configuration.title
            configuration.icon
                .frame(width: 40, height: 40)
                .background {
                    Circle()
                        .foregroundColor(.primary.opacity(0.1))
                }
                .alignmentGuide(.menu, computeValue: {
                    $0[HorizontalAlignment.center]
                })
        }
    }
}

10:50 The benefit of defining a custom alignment guide is that it wouldn't matter if we later change the dimensions of the icons — the views would still be correctly aligned without us having to adjust any hardcoded padding.

11:05 Before moving on, we adjust the font of the menu items to something a little smaller:

struct MenuLabelStyle: LabelStyle {
    func makeBody(configuration: Configuration) -> some View {
        HStack {
            // ...
        }
        .font(.footnote)
    }
}

Staggered Animations

11:32 Next, we want to animate the menu items when we toggle the menu. There are two ways of creating a staggered animation in which the menu items pop up one by one. One way is to write a custom Animatable modifier to drive the animation. The other way is to add separate transitions to the menu items and to give each transition's timing curve a slightly longer delay.

12:22 The latter approach — where each menu item gets its own animation and possibly its own transition — will be easier to set up, but it's harder to control the overall timing because we have to tweak multiple values. A single animation, on the other hand, takes more work to build, but we'll also have more control over the timeline.

13:08 Let's first try using individual transitions with different delays. We don't have to specify .transition(.opacity) for each item, because that's the default. But to see this transition, we specify a time curve using the animation modifier. This takes an Animation value, which can be given a delay. We want the items to appear bottom-up, so we give the top item the longest delay:

struct Menu: View {
    @State private var open = false
    
    var body: some View {
        VStack(alignment: .menu) {
            if open {
                Label("Add Note", systemImage: "note.text")
                    .animation(.default.delay(1), value: open)
                Label("Add Photo", systemImage: "photo")
                    .animation(.default.delay(0.5), value: open)
                Label("Add Video", systemImage: "video")
                    .animation(.default.delay(0), value: open)
            }
            // ...
        }
        .labelStyle(MenuLabelStyle())
    }
}

14:41 Unfortunately, this doesn't work: the menu items immediately appear without fading in when we open the menu. The problem is that the labels, along with their transitions, are inserted into and removed from the view hierarchy when we toggle the open state. The solution is to keep the transition stable in the view hierarchy, outside of the view that's appearing or disappearing. This is often as easy as moving the view that should be animated — including the visibility condition — into a stack view, and adding the transition to the stack view:

struct Menu: View {
    @State private var open = false
    
    var body: some View {
        VStack(alignment: .menu) {
            VStack {
                if open {
                    Label("Add Note", systemImage: "note.text")
                }
            }
            .animation(.default.delay(1), value: open)
            VStack {
                if open {
                    Label("Add Photo", systemImage: "photo")
                }
            }
            .animation(.default.delay(0.5), value: open)
            VStack {
                if open {
                    Label("Add Video", systemImage: "video")
                }
            }
            .animation(.default.delay(0), value: open)
            // ...
        }
        .labelStyle(MenuLabelStyle())
    }
}

16:44 Now we see the menu items appear one by one. They're also removed in the same way, for which we'll want to reverse the delays at some point.

View Modifier

16:59 To get rid of some duplication in our code, we move the transition logic into a view modifier:

struct Staggered: ViewModifier {
    var open: Bool
    var delay: Double
    
    func body(content: Content) -> some View {
        VStack {
            if open {
                content
            }
        }
        .animation(.default.delay(delay), value: open)
    }
}

extension View {
    func stagger(open: Bool, delay: Double) -> some View {
        modifier(Staggered(open: open, delay: delay))
    }
}

18:46 This cleans up our view a lot:

struct Menu: View {
    @State private var open = false
    
    var body: some View {
        VStack(alignment: .menu) {
            Label("Add Note", systemImage: "note.text")
                .stagger(open: open, delay: 1)
            Label("Add Photo", systemImage: "photo")
                .stagger(open: open, delay: 0.5)
            Label("Add Video", systemImage: "video")
                .stagger(open: open, delay: 0)
            Button {
                open.toggle()
            } label: {
                Image(systemName: "plus")
                    .font(.title)
                    .frame(width: 50, height: 50)
                    .background {
                        Circle()
                            .fill(Color.primary.opacity(0.1))
                    }
            }
        }
        .labelStyle(MenuLabelStyle())
    }
}

To Do

19:03 There are a few things we aren't yet happy with, most of which can be improved by creating a single animation for the entire menu. First of all: when the menu is closed, the labels go away, but the empty stack views stay around with spacing between them. This spacing would be visible if we were to add another element above the menu items.

19:39 Secondly, it feels a bit wrong to define the animations inside the Staggered view modifier. Typically, in SwiftUI, we're used to being able to specify a custom animation curve for a view, say by calling .animation on the outer Menu, rather than having to go into each of the menu items.

20:10 But the cool thing about the current setup is we can easily try out different kinds of built-in transitions. For example, we could make the menu items move up from the bottom while fading in:

struct Staggered: ViewModifier {
    var open: Bool
    var delay: Double
    
    func body(content: Content) -> some View {
        VStack {
            if open {
                content
                    .transition(.move(edge: .bottom).combined(with: .opacity))
            }
        }
        .animation(.default.delay(delay), value: open)
    }
}

20:58 We'll still have to look at changing the delays when closing the menu so that the menu items disappear in reverse order — from top to bottom. But that should be easily doable.

21:25 Next time, we'll abstract away the individual transitions and delays of the menu items and make the staggered animation work with a custom Animatable modifier.

Resources

  • Sample Code

    Written in Swift 5.7

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

135 Episodes · 48h28min

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