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.