Swiftui Animations by Tutorials
Swiftui Animations by Tutorials
Swiftui Animations by Tutorials
Notice of Rights
All rights reserved. No part of this book or corresponding materials (such as text,
images, or source code) may be reproduced or distributed by any means without
prior written permission of the copyright owner.
Notice of Liability
This book and all corresponding materials (such as source code) are provided on an
“as is” basis, without warranty of any kind, express of implied, including but not
limited to the warranties of merchantability, fitness for a particular purpose, and
noninfringement. In no event shall the authors or copyright holders be liable for any
claim, damages or other liability, whether in action of contract, tort or otherwise,
arising from, out of or in connection with the software or the use of other dealing in
the software.
Trademarks
All trademarks and registered trademarks appearing in this book are the property of
their own respective owners.
2
SwiftUI Animations by Tutorials
3
SwiftUI Animations by Tutorials
4
SwiftUI Animations by Tutorials
5
SwiftUI Animations by Tutorials
Challenge . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 183
Key Points . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 184
Where to Go From Here?. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 184
Chapter 7: Complex Custom Animations . . . . . . . . . . . . . . . . . . 185
Adding a Popup Button . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 186
Adding Button Options . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 187
Animating the Options . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 191
Animating Multiple Properties . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 193
Creating a Radar Chart . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 195
Adding Grid Lines . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 198
Coloring the Radar Chart. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 200
Using the Radar Chart . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 203
Animating the Radar Chart . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 205
Key Points . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 209
Where to Go From Here?. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 209
Chapter 8: Time-Based Animations . . . . . . . . . . . . . . . . . . . . . . . . 210
Exploring the TimelineView . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 211
Drawing With a Canvas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 214
Drawing Tick Marks . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 217
Adding Text to a Canvas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 219
Letting the Timer… Time . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 222
Adding the Minute Hand . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 226
Improving TimelineView Performance . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 228
Challenge . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 230
Key Points . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 231
Where to Go From Here?. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 231
Chapter 9: Combining Animations . . . . . . . . . . . . . . . . . . . . . . . . . 232
Building a Background Animation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 233
Making a Wave Animation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 236
Animating the Sine Wave . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 238
6
SwiftUI Animations by Tutorials
7
L Book License
• You are allowed to use and/or modify the source code in SwiftUI Animations by
Tutorials in as many apps as you want, with no attribution required.
• You are allowed to use and/or modify all art, images and designs that are included
in SwiftUI Animations by Tutorials in as many apps as you want, but must include
this attribution line somewhere inside your app: “Artwork/images/designs: from
SwiftUI Animations by Tutorials, available at www.kodeco.com”.
• The source code included in SwiftUI Animations by Tutorials is for your personal
use only. You are NOT allowed to distribute or sell the source code in SwiftUI
Animations by Tutorials without prior authorization.
• This book is for your personal use only. You are NOT allowed to reproduce or
transmit any part of this book by any means, electronic or mechanical, including
photocopying, recording, etc. without previous authorization. You may not sell
digital versions of this book or distribute them to friends, coworkers or students
without prior authorization. They need to purchase their own copies.
All materials provided with this book are provided on an “as is” basis, without
warranty of any kind, express or implied, including but not limited to the warranties
of merchantability, fitness for a particular purpose and noninfringement. In no event
shall the authors or copyright holders be liable for any claim, damages or other
liability, whether in an action of contract, tort or otherwise, arising from, out of or in
connection with the software or the use or other dealings in the software.
All trademarks and registered trademarks appearing in this guide are the properties
of their respective owners.
8
Before You Begin
This section tells you a few things you need to know before you get started, such as
what you’ll need for hardware and software, where to find the project files for this
book, and more.
9
i What You Need
• Xcode 14 or later. Xcode is the main development tool for iOS. You’ll need Xcode
14 or later for the tasks in this book. SwiftUI evolves rapidly so having the latest
version of Xcode can be crucial to having a smooth reading experience. You can
download the latest version of Xcode from Apple’s developer site here: apple.co/
2asi58y
• An intermediate level knowledge of Swift and SwiftUI. This book teaches you
how to leverage basic and advanced animations in SwiftUI. Even though you’ll use
Swift and SwiftUI throughout this book, its focus isn’t about these topics, so you
should have at least an intermediate-level knowledge of Swift.
If you want to try things out on a physical iOS device, you’ll need a developer
account with Apple, which you can obtain for free. However, all the sample projects
in this book will work just fine in the iOS Simulator bundled with Xcode, so a paid
developer account is completely optional.
10
ii Book Source Code &
Forums
• https://github.com/kodecocodes/sat-materials/tree/editions/1.0
Forums
We’ve also set up an official forum for the book at https://forums.kodeco.com/c/
books/swiftui-animations-by-tutorials. This is a great place to ask questions about
the book or to submit any errors you may find.
11
“To my family”
— Irina Galata
— Bill Morefield
12
SwiftUI Animations by Tutorials About the Team
13
SwiftUI Animations by Tutorials About the Team
14
v Introduction
SwiftUI has absolutely changed our lives when it comes to developer experience and
developer productivity. We can make beautiful apps extremely quickly, get instant
feedback from SwiftUI previews, and iterate. SwiftUI also enables developers to
easily leverage most common animations using simple SwiftUI modifiers, which
makes it a pleasure to use. But it also begs the question: “How do I make my app
stand out if everyone is using the same standard animations?”
It cannot be overstated how much animations matter in an app. It’s not just about
usability, it’s about your app’s “signature”, a feel of quality and craftsmanship, the
comforting feeling your users get that you’re going above and beyond to take care of
them. Animations are what separate good apps from the best.
This book aims to push the envelope for seasoned developers who might know how
to leverage SwiftUI’s basic animation system but aren’t aware of the many advanced
concepts they can leverage to bring their animations to that next level of
craftsmanship and interactivity, broadening the reader’s horizons and creative
thinking.
15
SwiftUI Animations by Tutorials Introduction
Throughout your journey, you’ll learn everything you need to create outstanding and
memorable animations. You’ll learn about the engine that drives SwiftUI animations,
explore animations, transitions, matched geometry, gestures and everything in
between!
16
Section I: SwiftUI Animations
by Tutorials
17
1 Chapter 1: Introducing
SwiftUI Animations
By Bill Morefield
Small touches can help your app stand out from the competition in the crowded App
Store. Animations provide one of these small delightful details.
Used correctly, animations show an attention to detail that your users will appreciate
and a unique style they’ll remember. Using animations purposefully provides your
users subtle and practical feedback as your app’s state changes.
Up until the release of SwiftUI, creating animations was quite a tedious task, even for
the simplest of animations. Luckily, SwiftUI is often clever enough to automatically
animate your state changes, or provide you with more granular control when the
default animations don’t cut it.
First, you’ll explore the basic native animations included in SwiftUI resulting from
state changes, the transformation of a value that a view depends on. You’ll then
explore view transitions, a type of animation that SwiftUI applies to views when
inserted or removed from the screen. These animations provide a base of knowledge
you’ll use throughout this book.
18
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
Creating Animations
To begin, download and extract the materials for this chapter. Open the starter
folder, which contains the starter project for this chapter. Then, open
AnimationCompare.xcodeproj to start working on this chapter.
Run the project by selecting Product ▸ Run or press Cmd-R. When the project starts
in the simulator, you’ll see two tabs:
The first tab contains the user interface for an app that helps a developer explore
different types of animations and manipulate various animation parameters to see
their effects. The user can add multiple animations and run them in tandem.
The second tab contains a red square you can show or hide using a button. You’ll use
this tab to explore view transitions later in this chapter.
19
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
As the state changes, you’ll use this property to animate views in your app. First, you
need to provide a way for the user to change this state. Immediately inside the
VStack, add the following code:
// 1
Button("Animate!") {
// 2
location = location == 0 ? 1 : 0
}
.font(.title)
.disabled(animations.isEmpty)
1. Creates a button that changes the state property location to animate views
when tapped.
2. Toggles the value of location between 0.0 and 1.0. You’ll use this later to
animate the views on screen.
Notice that you disable the button when the animations array is empty. This
prevents users from tapping the button before creating any animations.
20
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
This property contains the location passed in from the parent view. When the value
changes in AnimationCompareView, it also changes inside this view, since it’s a
Binding. Inside each AnimationView, SwiftUI will notice the state change and
trigger two animations that you specify.
HStack {
// 1
Image(systemName: "gear.circle")
.rotationEffect(.degrees(360 * location))
Image(systemName: "star.fill")
// 2
.offset(x: proxy.size.width * location * 0.8)
}
.font(.title)
// 3
.animation(
// 4
.linear(duration: animation.length),
// 5
value: location
)
1. You place two images in an HStack. You apply a rotation effect to the first image
that multiplies the location property by 360 degrees. Since location will vary
between zero and one, the result will toggle between zero and 360 degrees. The
key is that a change in location changes the view’s state.
2. The second image has an offset applied that multiples the width of the view,
taken from the GeometryProxy, by the location property and multiples that by
0.8. As a result, when location is zero, the offset is zero, and when location is
one, the offset is 80% of the width of the view. Since SwiftUI applies the offset to
the view’s leading edge, multiplying by 0.8 keeps the view from floating off the
screen.
3. There are several ways to tell SwiftUI you want to animate a state change. Here
you use animation(_:value:) on the HStack. This method creates the most
straightforward SwiftUI animation, an implicit animation. You apply it to the
HStack so that both views included within have the animation applied to them.
Sounds simple? That’s the beauty of animations in SwiftUI!
21
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
5. When you apply an implicit animation, you specify the value whose change will
trigger the animation. Explicitly setting the state change lets you use different
animations with different state changes.
Run the app and add an animation. Next, tap Animate!. The gear icon makes one
revolution, and the star slides to the right side of the view.
Linear Animation
22
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
If you tap Animate! again, you’ll see the gear spin in the opposite direction while
the star slides back to the left. Think for a moment about why the opposite
movement takes place. Here’s a hint: remember what the Animate! button does.
Since the Animate! button returns the property to its original value of zero, the
animation reverses. The rotationEffect(_:anchor:) method interprets greater
values as clockwise rotation. Therefore, the initial change from zero to one turns
into a degree change from zero to 360. This change animates as a set of increasing
clockwise rotations. The change back to zero causes counterclockwise animation as
the value decreases.
Linear animations work best for views that pass through but do not start or end
within the scene. In the real world, a car passing by a window would look routine
while moving at a constant speed, but a vehicle instantly achieving full speed from a
stop would seem odd. Our minds expect something that starts or stops within our
view to accelerate or decelerate.
In the next section, you’ll explore eased animations, another type of animation that
matches this behavior.
The most common is the ease out animation. It starts faster than a linear animation
before decelerating toward the end. Ease out animations are often the best choice in
a user interface since that fast initial motion gives the feeling your app is quickly
responding to the user. Here’s the graph of the movement against time:
Ease Out
23
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
An ease in animation reverses these steps. It starts more slowly than a linear
animation before accelerating. If you were to graph the movement against time, it
would look like this:
Ease In
The next eased animation combines the previous two. Ease in-out animations
accelerate, as in the ease in animation, before decelerating, as in the ease out
animation. For ease in and ease in-out animations, you usually want to keep the
duration less than 0.5 seconds so it feels more responsive to your user.
The movement graphed against time looks like a combination of the other two
graphs:
Ease In Out
24
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
.animation(
currentAnimation,
value: location
)
This code sets the animation using the new computed property you just added, so it
has the animation specified in the animation struct. Any animation you haven’t
implemented will fall back to a linear animation.
25
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
Run the app and create two animations. Tap the second and change the type to Ease
In-Out.
26
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
Tap Back and then Animate! to see the difference between the two animations.
Notice the ease in out animation moves slower at first before passing the linear
animation and then slowing down at the end. Since you specified the same duration
for both animations, they take the same time to complete.
27
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
Add two more animations and set one to Ease In and the other to Ease Out. Change
the length of one animation and rerun it to see how they compare. Notice the shape
of the animation doesn’t change. Only the time it takes the animation to complete
changes.
In the next section, you’ll learn two common modifiers that help you customize the
duration of your animation.
28
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
Modifying Animations
By default, an animation starts immediately when the state changes, but since your
app lets the user specify a delay for each animation, you’ll add support for it. Open
AnimationView.swift and replace the current animation(_:value:) modifier
with:
.animation(
currentAnimation
.delay(animation.delay),
value: location
)
You add the delay(_:) modifier to the animation and specify the delay in seconds.
Run the app and add two animations. Edit the second animation and set the delay to
0.5 seconds. Tap Back and tap the Animate! button to see the effect.
29
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
While the first animation begins when you tap the button, the second animation
doesn’t start until 0.5 seconds later. A delay doesn’t affect the duration or movement
of the animation. However, it can provide a sense of flow or order between multiple
animations tied to a single state change.
A value lower than one will result in a slower animation, while a value greater than
one will speed up the animation.
You’ll use this to implement a slowdown effect that will make it easier for the user to
notice the differences between animations, similar to the Simulator menu option.
You’ll use this boolean property to indicate when the user wants to slow the
animations. Next, add the following toggle control to the view as the first item inside
the List, before the ForEach:
Now the user can use this toggle to specify when they want to slow down the
animations. Next, open AnimationView.swift and add the following property.
The parent view can now indicate when to slow down the animations on this view,
defaulting to false.
.animation(
currentAnimation
.delay(animation.delay)
.speed(slowMotion ? 0.25 : 1.0),
value: location
)
30
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
Recall that values lower than one passed to the speed(_:) modifier cause the
animation to slow down. Passing 0.25 will cause the animation to take four times as
long (1 / 0.25 = 4) as it otherwise would have.
AnimationView(
animation: animation,
location: $location,
slowMotion: slowMotion
)
Run the app, add an animation and tap the Animate! button. You’ll see the familiar
one-second linear animation. Now toggle on Slow Animations and tap Animate!
again.
31
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
Your animation now takes four seconds, or four times longer, to complete. To verify
all animations run slower, add another animation and change its length to 0.5
seconds.
When you animate them, you’ll see the second animation takes two seconds (4 x 0.5
seconds) or half as long as the first animation.
Now that you’ve seen some of the modifiers you can apply to animations, you’ll look
at the last type of animation: spring animation.
Imagine a weight attached to one end of a spring. Attach the other end to a fixed
point and let the spring drop vertically with the weight at the bottom. The weight
will bounce several times before coming to a full stop.
The slowdown and stop come from friction acting on the system. The reduction
creates a damped system. Graphing the motion over time produces a result like this:
32
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
To experiment with this, open AnimationView.swift and add the following new
case to the currentAnimation computed property, before the default case:
case .interpolatingSpring:
return Animation.interpolatingSpring(
// 1
mass: animation.mass,
// 2
stiffness: animation.stiffness,
// 3
damping: animation.damping,
// 4
initialVelocity: animation.initialVelocity
)
Each of the parameters maps to an element of the physical model. Here’s what they
do:
3. damping maps to gravity and friction that slows down and stops the motion.
Notice that these parameters aren’t correlated with the linear and eased animations
you used earlier. You also don’t have a direct way to set the animation length as you
did before.
33
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
Change the type for the second one to an Interpolating Spring and keep the default
values, which include the default mass and initialVelocity if you don’t specify
them to the method.
Even with the extra movement, the spring completes faster than the one-second
linear animation.
34
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
Note: Before moving on, try to experiment with the different animation
parameters and get a grasp for how each of them effects the animation.
Increasing the mass causes the animation to last longer and bounce further on each
side of the end point. A smaller mass stops faster and moves less past the end points
on each bounce.
Increasing the stiffness causes each bounce to move further past the end points but
with a smaller effect on the animation’s length.
Increasing the damping slows the animation faster. If you set an initial velocity, it
changes the initial movement of the animation.
Open AnimationView.swift and add the following case before the default case:
case .spring:
return Animation.spring(
response: animation.response,
dampingFraction: animation.dampingFraction
)
The response parameter acts similarly to the mass in the physics-based model. It
determines how resistant the animation is to changing speed. A larger value will
result in an animation slower to speed up or slow down.
The dampingFraction parameter controls how quickly the animation slows down. A
value greater than or equal to one will cause the animation to settle without the
bounce that most associate with spring animations.
A value between zero and one will create an animation that shoots past the final
position and bounces a few times, similarly to the previous section.
35
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
A value near one will slow faster than a value near zero. A value of zero won’t settle
and will oscillate forever, or at least until your user gets frustrated and closes your
app.
Run the app and add two animations. Change the type of the first to Interpolating
Spring and the type of the second to Spring. Tap Animate!, and you’ll notice that
the animations are similar despite the different parameters.
Change the first animation to Spring and experiment by changing the values to see
the effect of mass and stiffness on the animation. Slowing the animation down
will help with the often subtle differences between spring animations.
36
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
You now understand the basics of SwiftUI animations and can use the app to explore
and fine-tune animations in your apps.
Run the app, go to the Transitions tab and tap the button a few times to see it in
action.
37
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
Note that there’s no animation when the square appears and vanishes. That’s
because transitions only occur when you apply an animation to the state change.
Earlier in the chapter, you used implicit animations where the
animation(_:value:) modifier implied SwiftUI should animate the view. Now, you
will explicitly tell SwiftUI to create an animation when showSquare changes. To do
so, go to the action for the button and change it to:
withAnimation {
showSquare.toggle()
}
With the animation applied, you can apply a transition using the transition(_:)
modifier on a view. Look for the conditional statement to show the rectangle and add
the following modifier after foregroundColor():
.transition(.scale)
Run the app and repeat the steps. You’ll see the square shrink to a single point.
You can also use this explicit withAnimation(_:_:) function on the animations you
used earlier in this chapter. However, it can only specify a single animation and will
apply that animation to all changes resulting from the code within the function.
The scale transition you applied here makes the view appear to originate or vanish
by scaling to a provided ratio of the view’s size. By default, the view scales down to
zero at a point in its center. You can change either of these values.
Run the app. When you hide the view, the square will expand to twice its original
size, with the scaling centered around the top leading corner of the view before
vanishing.
38
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
Note: When running in the simulator, the vanishing of the scale animation
might end abruptly due to a bug in SwiftUI. If this happens to you, try running
the code on a device, or a different Simulator.
Additional Transitions
You can specify the default fade transition using the opacity transition. Change the
transition to read:
.transition(.opacity)
39
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
Run the app. You’ll see the view now vanishes and appears with a fade-in/out
animation.
Another transition is offset(x:y:), which lets you specify that the view should
offset from its current position. Change the transition to read:
40
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
Run the app. The view slides slightly to the right and down before being removed.
When it returns, it appears at the same position it vanished from before returning to
the original location.
You can also specify the view should move towards a specified edge using the
move(edge:) transition. Change the current transition to:
.transition(.move(edge: .trailing))
41
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
Run the app. Now the view slides off toward the trailing edge when you tap the
button to hide it. When you show the square, the view will appear from the same
edge it moved toward before vanishing.
Having a view transition by appearing on the leading edge and vanishing toward the
trailing edge is common enough that SwiftUI includes a predefined specifier: the
slide transition. Change the transition to:
.transition(.slide)
42
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
Run the app, and you’ll see the view acts similar to the move(edge:) transition it
replaced, except the view now appears from the leading edge and vanishes toward
the trailing edge. This transition provides different animations for inserting and
removing the view.
Head over to the next section to learn how you too can create these custom
asynchronous transitions!
// 1
var squareTransition: AnyTransition {
// 2
let insertTransition = AnyTransition.move(edge: .leading)
let removeTransition = AnyTransition.scale
// 3
return AnyTransition.asymmetric(
insertion: insertTransition,
removal: removeTransition
)
}
1. You specify the transition as a computed property on the view. Doing so helps
keep the view code less cluttered and makes it easier to change in the future.
2. Next, you create two transitions. The first is a move(edge:) transition and the
second is a scale transition.
43
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
.transition(squareTransition)
This method tells SwiftUI to apply the transition from the squareTransition
property to the view.
Run the app. When you tap the button, you’ll see the view does as you’d expect. The
view appears from the leading edge and scales down when removed.
44
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
You’ve now explored the basics of animations and view transitions in SwiftUI. In the
remainder of this book, you’ll delve deeper into more complex animations.
Challenge
Modify the transitions view for this chapter’s app to let the user specify a single
transition or separate insert and removal transitions. For each type of transition, let
the user select the additional values supported by the transition. Apply these
transitions to the square when the user taps the button.
To help you get started, you’ll find data structures that can hold the properties for
transitions in TransitionData.swift. You’ll also find a view letting the user specify
these properties in TransitionTypeView.swift. Check the challenge project in the
materials for this chapter for a possible solution.
45
SwiftUI Animations by Tutorials Chapter 1: Introducing SwiftUI Animations
Key Points
• SwiftUI animations are driven by state changes. The change of a value that affects
a view.
• Most animations should last between 0.25 and 1.0 seconds in length. Shorter
animations often aren’t noticeable, while longer animations risk annoying your
user who just wants to get something done.
• View transitions can animate by opacity, scale or movement. You can use different
transitions for the insertion and removal of views.
• For more on how to use animations and transitions in your apps, see the Human
Interface Guidelines (https://developer.apple.com/design/human-interface-
guidelines/).
46
2 Chapter 2: Getting to
Know SwiftUI Animations
By Irina Galata
In this chapter, and the three following it, you’ll work on an app designed to sell
tickets for sports events. You’ll use the concepts you learned in the previous chapter
to make it stand out. Your first objective is to replace the plain activity indicator
when refreshing the events screen with an interactive pull-to-refresh animation.
Getting Started
First, download the materials for this chapter and open starter/
SportFan.xcodeproj. You’ll see a few Swift files waiting for you in advance, so you
can start working immediately.
47
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
Your app currently has only one screen — the events list that previews all upcoming
games. If you’re eagerly waiting for a specific game that wasn’t announced yet, you
can pull the list down to fetch the latest data.
In a real-world app, it might take a while to send a request to a server, wait for a
response, process it and display it appropriately. So, you must let the user know the
request is still ongoing. The built-in activity indicator the app currently uses is a
great way to do so, but it’s a missed opportunity to make your app unique and
memorable.
48
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
You’ll make manual calculations to detect when a user swipes the container to a
specific distance to trigger the data update. To achieve this, you’ll use SwiftUI’s
GeometryReader. It’s a view that provides vital information suggested by its parent -
in this case, a ScrollView, such as its size, frame and safe area insets.
This feature is the foundation of your animation. You’ll put GeometryReader inside
ScrollView alongside its content. When you pull the content on top, the geometry
reader view will be pulled as well, which allows you to catch the exact value of the
scroll offset and use it as needed.
The initial value of the geometry reader’s y-axis offset is marked y0 in the diagram
above.
49
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
Once a user starts pulling the content down, GeometryReader’s and VStack‘s frames
follow the movement with the same velocity. Through GeometryReader, you’ll
access the current y1 value at every moment of the gesture, calculate the distance
traveled and decide whether the app should trigger a refresh.
Since your animation will consist of multiple phases, you need an enum representing
its current state. It’s .idle before a user starts interacting with the scroll
view, .pulling while the gesture is in progress and then transitions to .ongoing to
indicate the ongoing refresh.
Note: An enum is an optimal way to handle state changes since, by its nature,
it prevents you from ending up in an invalid state: a variable of the
AnimationState type can have only one value out of three.
Soon, you’ll use it to share the state of your pull-to-refresh animation between its
components and ContentView.
// 1
@Binding var pullToRefresh: PullToRefresh
// 2
let update: () async -> Void
50
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
// 3
@State private var startOffset: CGFloat = 0
1. You use the @Binding property wrapper for the pullToRefresh property to
establish a two-way connection between ContentView and
ScrollViewGeometryReader. The main container passes a PullToRefresh
instance to the geometry reader. The geometry reader updates its properties
when a user interacts with a scroll view to tell ContentView when the animation
should start or finish.
3. You need to keep the initial value of the y-axis offset, y0, to compare it to the
current offset of the geometry reader and calculate the length of a user’s swipe.
GeometryReader<Color> { proxy in // 1
// TODO: To be implemented
return Color.clear // 2
}
.task { // 3
await update()
}
Here, you:
2. If you don’t want the content of the reader to be visible to a user, you can simply
return a transparent view.
3. Use task to trigger data fetching right before the view appears on the screen.
51
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
Using GeometryProxy
Now, it’s time to do a bit of math. First, create a constant for the maximum offset,
which a scroll view needs to reach to trigger the pull-to-refresh.
enum Constants {
static let maxOffset = 100.0
}
Next, add this switch below currentOffset. It’ll control the pullToRefresh state:
switch pullToRefresh.state {
case .idle:
startOffset = currentOffset // 1
pullToRefresh.state = .pulling // 2
case .pulling: // 4
pullToRefresh.state = .ongoing
pullToRefresh.progress = 0
Task {
await update() // 5
pullToRefresh.state = .idle
}
default: return
}
52
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
1. If state is still .idle, you set startOffset to currentOffset. That means the
gesture has just started, and it’s the initial value of the offset. Therefore it’s y0.
2. You change the state to .pulling as a user starts interacting with the scroll view.
3. Now, you calculate the progress of the gesture where your y1 currently is
between y0 and its maximum value y0 + maxOffset. This value will lay between
0 and 1.
4. Once the offset reaches its maximum value, but before the update starts, you
change the state property to .ongoing.
5. Since update is asynchronous, you create a new Task to invoke it and use the
await keyword to suspend the task and wait for the function to return a result.
Once you get the value, the execution resumes, and you use pullToRefresh to
say the update is complete and the animation is back to .idle.
Since you’ve implemented the function, replace the TODO comment inside the
geometry reader with:
Because you may modify some state variables inside calculateOffset(from:), the
compiler prevents you from executing it synchronously while the view’s body
renders, preventing you from potentially entering a loop and causing undefined
behavior.
To verify that your geometry reader can determine when to refresh the data
correctly, replace everything inside the body of ContentView with:
ScrollView {
ScrollViewGeometryReader(pullToRefresh: $pullToRefresh) { // 1
await update()
print("Updated!")
}
ZStack(alignment: .top) {
if pullToRefresh.state == .ongoing { // 2
ProgressView()
}
LazyVStack { // 3
53
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
ForEach(events) {
EventView(event: $0)
}
}
.offset(y: pullToRefresh.state == .ongoing ?
Constants.maxOffset : 0) // 4
}
}
2. If the update is ongoing, you show a regular progress view. Later, you’ll replace it
with a bouncy basketball. But to verify whether the math works, you use a built-
in view for now.
4. To give the future animation enough space on the screen, you move the events
container down using its offset modifier.
The first iteration of your custom pull-to-refresh solution is ready. Hooray! Now,
refresh the preview in ContentView or run the app.
54
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
As you finish pulling the scroll view, a progress wheel appears, spins for a few
seconds and disappears. At the same time, the events list refreshes if any earlier
events appear. Don’t worry about its abrupt, non-animated appearance at the
moment — you’ll deal with it soon.
It’s also worth checking the output in Xcode. You’ll see an Updated! message print
once the app starts and every time you trigger an update during the idle state. In
other words, no excessive updates should occur:
Updated!
Updated!
Updated!
Now, your goal is to render a ball while the data refreshes and make it jump using
explicit animations. Then, you’ll enhance your animation by adding physical details
like rotation, a shadow and even squashing when the ball reaches the bottom.
Before you start, add some new constants to your Constants.swift file, inside the
enum:
Then, create a new SwiftUI view file called BallView.swift and add the following
property to the generated view struct:
55
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
Image("basketball_ball")
.resizable()
.frame(
width: Constants.ballSize,
height: Constants.ballSize
)
}
}
This code makes the ball image easy to reuse without code duplication.
Animating Offset
Next, create a new SwiftUI view file — JumpingBallView.swift, which will be
responsible for rendering the jumping ball.
Keep the generated preview struct to work on and adjust this part of the animation
separately from the app.
Note: You can modify a view in its preview without affecting its appearance in
the app. If you zoom the ball in by adding .scale(4) in
JumpingBallView_Previews, you can inspect your animation closely and
keep its correct size in other components.
Now, add the two following properties inside JumpingBallView to keep track of the
animation state and offset of the view:
56
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
Note: In iOS, the center of the coordinate system, the intersection of the x and
y-axis, is located in the top left corner. Therefore, the values grow positively
on the x-axis when moving towards the right side and downwards along the y-
axis.
With these calculations behind you, you can simply add your new Ball with the
computed y offset. Replace JumpingBallView’s body with:
Ball()
// 1
.offset(y: currentYOffset)
To make the ball jump the moment it appears, add .onAppear to the ball image right
after .offset:
.onAppear {
withAnimation(
.linear(duration: Constants.jumpDuration)
.repeatForever()
) { // 1
isAnimating = true // 2
}
}
It took a while to start animating, but you’re there! Here’s what you achieved above:
2. By modifying isAnimating, you trigger the ball’s offset change, which now
animates according to the animation properties you passed to withAnimation().
Before trying out your changes, add JumpingBallView to BallView’s body, like so:
switch pullToRefresh.state {
case .ongoing:
JumpingBallView() // 1
default:
EmptyView() // 2
}
57
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
Last but not least, you’ll completely replace ProgressView with the BallView you
created. Go to ContentView and replace the if pullToRefresh.state
== .ongoing condition with:
BallView(pullToRefresh: $pullToRefresh)
Now, refresh the preview and see your animation’s first steps, or jumps.
Well, you may not be impressed just yet. The ball moves oddly and unnaturally.
However, in the first chapter, you learned that interpolating functions can
significantly affect an animation’s feel. It’s time to apply one.
withAnimation(
.easeInOut(duration: Constants.jumpDuration)
.repeatForever()
) {
58
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
Now, add .rotationEffect and .scaleEffect to the ball above .offset. The order
is important! If you reverse the order, the animation will look jarring.
.rotationEffect(
Angle(degrees: rotation),
anchor: .center
) // 1
.scaleEffect(
x: 1.0 / scale,
y: scale,
anchor: .bottom
) // 2
Here, you:
1. Use degrees for the rotation measurement and set the center of the ball as the
rotation’s anchor.
2. Squash the ball around its bottom side as it hits the surface on the bottom by
scaling it along the x-axis inversely to the y-axis.
The final touch is to animate those properties. Make a new function inside
JumpingBallView right below its body:
withAnimation(
.linear(duration: Constants.jumpDuration * 2)
.repeatForever(autoreverses: false)
) { // 2
rotation = 360
}
withAnimation(
.easeOut(duration: Constants.jumpDuration)
59
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
.repeatForever()
) { // 3
scale = 0.85
}
}
1. Since you need to trigger all these animations the moment the ball appears, you
move the previously-created jumping animation alongside new ones to a
separate function.
2. To make the ball rotate indefinitely without changing its direction, you disable
auto-reverse. By doubling the duration, you force the ball to make a whole
rotation before returning to the top position.
3. You use .easeOut to squash the ball since you want it to slow down slightly as
the ball hits the surface to amplify the effect. Making scale less than 1 increases
the ball horizontally and makes it smaller vertically, imitating squashing.
.onAppear {
animate()
}
Run your app again, and you’ll see the ball now rotates nicely and gets squashed at
its bottom, just as expected — great progress!
Animation Opacity
The ball is ready, but what about a little touch of shadow? Add a new property to
JumpingBallView to define the shadow’s height:
Then, in JumpingBallView’s ZStack, you wrap the ball image in body and add some
shadow to the ball. Replace the body with:
ZStack {
Ellipse()
.fill(Color.gray.opacity(0.4))
.frame(
width: Constants.ballSize,
height: shadowHeight
60
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
)
.scaleEffect(isAnimating ? 1.2 : 0.3, anchor: .center) // 1
.offset(y: Constants.maxOffset - shadowHeight / 2 -
Constants.ballSpacing) // 2
.opacity(isAnimating ? 1 : 0.3) // 3
Ball()
.rotationEffect(
Angle(degrees: rotation),
anchor: .center
)
.scaleEffect(
x: 1.0 / scale,
y: scale,
anchor: .bottom
)
.offset(y: currentYOffset)
.onAppear { animate() }
}
1. You draw a translucent gray ellipse behind the ball and make its size animate
with the jumping ball by reusing isAnimating. It becomes larger when the ball
reaches the surface and shrinks when the ball is in the air.
3. Like you did for scaling, you make the shadow change its opacity depending on
the ball position. When it’s closer to the bottom, the shadow is darker.
Great job! Remember to keep an eye on the whole screen’s integrity when working
on a single component. Run the app to see the entire picture.
61
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
To make it more natural, you must prepare the user and make it clear how the ball
got to the screen. What could be more logical than a ball rolling towards you rather
than just appearing in front of your nose?
You’ll make the ball roll in from the left corner toward the center before it starts
jumping.
First, for quick access to the value of half of the screen width, add a handy extension
to the bottom of BallView.swift:
extension UIScreen {
static var halfWidth: CGFloat {
main.bounds.width / 2
}
}
ZStack {
Ellipse()
.fill(Color.gray.opacity(0.4))
.frame(
width: Constants.ballSize * 0.8,
height: shadowHeight
)
.offset(y: -Constants.ballSpacing - shadowHeight / 2)
Ball()
.rotationEffect(
Angle(radians: rollInRotation),
anchor: .center
62
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
)
.offset(y: -Constants.ballSize / 2 -
Constants.ballSpacing)
}
.offset(x: rollInOffset) // 4
}
}
You’re already familiar with this setup. You have a ZStack with a shadow and a ball
inside. Here’s how the movement is different:
1. The ball’s starting position in the animation is right behind the left corner of the
screen. So, to move it from the center of the screen, where SwiftUI puts it by
default, you need to apply a negative offset of half of the screen width and half of
the ball size.
2. You use PullToRefresh‘s progress value to move the ball with the same
velocity as the user’s gesture. It reaches the screen’s center when the scroll view
reaches its maximum offset. When the progress is 0, rollInOffset equals
initialOffset. When the progress is 1, the offset is 0, bringing the ball back to
the center.
3. You apply the value of progress to the rotation and offset, too.
4. Since the horizontal offset of the shadow is identical to that of the ball, you can
apply the offset to their parent view, the ZStack.
To bring this part of the animation to the picture, add a new condition,
case .pulling, to the body of BallView, so the switch statement looks like this:
switch pullToRefresh.state {
case .ongoing:
JumpingBallView()
case .pulling:
RollingBallView(pullToRefresh: $pullToRefresh)
default:
EmptyView()
}
Now, when the pulling gesture is ongoing, but the update hasn’t started yet, you
display the rolling ball.
63
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
1. The state transitions to .preparingToFinish right after the app completes the
update to tell the ball to return to the top from whatever position it’s at while
jumping.
2. Once the ball is back to the top position, the state transitions to .finishing to
tell BallView to switch back to RollingBallView to show the final part of your
pull-to-refresh animation.
case idle = 0,
pulling,
ongoing,
preparingToFinish,
finishing
64
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
Then, add some new values to your Constants enum over at Constants.swift:
You give the ball 300 milliseconds to finish the jumping motion and a full second to
roll off the screen.
func after(
_ seconds: Double,
execute: @escaping () -> Void
) {
Task {
let delay = UInt64(seconds * Double(NSEC_PER_SEC))
try await Task<Never, Never>
.sleep(nanoseconds: delay)
execute()
}
}
Task {
await update()
pullToRefresh.state = .preparingToFinish // 1
after(timeForTheBallToReturn) {
pullToRefresh.state = .finishing // 2
after(timeForTheBallToRollOut) {
pullToRefresh.state = .idle // 3
startOffset = 0
}
}
}
1. Instead of transitioning back to the idle state, your animation moves to a new
phase — .preparingToFinish. It signals the ball to come back to the top to
smooth the transition to the rolling out phase.
2. After that, the state transitions to .finishing. At this moment, the rolling ball
replaces the jumping one.
3. Finally, once the ball is gone, you reset the animation state, setting its state
to .idle and the offset to 0. Now, the pull-to-refresh is in a valid state to execute
again if needed.
65
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
To make the jumping ball react to the state change, add PullToRefresh to
JumpingBallView:
Update the shadow’s .fill modifier to make it transparent when the ball stops
jumping. Reminder: it’s the Ellipse inside the ZStack:
.fill(
Color.gray.opacity(
pullToRefresh.state == .ongoing ? 0.4 : 0
)
)
Then, tell the ball to move to the top by updating the currentYOffset property to:
You’ll work with implicit animations later in the chapter. For now, you’ll use a little
trick to tell SwiftUI to animate a state change to .preparingToFinish for
timeForTheBallToReturn to smooth its move upwards. Add the following modifier
to after Ball’s offset modifier and before its onAppear:
.animation(
.easeInOut(duration: Constants.timeForTheBallToReturn),
value: pullToRefresh.state == .preparingToFinish
)
JumpingBallView(
pullToRefresh: .constant(
PullToRefresh(
progress: 0,
state: .ongoing
)
)
)
66
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
To animate the ball’s rotation, offset and shadow while rolling out, add two new
properties to RollingBallView in BallView.swift:
Then, update the x offset of the ZStack to change depending on the state:
.rotationEffect(
Angle(
radians: pullToRefresh.state == .finishing
? rollOutRotation
: rollInRotation
),
anchor: .center
)
withAnimation(
.easeIn(duration: Constants.timeForTheBallToRollOut)
) {
rollOutOffset = UIScreen.main.bounds.width
}
withAnimation(
.linear(duration: Constants.timeForTheBallToRollOut)
) {
rollOutRotation = .pi * 4
}
}
.onAppear {
animateRollingOut()
}
67
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
Update the switch statement in BallView to account for the new states. Adding
.finishing and .preparingToFinish and passing the pullToRefresh binding
to JumpingBallView. The entire switch statement should look like this:
switch pullToRefresh.state {
case .ongoing, .preparingToFinish:
JumpingBallView(pullToRefresh: $pullToRefresh)
case .pulling, .finishing:
RollingBallView(pullToRefresh: $pullToRefresh)
default:
EmptyView()
}
Now, the rolling ball view displays both the animation’s entrance, .pulling, and its
exit, .finishing. The jumping ball will take over the .ongoing
and .preparingToFinish phases.
As a final touch, update the .offset of the LazyVStack in the ContentView to wait
for the ball to stop jumping before getting back to the initial position:
Now the animation doesn’t look out of place and has a logical and seamless flow.
Only a few final touches are missing!
68
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
On the other hand, you may need to animate one single view depending on a variable
value that you modify in multiple places. That’s where implicit animations come to
the rescue, using View’s modifier — .animation(:value:). SwiftUI animates any
change to the value parameter using the animation you pass as the first argument.
For instance, pullToRefresh.state affects the offset of the events container when
pull-to-refresh starts and finishes. If you animate this change explicitly via
withAnimation to enhance the container’s movement, you’ll break the behavior of
other views dependent on the shared state.
.animation(
.easeInOut(duration: Constants.timeForTheBallToReturn),
value: pullToRefresh.state
)
That smooths the movement slightly, but you can go further using a spring
animation for the pull-down movement.
With the setup above, you instantiate a bouncy spring animation with a relatively
low damping and ensure the animation bounces a few more times before settling
down.
69
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
.animation(
pullToRefresh.state != .finishing ? spring : ease,
value: pullToRefresh.state
)
The code above lets you alternate between two different timing curves, ease and
spring, depending on the state of the pull-to-refresh.
Run the app and check out what you have so far — simply remarkable and delightful!
At the time, Pierre Bézier, a french engineer, came up with a mathematical formula
defined by a set of “control points” describing a smooth and continuous curve.
Nowadays, any graphic design tool like Photoshop, Figma or Sketch will offer you
Bézier curves to build complex curves. And SwiftUI is no exception. This lets you
create a custom interpolation function by defining a quadratic Bézier curve with two
control points:
70
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
To build such a curve, you may use one of many online tools for Bézier curves
preview, such as cubic-bezier.com. By dragging the control points, you’ll receive
precise coordinates to achieve the curve.
To enhance the rolling-in animation of the ball, you can use a custom timing curve
to make it bounce sideways slightly, as if the ball was affected by inertia, if the user
stops pulling the events container for a brief moment halfway through.
.animation(
bezierCurve,
value: pullToRefresh.progress
)
Run the app one final time and play with your now-complete pull-to-refresh
animation. Fantastic progress!
Don’t hesitate to experiment with the numbers and the interpolating functions.
That’s the fun part of crafting animations. :]
71
SwiftUI Animations by Tutorials Chapter 2: Getting to Know SwiftUI Animations
Key Points
• GeometryReader is a SwiftUI view, which takes up all the space provided by its
parent and allows accessing its size, frame and safe area insets through a
GeometryProxy.
• Avoid performing state changes directly inside a view’s body, as it may cause an
undesired render loop.
• Use interpolation functions to make animations feel more natural and physically
realistic.
• Try not to catch a user off guard with your animations. The behavior should be
expected and well placed in the app.
• A Bézier curve is defined by a set of control points and can be helpful in various
aspects of computer graphics.
72
3 Chapter 3: View
Transitions
By Irina Galata
In the previous chapter, you started working on a sports-themed app to sell game
tickets. You managed to improve its pull-to-refresh animation, turning the system
loading wheel into a fun and memorable interaction.
In this chapter, you’ll work on a new screen that contains a game’s details as the next
step toward the ticket purchase. You’ll implement popular UI concepts like list
filters, a collapsing header view and floating action buttons. Since this is an
animations book, you’ll also enhance them via various types of transitions, which
you already got to briefly play with in the first chapter. You’ll also get to roll up your
sleeves and craft a custom transition.
73
SwiftUI Animations by Tutorials Chapter 3: View Transitions
Getting Started
You can continue working on the project from the previous chapter or use the starter
project from this chapter’s materials.
To pick up where you left off at the previous chapter, grab the EventDetails folder
and the Asset catalog, Assets.xcassets, from this chapter’s starter project and add
them to your current project.
Since you’ll work on several different components this time, append the following
values inside your Constants enum, over at Constants.swift:
If you’re starting from the starter project, these files and values will already be part
of your project.
In the first iteration of the events screen, the navigation is somewhat cumbersome:
the only way to find an event is to scroll to it, which can take a while. To make it
more user-friendly, you’ll implement a filtering functionality. For example, a user
who only wants to see basketball can filter out all other games.
First, create a new SwiftUI file named FilterView.swift, and add the following
properties to the generated struct:
74
SwiftUI Animations by Tutorials Chapter 3: View Transitions
Before moving on, you’ll fix the preview code so your code compiles. Replace the
view in the preview code with:
Then, back in FilterView, below its body, add a method to build a view for each
option:
Now, you’ll add a bit more style. Add the following .background modifier to the
Text in item(for:):
.background {
ZStack {
RoundedRectangle(cornerRadius: Constants.cornersRadius)
.fill(
selectedSports.contains(sport)
? Constants.orange
: Color(uiColor: UIColor.secondarySystemBackground)
)
.shadow(radius: 2)
RoundedRectangle(cornerRadius: Constants.cornersRadius)
.strokeBorder(Constants.orange, lineWidth: 3)
}
}
This code makes the item appear as a rounded rectangle outlined by an orange
stroke. If selectedSports contains the sport the user picked, it paints the view
orange to indicate it was selected.
Now, replace the view’s body with a ZStack to hold all the sports options:
ZStack(alignment: .topLeading) {
if isShown {
ForEach(sports, id: \.self) { sport in
item(for: sport)
.padding([.horizontal], 4)
.padding([.top], 8)
}
}
}
.padding(.top, isShown ? 24 : 0)
75
SwiftUI Animations by Tutorials Chapter 3: View Transitions
With the code above, you stack all the filtering items on top of each other. To build a
grid out of them, you need to define each item’s location relative to its neighbors.
Computations like this require iterating over all the elements to accumulate the
total values of the horizontal and vertical shift, so add the following variables above
the ZStack you added in the previous step, at the top of the body:
// 1
.alignmentGuide(.leading) { dimension in
// 2
if abs(horizontalShift - dimension.width) >
UIScreen.main.bounds.width {
// 3
horizontalShift = 0
verticalShift -= dimension.height
}
// 4
let currentShift = horizontalShift
// 5
horizontalShift = sport == sports.last ? 0 : horizontalShift -
dimension.width
return currentShift
}
1. First, you tell SwiftUI you want to make a computation to change the .leading
alignment of a filter option. Inside the closure, you receive its dimensions, which
will help calculate the alignment.
76
SwiftUI Animations by Tutorials Chapter 3: View Transitions
3. If it doesn’t, you move it to the next “row” by setting the horizontal shift to 0,
which places it at the left corner of the parent container. Additionally, you deduct
the view’s height from the vertical alignment to move the element down, forming
a new row.
5. You deduct the current view’s width from the alignment, which the next item in
the loop will use.
Note: Although it may appear confusing at first, to move a view to the right,
you need to move its horizontal alignment guide to the left. Therefore you
deduct a view’s width from the alignment value.Once the alignment guide
moves to the left, SwiftUI aligns it with the alignment guides of the view’s
siblings by moving the view to the right.
// 1
.alignmentGuide(.top) { _ in
let currentShift = verticalShift
// 2
verticalShift = sport == sports.last ? 0 : verticalShift
return currentShift
}
77
SwiftUI Animations by Tutorials Chapter 3: View Transitions
2. Unless the current element is the last one, assign the value calculated alongside
the horizontal alignment above. Otherwise, reset the shift value to 0.
Now you’ll handle the user’s selection. Add a new method to FilterView:
Then, wrap your entire item(for:) with a Button and call your new onSelected
method, so it looks similar to the following:
Button {
onSelected(sport)
} label: {
item(for: sport)
// padding and alignment guide modifiers
}
2. selectedSports is a set where you’ll keep the selected sports. Later, changing
this property will filter the sports events.
78
SwiftUI Animations by Tutorials Chapter 3: View Transitions
Next, add a method to ContentView, which is responsible for filtering the events:
func filter() {
events = selectedSports.isEmpty
? unfilteredEvents
: unfilteredEvents.filter
{ selectedSports.contains($0.team.sport) }
}
To prevent the pull-to-refresh from breaking the filter functionality, replace the code
inside update() with:
.toolbar {
// 1
ToolbarItem {
Button {
// 2
filterShown.toggle()
} label: {
Label("Filter", systemImage:
"line.3.horizontal.decrease.circle")
.foregroundColor(Constants.orange)
}
}
}
1. Inside .toolbar, you pass the toolbar items you want to display on top of the
screen. You add only one primary action displayed as a filter icon.
To trigger the filter, you’ll use the view modifier .onChange(of:) to listen to the
changes to selectedSports. Add the following modifier to ScrollView:
79
SwiftUI Animations by Tutorials Chapter 3: View Transitions
Finally, wrap the LazyVStack holding the event views into another VStack and add
FilterView on top so that the structure looks like this:
VStack {
FilterView(selectedSports: $selectedSports, isShown:
filterShown)
.padding(.top)
.zIndex(1)
LazyVStack { ... }
}
Make sure the .animation and the .offset modifiers are attached to the outer
VStack so the filters and pull-to-refresh won’t overlap.
Run the app, and tap the filter button in the navigation bar to see the new feature:
The functionality is there, but it’s not very fun to use. The filter view abruptly moves
the events container down, which doesn’t look neat.
But it’s a piece of cake to make it smooth with SwiftUI’s transitions! Next, you’ll add
a basic transition to your component.
80
SwiftUI Animations by Tutorials Chapter 3: View Transitions
Since you already modify ContentView’s layout by showing and hiding the filter view
and updating the content of the events set, only two components are missing:
transitions and animations.
withAnimation(
.interpolatingSpring(stiffness: 30, damping: 8)
.speed(1.5)
) {
events = selectedSports.isEmpty
? unfilteredEvents
: unfilteredEvents.filter
{ selectedSports.contains($0.team.sport)
}
}
Modifying the events value inside withAnimation lets SwiftUI animate every view’s
update that depends on the events property.
// 1
EventView(event: event)
// 2
.transition(.scale.combined(with: .opacity))
Here, you:
This changes the animation of EventView from easing into the view to scaling and
slowly fading in.
81
SwiftUI Animations by Tutorials Chapter 3: View Transitions
Build and run, then filter by any sport to see the new transition.
82
SwiftUI Animations by Tutorials Chapter 3: View Transitions
Back in FilterView.swift, add the following code to the bottom of the file:
// 2
func body(content: Content) -> some View {
content
// 3
.scaleEffect(active ? 0.75 : 1)
// 4
.rotationEffect(.degrees(active ? .random(in: -25...25) :
0), anchor: .center)
}
}
1. You add an active property so you can animate the change between it’s true
and false states.
4. Additionally, you make the view swing in a random direction and then get back to
0 degrees.
Now, add a new property in FilterView to keep the transition created with your
view modifier:
83
SwiftUI Animations by Tutorials Chapter 3: View Transitions
To apply the newly created transition, add the following modifier to the Button
containing your filter item:
.transition(.asymmetric(
insertion: filterTransition,
removal: .scale
))
Typically, you apply the same transition to a view’s entry and exit. Since you only
want the filter options to bounce when they appear, you need to apply an
asymmetric transition. This way, you define insertion and removal transitions
separately.
To start the transition, you need to animate the filterShown value change.
filterShown.toggle()
Replace it with:
withAnimation(filterShown
? .easeInOut
: .interpolatingSpring(stiffness: 20, damping: 3).speed(2.5)
) {
filterShown.toggle()
}
With this approach, you alternate between the plain .easeInOut and the
bouncy .interpolatingSpring animations. Try it out. Run the app and tap the filter
button.
84
SwiftUI Animations by Tutorials Chapter 3: View Transitions
Nice job! The filter view appears with a bouncy spring animation and disappears with
an ease animation. How much cooler is that? Next, you’ll improve the user
experience on the event details screen.
Now, tapping on an event view cell in the container will navigate the user to the
EventDetailsView. Try it out. Run the app to see what you’ve got to work on next.
85
SwiftUI Animations by Tutorials Chapter 3: View Transitions
On the new details screen, you’ll see all the relevant information on the specific
event: the date, location, tickets available and the team’s upcoming games. You’ll
also notice a button, Seating Chart. It doesn’t do much right now, but soon it’ll be a
linking point to the component you’ll craft in the fourth and fifth chapters. Sounds
intriguing?
Although the event details screen looks fine and fulfills its designated purpose -
displaying event info - a good animation can improve its usability drastically.
There are multiple viable approaches to solving this problem. You could split the
screen’s functionality and, for example, show the upcoming games only on demand,
thus making the details screen smaller and more manageable.
Alternatively, you could hide them completely, add a search bar on the events list
screen and make users look for the stuff they need. You could also “pin” the crucial
components to stay visible and accessible while a user scrolls down the screen’s
content, which is the strategy you’ll take for this chapter.
Since you’re now an expert on SwiftUI’s GeometryReader, the first steps may already
be clear to you: create a new SwiftUI view file and name it
HeaderGeometryReader.swift. It’s responsible for catching the offset value of your
scroll view.
86
SwiftUI Animations by Tutorials Chapter 3: View Transitions
GeometryReader<AnyView> { proxy in
// 1
guard proxy.frame(in: .global).minX >= 0 else {
return AnyView(EmptyView())
}
Task {
// 2
offset = proxy.frame(in: .global).minY - startOffset
withAnimation(.easeInOut) {
// 3
collapsed = offset < Constants.minHeaderOffset
}
}
return AnyView(Color.clear.frame(height: 0)
.task {
// 4
startOffset = proxy.frame(in: .global).minY
}
)
}
1. Verify that the frame of the proxy is valid as a safety measure. If you navigate to a
different screen while some transitions are animating on the previous screen, the
proxy’s values may be off upon entering the screen. You ignore such values until
the valid ones appear.
2. Calculate the change to the scroll view’s offset by subtracting the starting value
from the current offset, minY of the proxy’s frame. This way, before a user
interacts with the content, the value of offset is 0.
3. The header should collapse if the offset gets below the minimum value. You wrap
this change in withAnimation to allow seamless transitions between the
collapsed and expanded states.
4. To fetch the starting value of the offset only once before the view appears, you
attach a .task modifier and access the proxy’s minY from within it.
87
SwiftUI Animations by Tutorials Chapter 3: View Transitions
Now, open EventDetailsView.swift and add the offset and collapsed properties:
Next, wrap the content of yourScrollView with a ZStack, so it looks like so:
ScrollView {
ZStack {
VStack {...}
}
}
Note: You may find the code folding ribbon option particularly helpful while
working on this chapter. With it, you can easily fold the code blocks when you
need to wrap them into another component. To enable it, tap on the checkbox
in Xcode Preferences -> Text Editing -> Display -> Code Folding Ribbon.
Then, add your HeaderGeometryReader inside the ZStack above the VStack:
HeaderGeometryReader(
offset: $offset,
collapsed: $collapsed
)
Before EventDetailsView‘s body grows even further, create a new SwiftUI file and
name it HeaderView.swift. This file is responsible for the views you’ll pin to the top
of the screen.
ZStack {
// 1
AsyncImage(
url: event.team.sport.imageURL,
content: { image in
image.resizable()
.scaledToFill()
.frame(width: UIScreen.main.bounds.width)
88
SwiftUI Animations by Tutorials Chapter 3: View Transitions
// 2
.frame(height: max(
Constants.minHeaderHeight,
Constants.headerHeight + offset
))
.clipped()
},
placeholder: {
ProgressView().frame(height: Constants.headerHeight)
}
)
}
2. You use the new headerHeight for the height instead of the hardcoded one. This
makes the image shrink as the user scrolls the content of EventDetailsView and
updates the height of AsyncImage in .frame so that it changes alongside the
offset but doesn’t go below the minimum allowed value.
Now, below clipped(), add some shadow and round the corners of the image to
make it appear elevated above the content:
.cornerRadius(collapsed ? 0 : Constants.cornersRadius)
.shadow(radius: 2)
Since you’re going to display the title and date label in the header, you need to apply
an overlay to the image to darken it and make the text more readable. Add
an .overlay modifier to the end of AsyncImage:
.overlay {
RoundedRectangle(cornerRadius: collapsed ? 0 :
Constants.cornersRadius)
.fill(.black.opacity(collapsed ? 0.4 : 0.2))
}
Since you’re building a custom header, namely a toolbar, you need to hide the system
header. Head over to EventDetailsView.swift and add the two following modifiers
on the root ZStack of EventDetailsView:
.toolbar(.hidden)
.edgesIgnoringSafeArea(.top)
89
SwiftUI Animations by Tutorials Chapter 3: View Transitions
Now, it’s time to add the missing views in your HeaderView. Head back to
HeaderView.swift and add a VStack below the AsyncImage. It’ll be the container for
the team’s name and date labels and the back button because you need to replace the
system one, which is now gone:
VStack(alignment: .leading) {
}
.padding(.horizontal)
.frame(height: max(
Constants.minHeaderHeight,
Constants.headerHeight + offset
))
Next, you’ll want to dismiss the view once the user taps the back button. Add the
following environment property to HeaderView above the event property:
Note: SwiftUI provides several environment values which can come in handy,
like color scheme and size class. You can access them using a keypath in the
@Environment attribute.
Next, add a new Button inside the VStack together with the back button and the
title label:
Button {
// 1
dismiss()
} label: {
HStack {
Image(systemName: "chevron.left")
.resizable()
.scaledToFit()
.frame(height: Constants.iconSizeS)
.clipped()
.foregroundColor(.white)
// 2
if collapsed {
Text(event.team.name)
.frame(maxWidth: .infinity, alignment: .leading)
.font(.title2)
90
SwiftUI Animations by Tutorials Chapter 3: View Transitions
.fontWeight(.bold)
.foregroundColor(.white)
} else {
Spacer()
}
}
.frame(height: 36.0)
// 3
.padding(.top, UIApplication.safeAreaTopInset +
Constants.spacingS)
}
1. You wrap an HStack inside a Button which triggers the dismiss() method when
tapped.
2. You show the team’s name alongside the back button image in case the header is
collapsed. Otherwise, you replace it with the Spacer.
3. Since you make your header ignore the safe area insets via
edgesIgnoringSafeArea(_:), you need to account for this measurement
yourself to prevent the notch of your iPhone from hiding the back button and
title.
The only thing missing in the header is the date label. Add the following code right
below the Button you added in the previous step:
// 1
Spacer()
// 2
if collapsed {
HStack {
Image(systemName: "calendar")
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(height: Constants.iconSizeS)
.foregroundColor(.white)
.clipped()
Text(event.date)
.foregroundColor(.white)
.font(.subheadline)
}
.padding(.leading, Constants.spacingM)
.padding(.bottom, Constants.spacingM)
}
91
SwiftUI Animations by Tutorials Chapter 3: View Transitions
1. You use a Spacer to keep the space when the header is expanded and prevent the
back button from jumping along the y-axis between state changes.
HeaderView(
event: Event(team: teams[0], location: "Somewhere",
ticketsLeft: 345),
offset: -100,
collapsed: true
)
Note: Check out the preview to see this view. Tweak the values of HeaderView
in the preview to check its states.
HeaderView(
event: event,
offset: offset,
collapsed: collapsed
)
.zIndex(1)
A few lines down, add a Spacer above the team name to prevent the header from
overlapping the screen’s content:
Spacer()
.frame(height: Constants.headerHeight)
92
SwiftUI Animations by Tutorials Chapter 3: View Transitions
Updating EventLocationAndDate to
Animate When Collapsed
Now, open EventLocationAndDate.swift and add the following property below the
event:
EventLocationAndDate(
event: makeEvent(for: teams[0]),
collapsed: false
)
Inside the second HStack, wrap the calendar Image and the date Text into an if
statement to remove them when the header collapses, leaving the Spacer outside of
the condition:
if !collapsed {
...
}
Last but not least, hide the team’s name label once the header collapses as well:
if !collapsed {
Text(event.team.name)
.frame(maxWidth: .infinity, alignment: .leading)
.font(.title2)
.fontWeight(.black)
.foregroundColor(.primary)
.padding()
}
93
SwiftUI Animations by Tutorials Chapter 3: View Transitions
SwiftUI recognizes which views it needs to adjust by their identifier in the common
namespace.
94
SwiftUI Animations by Tutorials Chapter 3: View Transitions
Obtaining the animation namespace of a view is very straightforward. Simply add the
following property to EventDetailsView above the event property:
.matchedGeometryEffect(
id: "title",
in: namespace,
properties: .position
)
To make the title transition above the header, set its zIndex to move the label on top
of the header:
.zIndex(2)
.matchedGeometryEffect(
id: "title",
in: namespace,
properties: .position
)
95
SwiftUI Animations by Tutorials Chapter 3: View Transitions
To animate the transition for the calendar icon and the date label,
add .matchedGeometryEffect to them as well:
.matchedGeometryEffect(
id: "date",
in: namespace,
properties: .position
)
To match them with the views inside EventLocationAndDate, follow the same steps
as for HeaderView.
Go to EventLocationAndDate and:
2. Add .matchedGeometryEffect to the calendar icon, the date label with the
"icon" and "date" identifiers, respectively:
.matchedGeometryEffect(
id: "date",
in: namespace,
properties: .position
)
96
SwiftUI Animations by Tutorials Chapter 3: View Transitions
HeaderView(
namespace: namespace,
event: event,
offset: offset,
collapsed: collapsed
)
EventLocationAndDate(
namespace: namespace,
event: event,
collapsed: collapsed
)
You’ve got the transitions working, but the button still goes out of sight when you
scroll down to the upcoming games list. To keep it accessible to the user at all times,
you’ll implement the floating action button concept.
if !collapsed {
Button(action: {}) {
...
}
}
Then, add .matchedGeometryEffect to the Text inside the button’s label with a
new identifier:
.matchedGeometryEffect(
id: "button",
in: namespace,
properties: .position
)
97
SwiftUI Animations by Tutorials Chapter 3: View Transitions
To make the button shrink smoothly while scrolling, add a constant .frame(width:)
to RoundedRectangle:
.frame(
width: max( // 2
Constants.floatingButtonWidth,
min( // 1
UIScreen.halfWidth * 1.5,
UIScreen.halfWidth * 1.5 + offset * 2
)
)
)
1. The min function returns the smaller value out of the two parameters it receives.
This ensures that as the offset value grows, the button’s width doesn’t go over
UIScreen.halfWidth * 1.5 or 75% of the screen width.
2. The max function does the exact opposite - it’s helpful to ensure the bottom limit
of a value. Once the offset value grows negatively, it caps the minimum value of
the button’s width to the Constants.floatingButtonWidth.
This way, although the button’s width depends on the offset, you limit its possible
values to the range between the Constants.floatingButtonWidth and 75% of the
screen width.
98
SwiftUI Animations by Tutorials Chapter 3: View Transitions
properties: .position
)
}
.padding(36)
}
}
In the collapsed state, you replace the label with an icon. And obviously we won’t
forget to link it to the original button with a .matchedGeometryEffect of the same
id.
VStack {
HeaderView(
namespace: namespace,
event: event,
offset: offset,
collapsed: collapsed
)
Spacer()
if collapsed {
collapsedButton
}
}
.zIndex(1)
99
SwiftUI Animations by Tutorials Chapter 3: View Transitions
Key Points
1. Transitions define the way a view appears and disappears from the screen.
4. A view will have different insertion and removal animations if you specify both
via .asymmetric(with:).
100
4 Chapter 4: Drawing
Custom Components
By Irina Galata
In the previous chapters, you got to use existing SwiftUI controls, like Image, Button
or various stacks, to build an animated component. In many cases, if not the
majority, they’re sufficient to make an app engaging and valuable. But what about a
non-trivial view requiring more intricate user interaction?
Avid basketball fans often have specific preferences regarding their seating. It’s not
enough to choose how many tickets they need for a game and have their credit card
charged. They also want to choose where their seats are.
In this chapter, you’ll build an interactive animated seating chart that lets users
select their favorite seats quickly and conveniently. Considering the complexity of
the shapes and their composition, drawing the various shapes from scratch is the
way to go.
By the end of this chapter, your seating chart will look like this:
101
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
Note: Some of the parts related to drawing and positioning the various
stadium parts are a bit math-heavy, but no worries - we’ve got you covered!
You can simply follow along the provided code snippets, or compare your code
against the final project of this project’s materials.
Open the starter project in this chapter’s materials, or start working from the end of
the previous chapter.
Start by creating a new SwiftUI view file that will contain all the above sub-
components and name it SeatingChartView.swift. For now, place a ZStack in the
body of the generated struct:
ZStack {
In SwiftUI, Shape is a 2-dimensional drawn view, which can have a stroke and a fill.
To implement a shape, you must create and manipulate a Path instance in the
path(in:) method and return the result.
102
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
First, you’ll declare the number of sectors your stadium contains. Add a new
constant to your Constants.swift file:
To calculate sectors’ frames based on the size of the stadium, add the following lines
inside Stadium’s path(in:):
// 1
Path { path in
let width = rect.width
1. You initialize a new Path using a special initializer which hands you a path
argument you can manipulate and draw the chart’s main outlines onto.
2. To make the stadium appear elliptical, you need a width-to-height ratio different
from 1.0.
3. Since the sectors are different sizes, you need to calculate the size change value,
vertically and horizontally, between the neighboring sectors.
103
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
Now, add a loop under the variables you just added to calculate frames for each
sector, and draw a rounded rectangle for each:
(0..<Constants.stadiumSectorsCount).forEach { i in
let sectionWidth = width - sectorDiff * Double(i) // 1
let sectionHeight = width / widthToHeightRatio - sectorDiff *
Double(i) // 2
let offsetX = (width - sectionWidth) / 2.0 // 3
let offsetY = (width - sectionHeight) / 2.0
path.addRoundedRect(
in: sectorRect,
cornerSize: CGSize(
width: sectorRect.width / 4.0,
height: sectorRect.width / 4.0
), // 4
style: .continuous
)
}
1. Because a rectangle is defined by its origin (x, y) and size (width, height), you
calculate those values based on the stadium’s size, making sure to deduct a value
of the sectorDiff each time to get the width of the next smaller sector.
2. In addition to the difference between the sectors, you must account for the
width-to-height ratio to calculate the sector’s height.
3. To calculate the offset of a sector’s origin, you calculate the difference between
the available width (or height) and the section width. You then divide it by two.
4. You draw a rounded rectangle into the path with corners a quarter of the sector’s
width.
To get the first preview of your component, add an instance of Stadium inside the
ZStack in SeatingChartView:
Stadium()
.stroke(.white, lineWidth: 2)
104
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
SeatingChartView()
.aspectRatio(1.0, contentMode: .fit)
.padding()
You set the aspect ratio to 1 to give the view a squared area to draw the stadium.
Your SwiftUI preview should look like the screenshot in the beginning of this section.
105
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
To animate the stadium field separately from the other components and fill it with a
different color, you need to draw it separately.
Then, in the same file, add a @Binding variable of the same type in Stadium shape:
Stadium(field: $field)
To draw the field inside the smallest sector frame, add a new variable above the loop
in Stadium:
Once you calculate the rectangle for a sector inside the loop, assign it to the new
variable. This will guarantee that when the loop finishes, the smallest sector Rect
stores inside this variable. Add this below let sectorRect = ...:
smallestSectorFrame = sectorRect
The field needs to be half the size of the smallest sector. You’ll calculate the exact
field Rect with the help of some simple math.
Below the path(in:) method of Stadium, add a new method to calculate and update
the field property:
106
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
Now, back in path(in:) and after the forEach, run computeField to assign the
result of the computation to field :
computeField(in: smallestSectorFrame)
With these preparations in place, you’re ready to draw the field. Below Stadium, add
a new shape to describe the field:
Path { path in
path.addRect(rect) // 1
path.move(to: CGPoint(x: rect.midX, y: rect.minY)) // 2
path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY)) // 3
path.move(to: CGPoint(x: rect.midX, y: rect.midX))
path.addEllipse(in: CGRect( // 4
x: rect.midX - rect.width / 8.0,
y: rect.midY - rect.width / 8.0,
width: rect.width / 4.0,
height: rect.width / 4.0)
)
}
2. Then, you move the path to the top center of the rectangle.
3. You draw the line from there toward the bottom center to split the field in half.
4. From the center of the field, you draw a circle with a diameter of a quarter of the
field’s width.
Field().path(in: field).fill(.green)
Field().path(in: field).stroke(.white, lineWidth: 2)
Right now, a path can either have a stroke or a fill. To have both, you need to
duplicate them. Run the app or check the preview to see how the field you just
created looks.
107
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
108
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
Now, create a new property inside SeatingChartView to make it aware when the
calculations of the tribunes are complete:
You’ll keep the tribunes as a dictionary of sector indices and the tribunes belonging
to it.
Then, to split the sectors-related computations from the general outline, create a
new Shape for a sector:
path.addRoundedRect( // 3
in: rect,
cornerSize: CGSize(width: corner, height: corner),
style: .continuous
)
}
}
}
1. To update the tribunes value once you finish the computations, you create a
@Binding in Sector, which you’ll pass from Stadium.
2. Sector needs a few values to position the tribunes. Specifically, their size and the
offset from the bounds of the current sector.
3. You move the drawing of the rounded rectangle from Stadium to Sector.
109
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
Now, go back to Stadium’s path(in:) and add the following variable above the loop:
Based on the difference between the sector sizes, you need to decide on the height of
a tribune. You divide the diff value by three to account for the vertical measurements
of two rows of tribunes, top and bottom, and the top and bottom spacings for both of
a quarter of a tribune’s height: two tribunes + spacings of 0.25.
For the width, you’ll use a ratio of 1:1.5, meaning fifty percent bigger than its height.
Then, replace the drawing of the rounded rectangle with the Sector shape you just
created. Replace path.addRoundedRect(...) with:
Here’s a breakdown:
1. Normally, the tribunes closer to the field are smaller than those on the borders.
Therefore, depending on the index of the current sector, you deduct a part of the
tribune width.
2. You divide the difference between the sectorDiff and the tribune’s height in
half to have equal top and bottom spacings for the tribune.
Now, to the tribunes. Create a new method inside the Sector struct:
Since you place the rectangular tribunes only along a sector’s straight segments,
horizontal and vertical, you need to keep track of the width and height of such a
segment.
110
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
Then you need to know how many tribunes would fit horizontally or vertically:
Notice that you divide the value in both cases by tribuneSize.width. In the sector’s
vertical segments, the tribunes rotate by 90 degrees, so you still need to operate with
the width to know how many would fit vertically.
Finally, before computing each tribune, you need to add a helper function to create a
Tribune instance out of a RectTribune. Add it right below
computeRectTribunesPaths(at:corner:):
2. Depending on whether the tribune is rotated, you swap the width and height
when building a CGRect.
111
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
2. The x value for the top and bottom horizontal tribune is the same, so you
calculate it beforehand, each time moving from left to right, by the width of the
tribune and the horizontal spacing.
3. You add the top tribune. To offset it vertically, you add the value of the offset to
the minY of the rectangle.
4. You place the bottom tribune by offsetting it from the bottom border of the
rectangle. Since the origin of a CGRect refers to its top left corner, you must also
deduct the tribune’s height.
Now, calculate the positions for the vertical tribunes and return the result:
(0..<Int(tribunesVerticalCount)).forEach { i in
let y = rect.minY + (tribuneSize.width + spacingV) *
CGFloat(i) + corner + spacingV / 2 // 1
tribunes.append(makeRectTribuneAt(x: rect.minX + offset, y: y,
rotated: true)) // 2
tribunes.append(makeRectTribuneAt(x: rect.maxX - offset -
tribuneSize.height, y: y, rotated: true)) // 3
}
return tribunes
112
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
1. The y value is equal for the left and right vertical pair of tribunes. In this case,
you move from top to bottom, starting from minY.
2. You add the left tribune by offsetting it from the rectangle’s minX. You pass true
as a rotated argument because the tribune is vertical.
3. You calculate the right tribune’s position similar to the bottom horizontal one.
You deduct the height value and offset from the maxX.
Now, assign the calculated result to the tribunes variable in the path(in:) method
of Sector below the line drawing the rounded rectangle:
1. Since you want to calculate the tribunes’ positions only once to improve the
performance, you check whether the dictionary already contains them. They’re
not moving once installed, right? :]
Finally, draw the tribunes in the SeatingChartView’s body right below Stadium:
113
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
Whew, that was a lot! But if you check out your preview, you’ll see you’ve made some
amazing progress. So close!
114
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
Look at the sectors you drew, and you’ll notice that the corners are lying precisely on
a circle with a radius the size of the corner of the rounded rectangle and the center in
(minX + corner, minY + corner) for the top left corner. This information is
sufficient to make further computations.
As you build an arc tribune, you need to outline the arcs of two circles, one bigger
and one smaller, and then connect them with straight lines.
115
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
To calculate the radiuses of these circles, start by adding these variables inside the
method:
Then, you’ll need to calculate how many tribunes would fit into the arc. First, you
obtain the length of the arc:
1. To calculate the arc length, you need to multiply the center angle in radians by
the radius.
2. Then, you divide the length by the width of the tribune and an additional twenty
percent of it to account for the spacings between the tribunes.
Now, you can calculate the angle of an arc needed for each tribune and an angle for
the spacing. When moving along an arc, it’s easier to operate with angles than sizes:
2. To calculate the angle for a tribune, divide the tribune’s width by the radius.
116
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
Now, you need to define an arc’s starting angles and a circle’s center for each corner
of the sector. Make a dictionary to track them:
1. You start from the top left corner of the sector. Its arc will go from .pi to 3 * .pi
/ 2.
2. The top right sector’s arc starts at 3 * .pi / 2 and ends at 2 * .pi.
Now, you need to iterate over the dictionary and compute the arcTribunesCount
amount of tribunes for each corner of the current sector. Add the following lines
below the dictionary:
tribunes.append(contentsOf: arcTribunes)
}
117
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
You’ll calculate the outer arc’s starting point and the inner ones for each tribune.
Add these calculations at the top of the inner map:
To calculate the x-coordinate of a point on a circle, you need to use the following
formula:
The startingPoint is the point from which you’ll draw the outer arc
counterclockwise. Then, you’ll draw a straight line toward the startingInnerPoint.
From there, draw an arc clockwise, then connect it to the startingPoint with a
straight line again.
Path { path in
path.move(to: startingPoint)
118
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
path.addArc(
center: center,
radius: radius,
startAngle: .radians(startAngle),
endAngle: .radians(endAngle),
clockwise: false
)
path.addLine(to: startingInnerPoint)
path.addArc(
center: center,
radius: innerRadius,
startAngle: .radians(endAngle),
endAngle: .radians(startAngle),
clockwise: true
)
path.closeSubpath()
}
return tribune
With this code, you add a new arc tribune on each iteration and continue moving
counterclockwise.Add a new method in Sector to calculate both types of tribunes
for the sector:
119
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
The most complicated part is over! You only need to add some bells and whistles to
make the seating chart view shine. But for now, run the app!
.onChange(of: tribunes) {
guard $0.keys.count == Constants.stadiumSectorsCount else
{ return } // 1
withAnimation(.easeInOut(duration: 1.0)) {
percentage = 1.0 // 2
}
}
120
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
Here’s a breakdown:
1. You check whether the data is complete every time the tribunes count changes,
for example, when you append a sector’s tribunes.
Every time the view appears on the screen, SwiftUI will animate the chart.
To give it a final touch, add background to the tribune inside the loop, below
the .stroke modifier:
.background(
tribune.path
.trim(from: 0.0, to: percentage)
.fill(.blue)
)
Time to check out the result, run the app and see for yourself! ;]
121
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
Once the user selects a tribune, you’ll zoom the chart with the chosen tribune as an
anchor.
.rotationEffect(.radians(.pi / 2))
.coordinateSpace(name: "stadium")
.scaleEffect(zoom, anchor: zoomAnchor)
Since every view defines its own coordinate system, the touch gestures have
coordinates in the context of the view where the gesture occurred. In the current
case, you want to receive touch events from each specific tribune but apply the zoom
anchor toward a bigger container. You need to operate in the same coordinate space
to make it possible.
The anchor of a scale effect is defined by its UnitPoint. Its x and y values lay in the
range of [0…1]. For example, the .center UnitPoint is (x: 0.5, y: 0.5). This
way, to translate normal coordinates into a UnitPoint, you need to divide them by
the width and height of the coordinate space’s owner, ZStack. To obtain its size, you
need to wrap the ZStack in a GeometryReader and fetch the value from its proxy:
122
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
1. First, you define the coordinate space in the context in which you want to receive
a touch event’s coordinates. You use the same name you used for the ZStack.
4. You create an instance of UnitPoint, pass the translated values to its initializer
and assign it to zoomAnchor if the user selects a tribune.
5. Finally, if the user selects a tribune, you increase the scale effect. Otherwise, you
decrease to the normal value and shift the zoom anchor toward the center of
ZStack.
Last but not least, to indicate that a tribune is selected, update its background fill.
Replace .fill(.blue) with:
123
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
124
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
Linking Animations
Right now, the selected tribune goes out of sight for a moment when zooming in,
which can feel somewhat confusing for a user. It would make sense to shift the
anchor and zoom the selected tribune so that a user doesn’t feel lost while the
animation is ongoing. When the tribune is deselected, the opposite would work
better - zoom out, then shift the anchor.
There are a few ways to chain the animations in SwiftUI, but no direct method. To
implement this API yourself, create a new Swift file named LinkedAnimation and
add:
import SwiftUI
struct LinkedAnimation { // 1
let type: Animation
let duration: Double
let action: () -> Void
Here’s a breakdown:
2. You create a helper function to quickly initialize the most commonly used
easeInOut type of linkable animation.
125
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
withAnimation(reverse ? type.delay(animation.duration) :
animation.type.delay(duration)) { // 2
reverse ? action() : animation.action()
}
}
Here you create an easy-to-use method that lets you link two animations in regular
or reverse order:
1. In case of the reverse order, you start with the second animation, pass it to the
withAnimation and execute its action inside the closure.
2. Right after, you execute the next animation but delay it for the duration of the
previous one.
LinkedAnimation
.easeInOut(for: 0.7) { zoom = unselected ? 1.25 : 12 }
.link(
to: .easeInOut(for: 0.3) {
selectedTribune = unselected ? nil : tribune
zoomAnchor = unselected ? .center : anchor
},
reverse: !unselected
)
With this code, you alternate the order of the zooming and anchor animations
depending on whether a user selected a tribune.
Run your app one more time, select and deselect some seats, and give yourself a
hard-earned pat on the back for some incredible work. Well done!
126
SwiftUI Animations by Tutorials Chapter 4: Drawing Custom Components
Key Points
1. To create a custom Shape, you need to create a struct conforming to the protocol
and implement path(in:), which expects you to return the shape’s path.
2. Apple uses a flipped coordinate system, which results in a flipped unit circle:
angle value increases downward from the positive x-axis direction.
4. UnitPoint represents an anchor in SwiftUI, its x- and y-values are in the range of
0.0 and 1.0. To convert those values to their normal, you must divide them by a
view’s width and height, respectively.
5. To ease the use of the coordinates across different views, you can define a
common coordinate space for them, using .coordinateSpace(name:) together
with .onTapGesture(count:coordinateSpace:perform).
127
5 Chapter 5: Applying
Complex Transformations &
Interactions
By Irina Galata
In the previous chapter, you learned how to draw a custom seating chart with
tribunes using SwiftUI’s Path. However, quite a few things are still missing. Users
must be able to preview the seats inside a tribune and select them to purchase
tickets. To make the user’s navigation through the chart effortless and natural, you’ll
implement gesture handling, such as dragging, magnifying and rotating.
As usual, fetch the starter project for this chapter from the materials, or continue
where you left off in the previous chapter.
128
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
}
}
}
The shape you’re about to draw consists of a few parts: the seat’s back, squab, and
rod connecting them. Start by defining a few essential properties right below inside
the Path’s trailing closure:
To emulate the top-to-bottom perspective, you calculate the seat back rectangle as
slightly shorter vertically than the squab.
Then, right below these variables, define the CGRect’s for the back and squab and
draw the corresponding rounded rectangles:
129
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
)
let squabRect = CGRect(
x: 0, y: rect.height / 2.0,
width: seatWidth, height: squabHeight
)
path.move(to: CGPoint(
x: rect.width / 2.0,
y: rect.height / 3.0
))
path.addLine(to: CGPoint(
x: rect.width / 2.0,
y: rect.height / 2.0
))
You still have a long way to go before looking at the seat’s shape as part of a tribune.
To get a quick preview for the time being, create a new struct called SeatPreview:
SeatShape().path(
in: CGRect(
x: 0, y: 0,
width: seatSize, height: seatSize
))
.stroke(lineWidth: 2) // 2
}
.frame(width: seatSize, height: seatSize)
}
}
This process is similar to the shapes you’ve drawn in the previous chapter:
1. Inside a ZStack, you use one instance of SeatShape as a background with .blue
fill.
2. You use the second shape’s instance to draw the seat’s stroke.
130
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
Finally, you must make Xcode show the SeatPreview in the previews window. Create
a new PreviewProvider:
Your seat preview should look like this, for the time being:
The seat is there but looks relatively flat. You’ll skew it back to give it a slightly more
realistic perspective. Don’t forget that you drew the tribunes all around the stadium
field, which means the seats should always face the center of the field. Head to the
next section to learn how to transform shapes!
Matrices Transformations
Check Path‘s API, and you’ll notice there are many methods, such as
addRoundedRect or addEllipse, accepting an argument of type
CGAffineTransform called transform. Via just one argument, you can manipulate a
subpath in 2D space in several ways: rotate, skew, scale or translate.
As you might have guessed from its prefix, CGAffineTransform is part of Apple’s
Core Graphics framework, which still comes in handy in SwiftUI.
131
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
You’ll work with the parameters a, b, c, d, tx and ty. The third column stays
unchanged regardless of the transformations you apply - 0, 0 and 1.
When you want to apply an offset to an object, you need a translation matrix, where
tx represents the shift along the x-axis, and ty moves the object along the y-axis:
132
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
A scaling operation is similar as well, having only two defining parameters, sx and
sy:
You do, however, need to use a, b, c and d to make a rotation matrix to rotate an
object counterclockwise by angle a:
133
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
Now, knowing all the transformation possibilities matrices offer you, you can sketch
out your action plan:
2. Then rotate the entire seat’s shape by an angle it accepts from the outside to face
the stadium field.
3. Finally, translate the seat’s shape to negate the translation effect from previously
rotating it since SwiftUI rotates a subpath around its (minX, minY) point. The
shape will appear to rotate around its center point without shifting sideways.
Next, you need to calculate how much further along the x-axis the seat back goes
after being skewed. You’ll account for this measurement when defining seatWidth,
thus making the whole shape fit into rect. Add the following variable right below
skewAngle:
To calculate the value of skewShift, you use a mathematical formula to find the
adjacent in the right triangle by the angle’s tangent.
134
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
Next, update the rod’s final point to connect it to the center of the squab. Replace:
path.addLine(to: CGPoint(
x: rect.width / 2.0,
y: rect.height / 2.0
))
With:
path.addLine(to: CGPoint(
x: rect.width / 2.0 - skewShift / 2,
y: rect.height / 2.0
))
Here comes the exciting part! Above the addRoundedRect invocations, create a
matrix to skew the seat back:
2. Since SwiftUI transforms an object around its origin point, you shift the x value
to keep the shape inside the rect’s bounds.
With:
path.addRoundedRect(
in: backRect,
cornerSize: cornerSize,
transform: skew
)
135
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
SeatShape(rotation: CGFloat(-rotation))
Then, wrap the root view in the preview’s body into a VStack. Then add a Slider
and a Text:
VStack {
136
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
Now, return to SeatShape and apply a rotation matrix to the path by mutating the
existing final path:
Well, that was easy, wasn’t it? Check out the preview and play around with the
rotation slider:
137
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
First, define the rotation point by adding the following variable at the very bottom of
Path { } before applying the rotation transformation:
Now, create the first translation matrix to shift the seat to the rotation point:
Additionally, you need a translation matrix to move the seat inside the rect’s
bounds:
Now, apply the transformations step-by-step. Create a variable to keep the result of
the first multiplication:
path = path.applying(result.concatenating(initialTranslation))
138
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
Check out the preview and move the slider’s knob around a bit to make sure the seat
rotates around its center:
That was a bit of a challenge. Great job! Next, to calculate the bounds for each seat in
all the rectangular tribunes.
You conform Seat to Hashable to iterate over a tribune’s seats to display them.
Later, you’ll enable users to pick a specific seat, so being Equatable will also come in
handy.
139
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
// TODO
return seats
}
This method will eventually calculate the bounds for the seats based on the CGRect
of the tribune and the rotation.
Start by defining all the necessary values, such as size, the number of horizontal and
vertical seats and spacings. Add these lines in the // TODO above:
Below the variables you’ve just added, create two loops to iterate over all the seats:
(0..<columnsNumber).forEach { column in
(0..<rowsNumber).forEach { row in
}
}
Inside the inner loop, calculate the origin points for each seat and build a CGRect:
140
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
Finally, create a SeatShape, pass the rotation to it and append Seat to the array:
seats.append(Seat(
path: SeatShape(rotation: rotation)
.path(in: seatRect)
)
)
Note that you also removed the default value for vertical, so you’ll need to provide
this in all invocations, or the compiler will throw an error. You’ll handle that shortly.
Now, create a variable for the tribune’s CGRect inside the method:
Use rect to instantiate Tribune and calculate the seats by updating the return
statement:
return Tribune(
path: RectTribune().path(in: rect),
seats: computeSeats(for: rect, at: rotation)
)
Now, the compiler will be unhappy about some missing arguments. To sort it out,
pass an empty array as the last parameter to the arc tribune initializer.
141
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
tribunes.append(Tribune(path: ArcTribune(
/* arc tribune's properties */
).path(in: CGRect.zero), seats: []))
tribunes.append(makeRectTribuneAt(
x: x,
y: rect.minY + offset,
vertical: false,
rotation: 0
))
tribunes.append(makeRectTribuneAt(
x: x, y: rect.maxY - offset - tribuneSize.height,
vertical: false,
rotation: -.pi
))
For the vertical tribunes, pass -.pi / 2.0 and 3.0 * -.pi / 2.0:
tribunes.append(makeRectTribuneAt(
x: rect.minX + offset,
y: y,
vertical: true,
rotation: -.pi / 2.0
))
tribunes.append(makeRectTribuneAt(
x: rect.maxX - offset - tribuneSize.height,
y: y,
vertical: true,
rotation: 3.0 * -.pi / 2.0
))
Finally, you can display the selected tribune’s seats! Go to SeatingChartView’s body
and add the following code after the tribunes ForEach:
if let selectedTribune {
ForEach(selectedTribune.seats, id: \.self) { seat in
ZStack {
seat.path.fill(.blue)
seat.path.stroke(.black, lineWidth: 0.05)
}
}
}
142
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
143
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
// TODO
return seats
}
An arc tribune has seat columns of the same size, but the rows shrink toward the
stadium field. So, define the “static” variables right away in the method, instead of
the // TODO mark:
(0..<rowsNumber).forEach { row in
Inside the loop, add variables that will dynamically change depending on the row:
1. For each row, you calculate the radius of a circle. You’ll place the row’s seats
along an arc of this circle, just as you did when drawing the arc tribunes’ outlines.
2. You multiply the difference between the tribune’s endAngle and startAngle by
the radius to produce the length of the corresponding arc.
3. Based on the length of the arc, you calculate the number of seats in the row. You
multiply seatSize by 1.1 to give a slight spacing between the seats.
144
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
1. To calculate the spacing, you deduct the sum of all seat sizes from the arc length
and divide the result by the number of seats.
2. Dividing seatSize by radius gives you the angle needed for each seat. Although
seatSize is the measurement of a seat along a straight line, you need an arc
measurement for the formula. The difference between them is negligible in this
case.
3. Applying the same formula, you calculate the angle needed for the spacing
between the seats.
4. previousAngle contains the latest offset along the arc, and you’ll update it after
each seat’s calculations.
(0..<arcSeatsNum).forEach { _ in
Inside the inner loop, calculate the “center” of each seat based on previousAngle:
With the approach above, you’ll iteratively move along the arc, centering the seats
precisely on the arc.
Knowing the seat’s center, you can calculate its origin and bounds:
145
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
seats.append(
Seat(
path: SeatShape(rotation: previousAngle + .pi / 2)
.path(in: seatRect)
)
)
Since the seats’ angles are perpendicular to the angle of the tribune, meaning you
drew tribunes from left to right, but you draw the seats from the tribune’s top to
bottom, you need to add .pi / 2 to previousAngle.
146
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
Nice! It’s now time to let the user actually interact with the seats.
SwiftUI offers a variety of gesture handlers, most of which are valuable for the
seating chart.
147
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
Dragging
To obtain the offset value from the user’s drag gesture, you’ll use SwiftUI’s
DragGesture. First, add these new properties to SeatingChartView:
@GestureState is a property wrapper that keeps drag up-to-date when the gesture
that is ongoing and will reset it to its initial state once the user is done. The offset
property keeps the latest value between the gestures to avoid resetting it.
Since CGSize is the measurement for a drag gesture, add a handy extension to ease
CGSizes concatenation:
extension CGSize {
static func +(left: CGSize, right: CGSize) -> CGSize {
return CGSize(width: left.width + right.width, height:
left.height + right.height)
}
}
2. Once the gesture is over, .onEnded is invoked. There you update the offset
property to ensure the chart stays in place once the user lifts their finger.
.offset(offset + drag)
148
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
.simultaneousGesture(dragging)
SwiftUI can handle multiple gestures at the same time. Use .simultaneousGesture
to indicate that you’d like to enable more than one gesture giving them equal
priority.
Currently, you have two gesture handlers: dragging and the tap gesture handler for
the tribunes. You’ll add a few more soon. Now, when you run the app, the chart is
easily draggable:
149
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
Zooming
Like DragGesture, you can use MagnificationGesture to obtain the current
gesture’s scale.
.simultaneousGesture(magnification)
150
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
If you run it on a simulator, hold the Option (⌥) key and drag the chart with your
mouse to emulate a magnification gesture.
Rotating
The 2D rotating gesture is as easily implemented in SwiftUI as the others. You know
what to do! Add another @GestureState property, and add a rotation property
keep track of the applied rotation:
151
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
Last but not least, add the gesture handler below the two previous ones:
.simultaneousGesture(rotationGesture)
That was a piece of cake, right? :] Next, you’ll implement seat selection and add
some bells and whistles.
152
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
A tap gesture to pick a tribune and one to pick a seat should be mutually exclusive:
they can’t co-occur. Therefore, it makes sense to handle both in one gesture handler
and decide which one should occur depending on the coordinates of the touch.
Remove .onTapGesture from the tribune, and add a new .onTapGesture to the
ZStack above .scaleEffect:
.onTapGesture { tap in
if let selectedTribune, selectedTribune.path.contains(tap) {
// TODO pick a seat
} else {
// TODO pick a tribune
}
}
Now, if a user has already selected a tribune and the touch occurred inside its
bounds, it’s safe to assume the user tapped a seat. Otherwise, they’ve chosen a
tribune.
withAnimation(.easeInOut) {
if let index = selectedSeats.firstIndex(of: seat) { // 2
selectedSeats.remove(at: index)
} else {
selectedSeats.append(seat)
}
}
}
153
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
Here’s a breakdown:
1. First, you search for a seat containing the coordinates of the touch among the
selected tribune’s seats. If there is none, you return immediately.
2. Finally, you select or deselect the seat depending on whether the seat is present
in selectedSeats.
LinkedAnimation.easeInOut(for: 0.7) {
zoom = unselected ? 1.25 : 25
}
.link(
to: .easeInOut(for: 0.3) {
selectedTribune = unselected ? nil : tribune
zoomAnchor = unselected ? .center : anchor
offset = .zero
},
reverse: !unselected
)
}
Like the seat selection, you first search for the tribune containing the needed
coordinates. After that, you proceed the way you have since the previous chapter,
except the offset is reset to .zero when zooming in or out.
154
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
Now update a seat’s .fill depending on whether the user has selected it. Replace:
seat.path.fill(.blue)
With:
As the last step, remove .coordinateSpace from the ZStack. Now all touch events
occur in the same view, so there’s no need to convert the coordinate space.
You’re so close to the finish line with only a few things left to polish.
seat.path
.trim(from: 0, to: seatsPercentage)
.fill(selectedSeats.contains(seat) ? .green : .blue)
155
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
seat.path
.trim(from: 0, to: seatsPercentage)
.stroke(.black, lineWidth: 0.05)
Now, the animation will reset every time you select a new tribune.
Check it out:
156
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
withAnimation {
zoomed = zoom > 1.25
}
zoomed = !unselected
Then, to zoom out and reset the chart, if zoomed gets updated from
SeatsSelectionView, add .onChange to the ZStack in SeatingChartView:
.onChange(of: zoomed) {
if !$0 && zoom > 1.25 {
LinkedAnimation.easeInOut(for: 0.7) {
zoom = 1.25
seatsPercentage = 0.0
}
.link(
to: .easeInOut(for: 0.3) {
selectedTribune = nil
zoomAnchor = .center
offset = .zero
},
reverse: false
)
}
}
157
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
SeatingChartView(
zoomed: Binding.constant(false),
selectedTicketsNumber: Binding.constant(5)
)
SeatingChartView(
zoomed: $stadiumZoomed,
selectedTicketsNumber: $selectedTicketsNumber
)
Now, wrap the inner VStack containing the team name and the cart icon into the if-
statement, and add a .transition:
if !stadiumZoomed {
VStack { ... }
.transition(.move(edge: .top))
}
Now when a user zooms on the chart, the title and icon go out of sight to make the
screen less cluttered.
To indicate the number of selected tickets, wrap the cart icon in a ZStack, and add a
label:
ZStack(alignment: .topLeading) {
/* cart icon */
if selectedTicketsNumber > 0 {
Text("\(selectedTicketsNumber)")
.foregroundColor(.white)
.font(.caption)
.background {
Circle()
.fill(.red)
.frame(width: 16, height: 16)
}
.alignmentGuide(.leading) { _ in -20}
.alignmentGuide(.top) { _ in 4 }
}
}
158
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
Using the alignment guides, you adjust the label to appear on the top right corner of
the icon.
To reset the gestures quickly, add a zoom-out button below the Buy Tickets button
and wrap both into an HStack:
HStack {
/* Buy Tickets button */
if stadiumZoomed {
Button {
withAnimation {
stadiumZoomed = false
}
} label: {
Image("zoom_out")
.resizable()
.scaledToFit()
.frame(width: 48, height: Constants.iconSizeL)
.clipped()
.background {
RoundedRectangle(cornerRadius: 36)
.fill(.white)
.frame(width: 48, height: 48)
.shadow(radius: 2)
}
.padding(.trailing)
}
}
}
if selectedTicketsNumber > 0 {
ticketsPurchased = true
}
Finally, you need to show a pop-up to tell the user that the purchase was successful.
Add .confirmationDialog to the root view, right below
background(Constants.orange, ignoresSafeAreaEdges: .all):
.confirmationDialog(
"You've bought \(selectedTicketsNumber) tickets.",
isPresented: $ticketsPurchased,
actions: { Button("Ok") {} },
message: { Text("You've bought \(selectedTicketsNumber)
tickets. Enjoy your time at the game!")}
)
159
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
Ta-da! You’ve done it! Run the app to see the final result:
160
SwiftUI Animations by Tutorials
Chapter 5: Applying Complex Transformations & Interactions
Key Points
1. CGAffineTransform represents a transformation matrix, which you can apply to
a subpath to perform rotation, scaling, translating or skewing.
Additionally, if matrices don’t scare but excite you, and you want to dive deep into
Metal, Apple’s low-level computer graphics framework, Metal by Tutorials (https://
www.kodeco.com/books/metal-by-tutorials/v2.0) can guide you step-by-step along
your journey.
161
6 Chapter 6: Intro to Custom
Animations
By Bill Morefield
In this book, you’ve explored many ways SwiftUI makes animation simple to achieve.
By taking advantage of the framework, you created complex animations with much
less effort than previous app frameworks required. For many animations, this built-
in system will do everything that you need. However, as you attempt more complex
animations, you’ll find places where SwiftUI can’t do what you want without further
assistance.
Fortunately, the animation support in SwiftUI includes protocols and extensions that
you can use to produce animation effects beyond the basics while still having SwiftUI
handle some of the work. This support lets you create more complex animations
while still leveraging SwiftUI’s built-in animation capabilities.
In this chapter, you’ll start by adding a standard SwiftUI animation to an app. Then
you’ll learn to implement animations beyond the built-in support while having
SwiftUI handle as much as possible.
162
SwiftUI Animations by Tutorials Chapter 6: Intro to Custom Animations
The app lists several types of tea and provides suggestions on water temperature and
the amount of tea leaves needed for the desired amount of water. It also provides a
timer that counts down the time needed to steep the tea.
You’ll see information for brewing the tea you selected. Adjust the amount of water,
and it’ll update the needed amount of tea leaves to accommodate the change. It also
lets you start and stop a timer for steeping the tea. When you start the brewing
timer, it begins a countdown until your steeping completes.
163
SwiftUI Animations by Tutorials Chapter 6: Intro to Custom Animations
While it works, the app lacks energy and excitement. You’ll add some animations
that give it more energy and help users in their quest for the best tea.
First, the ring around the timer turns blue when you start the timer. While the color
change does show the timer is running, it doesn’t attract the eye. To do so, you’ll
animate the timer’s border as a better indicator of a running timer.
Open TimerView.swift, and you’ll see the code for this view. The
CountingTimerView used in this view contains the control for the timer. It currently
uses overlay(alignment:content:) to add a rounded rectangle with the color
provided by the timerBorderColor computed property. You’ll add a special case to
display an animated border when the timer is running.
164
SwiftUI Animations by Tutorials Chapter 6: Intro to Custom Animations
After the existing state properties, add the following new property:
You’ll use this property to control and trigger the animation by toggling its value.
The animation here will animate the border around the timer display and controls.
You’ll animate the border so it appears to move in a circle around the digits and
controls. To do this, you’ll create an angular gradient or conic gradient.
Unlike the more common linear gradient, which blends colors based on the distance
from a starting point, an angular gradient blends colors as it sweeps around a central
point. Instead of the distance from the starting point determining the color, the
angle from the central point determines the color. All points along a line radiating
from the center will share the same color.
Add the following code after the existing computed properties to create the angular
gradient:
You specify the gradient will begin as a dark shade of black, transition to olive green
at the midpoint, and then back to the same shade of black at the end. You set the
gradient to use the center of the view as its origin. To allow animation, you set the
angle by multiplying animateTimer by 360 degrees.
165
SwiftUI Animations by Tutorials Chapter 6: Intro to Custom Animations
Now find the overlay modifier on CountingTimerView and replace its contents
with:
switch timerManager.status {
case .running:
RoundedRectangle(cornerRadius: 20)
.stroke(animationGradient, lineWidth: 10)
default:
RoundedRectangle(cornerRadius: 20)
.stroke(timerBorderColor, lineWidth: 5)
}
While the timer runs, you apply a different style to stroke(_:lineWidth:) that
uses the gradient you just added. You also widen the line to draw the eye and provide
more space for the animation to show, and add another visual indicator that
something has changed.
Now, build and run the app. Tap any tea and then start the timer. The border takes on
the new broader gradient but doesn’t animate yet. You’ll do that in the next section.
166
SwiftUI Animations by Tutorials Chapter 6: Intro to Custom Animations
case .running:
// 1
withAnimation(
.linear(duration: 1.0)
// 2
.repeatForever(autoreverses: false)
) {
// 3
animateTimer = true
}
2. By default, the animation only occurs once when the state changes. You could do
a continuous change of animateTimer, perhaps by tying it directly to the elapsed
time. Still, there’s an easier way. repeatForever(autoreverses:) tells SwiftUI
to restart the animation when it completes. By default, the animation would
reverse before repeating. You pass false to autoreverses to skip the reversing
of the animation.
3. You change animateTimer to true. Since this occurs in the closure, it’ll animate
the state change using the specified animation. The state changes cause the
angular gradient to rotate one complete revolution, which will be animated.
167
SwiftUI Animations by Tutorials Chapter 6: Intro to Custom Animations
Run your app, select any tea and start the timer. You’ll see the gradient rotates while
the timer counts down.
Next, you’ll look at a similar animation using opacity to produce a pulsing effect
when the user pauses the timer.
You’ll change this state property to trigger the animation when the user pauses the
timer.
168
SwiftUI Animations by Tutorials Chapter 6: Intro to Custom Animations
case .paused:
RoundedRectangle(cornerRadius: 20)
.stroke(.blue, lineWidth: 10)
.opacity(animatePause ? 0.2 : 1.0)
You added a new option for the case when the timer reaches the paused state. As
with the others, you apply a stroke(_:lineWidth:), in this case, a blue line the
same width as when running. You then apply opacity(_:) using animatePause to
change it between 0.2 (almost transparent) to 1.0 (fully opaque).
Now find the onChange(of:perform:) modifier you worked in earlier. Add the
following line right at the beginning of the .running case:
animatePause = false
This code resets the property when the timer begins running. It’s essential to ensure
the animation is ready if triggered again.
Now you need to handle the new paused state. Still in onChange(of:perform:), add
a new case to handle the .paused state:
case .paused:
// 1
animateTimer = false
// 2
withAnimation(
.easeInOut(duration: 0.5)
.repeatForever()
) {
animatePause = true
}
// 3
animateTimer = false
animatePause = false
169
SwiftUI Animations by Tutorials Chapter 6: Intro to Custom Animations
1. When your time switches to the paused state, you set animateTimer to false.
Setting animateTimer back to its original state prepares it if the user starts the
timer again.
2. You use an explicit animation when setting animatePause to true. Recall this
will change the opacity from 0.2 to 1.0. You apply an ease-in-out animation
lasting one half-second. You also apply repeatForever(autoreverses:) using
the default parameter for autoreverses, which will reverse the animation before
repeating it. As a result, the animation will cycle from dim to bright and back
once per second.
3. If the timer status changes to any other state, then neither animation should be
active, and you set both state properties to false.
Run your app, select any tea and start the timer. After a few seconds, pause the timer.
You’ll see the border of the timer pulse.
170
SwiftUI Animations by Tutorials Chapter 6: Intro to Custom Animations
These two animations work like many others you’ve seen, taking advantage of
SwiftUI automatically handling the animation for a Bool value such as
animateTimer. In the next section, you’ll learn how to handle more complex cases
when SwiftUI can’t handle the animation for you. It’s time to look into the
Animatable protocol.
SwiftUI can’t manage a change to a Path or a shift in the text shown in a Text view.
In these cases, you can conform to the Animatable protocol and manage the
animation yourself.
In this section, you’ll use Animatable to implement a text view that can animate the
transition between two numbers. In these cases, you’ll turn to the underlying
structure you’ve been using and directly implement what you need.
Behind the scenes of every SwiftUI animation lies the Animatable protocol. You turn
to it when you can’t do what you want with just animation(_:) or
withAnimation(_:_:).
171
SwiftUI Animations by Tutorials Chapter 6: Intro to Custom Animations
Create a new SwiftUI View file named NumberTransitionView.swift and open it.
Update the definition of the generated struct to:
Adding the Animatable protocol lets you provide direct control of the animated
values. Next, add the following code to the top of the struct:
Here, you create a property to hold the number the view will display as an Int. You’ll
also let the user pass in a string to append to the number.
Text(String(number) + suffix)
You display the number and append the passed suffix to the end. Finally, update the
preview to provide a number and the suffix by changing it to:
172
SwiftUI Animations by Tutorials Chapter 6: Intro to Custom Animations
If you look at the preview, you won’t see much difference between this view and a
regular text view showing a number.
The difference will only show when you animate the view. You’ll do that in the next
section.
You’ll use this property to change the value displayed. Initially, you set it to zero, so
you can change it when the view appears. Now attach the following modifier to the
VStack before padding(_:_:):
.onAppear {
withAnimation(.easeOut(duration: 0.5)) {
brewingTemp = brewTimer.temperature
}
}
173
SwiftUI Animations by Tutorials Chapter 6: Intro to Custom Animations
You set the state property to the temperature passed into this view. You wrap this
change inside an explicit call to withAnimation(_:_:) and specify an ease-out
animation that lasts one half-second.
You choose the ease-out animation because the fast initial change of this type of
animation makes the interface seem speedy. The short duration also gives the user
enough time to see the animation while remaining quick enough so they don’t grow
impatient.
Before implementing the animation, you’ll change the view so you can better
compare it to the final animation. Find the line in the view that reads Text("\
(brewTimer.temperature) °F") and change it to:
Text("\(brewingTemp) °F")
This change shows the new property instead of the brewing temperature passed into
the view. So, the value will initially be zero and change to the final temperature.
Run the app and select any tea. When the view appears, you’ll see what you probably
expected. The initial view showing the zero fades out, and the new view showing the
desired temperature fades in. SwiftUI doesn’t know how to animate text changing
from zero to a temperature, so it uses a view transition.
174
SwiftUI Animations by Tutorials Chapter 6: Intro to Custom Animations
Change the text line to use your new view. Replace the view showing the brewing
temperature with:
That’s the power of the Animatable protocol! It lets you make almost anything you
can imagine animate with SwiftUI. You take care of the state change as before and let
SwiftUI calculate the changed values. In your view, you accept the changing values
through the protocol and show appropriate values.
In the next section, you’ll work on a more complex scenario to produce a better
animation for the timer as it counts down.
175
SwiftUI Animations by Tutorials Chapter 6: Intro to Custom Animations
Before digital timers, clocks often used mechanical movements that moved printed
numbers to show time. In this section, you’ll create an animated version of this type
of display for the steeping timer as it counts down. You’ll begin by creating a new
view that shows each timer digit in a separate view.
Create a new SwiftUI View file named TimerDigitsView.swift. Add this new
property to the top of the view:
You’ll pass in the digits of the timer as an array of Int values. The first two store the
minutes, and the last two values in the array store the seconds. This change will
display these values individually and make each digit easier to animate. Add this
code below the digits property:
This computed property will return false only when both digits of the minutes are
zero. You’ll use this to help format the numbers in this view. Now change the body of
the view to:
HStack {
// 1
if hasMinutes {
// 2
if digits[0] != 0 {
Text(String(digits[0]))
}
// 3
Text(String(digits[1]))
Text("m")
176
SwiftUI Animations by Tutorials Chapter 6: Intro to Custom Animations
}
// 4
if hasMinutes || digits[2] != 0 {
Text(String(digits[2]))
}
// 5
Text(String(digits[3]))
Text("s")
}
1. You check the computed property and see if the time contains minutes. If not,
then you skip displaying information about the minutes.
2. When minutes exist, you display the first digit as long as it’s not zero.
3. You always show the second digit of the minutes followed by the letter m to
indicate this value shows minutes.
4. If the first seconds digit is zero or if you had minutes, you show the first seconds
digit. This condition will display a leading zero only when the time contains
minutes.
5. You always show the seconds digit and an s string to indicate these show
seconds.
TimerDigitsView(digits: timerManager.digits)
Now you use this new view to show the time. The digits property of timerManager
formats an array with the desired data based on the remaining time.
Run the app, select a tea and start the timer. The view looks similar to the original,
except now, each digit is a separate view instead of a single Text view showing a
formatted string.
177
SwiftUI Animations by Tutorials Chapter 6: Intro to Custom Animations
Visually, the most noticeable difference is more spacing between the letters and
numbers indicating the time.
Now you’ll build a view to animate these individual digits. Create a new SwiftUI View
file named SlidingNumber.swift. Open the new view and change the definition of
the struct to:
As a reminder, adding Animatable tells SwiftUI that you’ll make this view support
animation. As before, you implement the animatableData required by the protocol.
Add this code to the top of the struct:
You store the value sent by SwiftUI in a Double property named number. You might
wonder why you need a Double here instead of the Int you used in the last section,
even though you’ll only display single integer digits.
178
SwiftUI Animations by Tutorials Chapter 6: Intro to Custom Animations
The reason comes down to the granularity of the data. In the previous section, you
produced animation between far apart integers. Here, you’ll change between
adjacent digits. To create a smooth animation, you need the fractional values
between the two numbers.
SlidingNumber(number: 0)
With this view in place, you have the foundation to animate the timer. In the next
section, you’ll examine how to get the desired effect.
Building an Animation
When developing an animation, it helps to consider the visual effect you want to
achieve. Go back to the inspiration of a sliding scale of digits. You’ll implement a
strip of numbers starting at nine and then moving down through zero. When the
digit changes, the strip of numbers shifts to show the new value.
In SwiftUI terms, you want a vertical strip of the numbers around the new value.
When the number changes, SwiftUI will provide a series of values between the
original and new number through animatableData.
Look at this example where number begins at four and changes to three.
The series provided through animatableData begins at four and will decrease to
three though the exact values will vary depending on the type of animation. The first
value is slightly below four. The fractional part of the number indicates how far
you’re through the change in the digit and begins as near one and approaches zero.
179
SwiftUI Animations by Tutorials Chapter 6: Intro to Custom Animations
As that fractional part decreases, you shift the number upward toward the new
number.
Once the number reaches the new value of three, the view resets so that the central
value is that new number. The cycle can then repeat when the number changes
again. With that background, you can now implement it in SwiftUI in the next
section.
// 1
let digitArray = [number + 1, number, number - 1]
// 2
.map { Int($0).between(0, and: 10) }
1. You create an array of the number after the current number, the current number
and the numbers below the current number. If number is four, the array would
contain [5, 4, 3]. This array lets the animation flow in both directions.
2. You use map on the array to convert the values to integers and remove that
fractional amount from the Double. You also use between(:and) from
IntegerExtensions.swift to handle the edge cases. The value below zero is nine,
and the value above nine is zero.
// 1
VStack {
Text(String(digitArray[0]))
Text(String(digitArray[1]))
Text(String(digitArray[2]))
}
// 2
180
SwiftUI Animations by Tutorials Chapter 6: Intro to Custom Animations
.font(.largeTitle)
.fontWeight(.heavy)
// 3
.frame(width: 30, height: 40)
// 4
.offset(y: 40 * shift)
This code implements the steps discussed in the last section. Here’s how each part
creates part of the animation:
1. To create the strip of digits, you use a VStack showing the integers you stored in
digitArray.
2. You apply the .largeTitle font with a heavy weight to let the digits stand out.
3. You set the frame for the view to 30 points wide and 40 points tall. The height
matches the distance between digits in the VStack.
4. You take the shift you calculated earlier as the portion of the height that the
view should shift for the current point in the animation. You multiply it by 40,
the distance between digits in the stack. That converts the shift into an amount
of vertical movement for the view.
Now you need to use this new view. Open TimerDigitsView.swift and change the
body to:
HStack {
if hasMinutes {
if digits[0] != 0 {
SlidingNumber(number: Double(digits[0]))
}
SlidingNumber(number: Double(digits[1]))
Text("m")
}
if hasMinutes || digits[2] != 0 {
SlidingNumber(number: Double(digits[2]))
}
SlidingNumber(number: Double(digits[3]))
Text("s")
}
This code replaces the Text views from earlier with your new SlidingNumber view.
Run the app, select any tea and start the timer.
In this state, you’ll see the entire strip of digits. As it animates, note that the strip
shifts and how new numbers appear and vanish as the animation progresses.
181
SwiftUI Animations by Tutorials Chapter 6: Intro to Custom Animations
Once you watch the animation, you’ll finish cleaning up the view. Open
SlidingNumber.swift. Add two more modifiers after offset(x:y:):
// 1
.overlay {
RoundedRectangle(cornerRadius: 5)
.stroke(lineWidth: 1)
}
// 2
.clipShape(
RoundedRectangle(cornerRadius: 5)
)
2. While there is a strip of numbers, you only want to show a single number at a
time. You do this using clipShape(_:style:) with RoundedRectangle that
matches the one used to produce the frame in step two. This shape fills the frame
and clips to the frame you applied to the view. Clipping removes any elements
outside that space and hides the extra digits in the VStack.
182
SwiftUI Animations by Tutorials Chapter 6: Intro to Custom Animations
Run the app and start a steeping timer. You’ll see only a single digit that animates as
the timer changes. It also has a nice surrounding border that helps each number
stand out.
Challenge
Using what you’ve learned in this chapter, adjust the timer animation so the digits
slide in the opposite direction and the numbers slide downward. As a hint, recall that
in SwiftUI, a decrease in offset will cause a shift upward. How can you make that
move down instead?
Check the challenge project in the materials for this chapter for one solution.
183
SwiftUI Animations by Tutorials Chapter 6: Intro to Custom Animations
Key Points
• An angular gradient shifts through colors based on the angles around a central
point.
• The Animatable protocol provides a method to help you handle the animation
within a view yourself. You only need to turn to it when SwiftUI can’t do things for
you.
• When using the Animatable protocol, SwiftUI will provide the changing value to
your view through the animatableData property.
• You can find other examples of using the Animatable protocol in Getting Started
with SwiftUI Animations (https://www.kodeco.com/5815412-getting-started-with-
swiftui-animations).
• You’ll also explore the Animatable protocol more in the next section, including
learning how to deal with animations involving multiple elements.
184
7 Chapter 7: Complex
Custom Animations
By Bill Morefield
By now, you can see that creating more complex animations in SwiftUI relies on
understanding how the SwiftUI protocols and animation engine work. Done
correctly, your custom animations still use SwiftUI to handle as much work as
possible.
To create more complex animations, you often need to combine several elements
working together. One way to produce a more complex animation is to combine view
transitions with animated state changes. Animating the appearance and removal of a
view while animating a state change can make a view stand out and clarify the
relationship between new elements on a view.
In the previous chapter, you worked on adding animations to your custom views. Up
to this point, your animations were limited to relying on a single property, but
SwiftUI also supports animating multiple property changes within the same view. In
this chapter, you’ll create a view that supports five independently animated values.
First, you’ll look at how to combine transitions and animations to produce a unified
animation.
185
SwiftUI Animations by Tutorials Chapter 7: Complex Custom Animations
Open TimerView.swift. You’ll see the timer is now at the top of the view to make it
easier to see. The timer also adds a slider to let the user adjust the brewing length.
Further down, you’ll see the familiar information showing the suggested brewing
temperature and a slider that lets the user adjust the amount of water so the app can
provide a suggested amount of tea. You’ll now add a button so the user can adjust
the suggested ratio of tea to water.
Create a new SwiftUI view file inside the Timer folder named
PopupSelectionButton.swift. Add the following properties to the generated view:
These properties provide a binding that passes the selection back from the view. It
also allows passing in an array of Double values that can be selected. Replace the
preview body with:
PopupSelectionButton(
currentValue: .constant(3),
values: [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0]
)
This code provides the view sample settings. Update the view’s body to:
Group {
if let currentValue {
Text(currentValue, format: .number)
.modifier(CircledTextToggle(backgroundColor:
Color("Bourbon")))
} else {
Text("\(Image(systemName: "exclamationmark"))")
.modifier(CircledTextToggle(backgroundColor: Color(.red)))
}
}
186
SwiftUI Animations by Tutorials Chapter 7: Complex Custom Animations
This code attempts to unwrap the currentValue binding property. If successful, the
value will display using the color bourbon for the background. If not, the view will
show an exclamation mark with a red background. You wrap the conditional inside a
Group so you can apply additional modifiers to the two view states without repeating
code. The CircledTextToggle view modifier is identical to the CircledText view
modifier, except it applies a fixed frame to the Text. Without adding this frame, the
changing size of the Text view when transitioning from text to a system image
would cause the view to shift.
Since you provided the preview a value of 3, you’ll now see the result, which shows
the numeral three with the bourbon color background.
Basic button
Your button shows the value but doesn’t let the user change it. You’ll implement that
in the next section.
This state property stores whether the view should show the options. To toggle it,
add the following modifier to Group:
.onTapGesture {
showOptions.toggle()
}
187
SwiftUI Animations by Tutorials Chapter 7: Complex Custom Animations
When the user taps the view, you toggle showOptions. Now you need to show the
user the options. You’ll lay out the options in an arc starting above the button. Add
the following methods after the body:
1. You set distance to 180 for the radius of a circle. You’ll lay the buttons along
this circle.
2. You want each button rotated 15 degrees from the previous one, so you multiply
the index by 15. You then add 90 to this value which rotates the element’s
location a quarter turn counter-clockwise. Note this difference from SwiftUI
rotations. In a SwiftUI rotation, an increase in the angle rotates further clockwise.
You then convert the angle from degrees to radians.
3. Then you multiply the distance by the sine of the angle. The Swift sin function
expects the angle in radians, which you converted to in the previous step. You
then subtract the distance from this value which shifts the circle’s center to the
left. As a result, the x offsets start in line with the button and then decrease as
the angle increases.
The vertical offset calculation works the same, except you use a cosine since you’re
dealing with the y value. You subtract 45 to shift the circle’s center to just above the
button. With those methods to calculate each view’s position, you can now show the
options in the view.
Wrap the current Group inside a ZStack by holding down Command and clicking the
Group view. Then select Embed in ZStack from the menu. A ZStack overlays its
views, with each view lying above the previous views in the stack. Since you want to
overlay these options, this is perfect.
188
SwiftUI Animations by Tutorials Chapter 7: Complex Custom Animations
// 1
if showOptions {
// 2
ForEach(values.indices, id: \.self) { index in
// 3
Text(values[index], format: .number)
.modifier(CircledText(backgroundColor:
Color("OliveGreen")))
// 4
.offset(
x: xOffset(for: index),
y: yOffset(for: index)
)
// 5
.onTapGesture {
currentValue = values[index]
showOptions = false
}
}
Text("\(Image(systemName: "xmark.circle"))")
.transition(.opacity.animation(.linear(duration: 0.25)))
.modifier(CircledTextToggle(backgroundColor: Color(.red)))
}
2. You iterate through the indices property of the values array to get the index of
each element in the array.
3. Then, you show the value using the initializer Text that allows passing a format.
Using the number format also displays the value concisely with only the
minimum digits needed to reflect the value.
4. You offset each option vertically using the methods you just added to the view.
5. When the user taps one of the options, you set the currentValue binding to the
value and then set showOptions to false to hide the options.
You now have an implementation you can use in your app. Open
BrewInfoView.swift, which contains the view showing the suggested amount of tea
for a given amount of water. Find the last Text element in the VStack and replace it
with the following:
HStack(alignment: .bottom) {
Text("\(teaToUse.formatted()) teaspoons")
189
SwiftUI Animations by Tutorials Chapter 7: Complex Custom Animations
.modifier(InformationText())
Spacer()
PopupSelectionButton(
currentValue: $waterTeaRatio,
values: [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0]
)
}
You added the new PopupSelectionButton view to let the user select the desired
ratio and provide several options between 1.0 and 5.0.
Now run the app. Select any tea, tap the button and change the ratio. Observe how
the suggested amount of tea changes to match the new ratio. Adjust the amount of
water and observe that the tea adjusts to fit.
While the buttons show, they appear suddenly. In the next section, you’ll animate
the appearance of the options.
190
SwiftUI Animations by Tutorials Chapter 7: Complex Custom Animations
You might consider triggering the animation based on the same state change, but
that can be complicated. Instead, you’ll introduce a new property to manage the
animation.
Back in PopupSelectionButton, add the following new property after the existing
ones:
You’ll use this property to manage the view appearance and removal independently
of each other. Now update your offset(x:y:), under comment four, to:
.offset(
x: animateOptions ? xOffset(for: index) : 0,
y: animateOptions ? yOffset(for: index) : 0
)
Now you only offset the views when animateOptions is true. Otherwise, they would
remain hidden under the main button since they appear earlier in the ZStack.
Changing animateOptions animates the buttons so they appear behind the main
button and move to their final positions.
Next, update the code inside the outer onTapGesture attached to the Group to:
// 1
withAnimation(.easeOut(duration: 0.25)) {
animateOptions = !showOptions
}
// 2
withAnimation { showOptions.toggle() }
You’ll run two animations separately based on the current value of showOptions.
191
SwiftUI Animations by Tutorials Chapter 7: Complex Custom Animations
Recall that you also hide the options when the user selects an option. You need to
update that code to match these changes. Replace the onTapGesture closure inside
the ForEach loop with:
.onTapGesture {
currentValue = values[index]
withAnimation(.easeOut(duration: 0.25)) {
animateOptions = false
}
withAnimation { showOptions = false }
}
You’re using the same code you did earlier, except you know you’re hiding the
options. As the last step, you’ll add a transition to the view.
Go to the Text view at the top of the ForEach loop. Add the following modifier after
the view and before all the other modifiers:
.transition(.scale.animation(.easeOut(duration: 0.25)))
By default, the scale animation scales from and to a vanishing point at the center of
the view. You apply an ease-out animation with a duration of 0.25 seconds to the
transition, which matches the animation used with the change of offset position.
Using the same animation keeps the two in sync, so they act as a single combined
animation instead of separate animations.
192
SwiftUI Animations by Tutorials Chapter 7: Complex Custom Animations
Now you’ll see the options slide out from under the original button.
193
SwiftUI Animations by Tutorials Chapter 7: Complex Custom Animations
The app shows you the past ratings of your brews. While the list shows the
information, it would be nice to provide a visualization to help clarify the
relationships between the different settings and the results. To do this, you’ll create
a radar chart: a visualization to compare the characteristics of multiple values by
plotting the data as a polygon, with each corner of the polygon representing one
value. A radar chart looks like this:
Run the app and select Green Tea or Oolong Tea, which already have ratings. At the
bottom of the view, you’ll see the ratings listed. Tap anywhere in that window, and
you’ll see a sheet showing the first rating. You can quickly swipe between the ratings.
You’ll now create a visualization that reflects the values in these ratings.
194
SwiftUI Animations by Tutorials Chapter 7: Complex Custom Animations
These are the five properties that your radar chart will show. For greater precision
during the animation, you use a Double type for each. Now update the preview to
provide the values. Change the body of the preview to:
AnimatedRadarChart(
time: Double(BrewResult.sampleResult.time),
temperature: Double(BrewResult.sampleResult.temperature),
amountWater: BrewResult.sampleResult.amountWater,
amountTea: BrewResult.sampleResult.amountTea,
rating: Double(BrewResult.sampleResult.rating)
)
This computed property takes the individual values, turns them into an array you
can loop over and handles the problem of scaling the chart by dividing each value by
the maximum expected value. This step turns each value into a fraction between
zero and one and ensures that charts from different measurements are comparable.
Now you’ll work on the chart to show these values. Replace the body of the view
with:
// 1
ZStack {
// 2
GeometryReader { proxy in
195
SwiftUI Animations by Tutorials Chapter 7: Complex Custom Animations
// 3
let graphSize = min(proxy.size.width, proxy.size.height) /
2.0
let xCenter = proxy.size.width / 2.0
let yCenter = proxy.size.height / 2.0
}
}
This code makes some calculations you need to match the size of the chart to the
size of the view. Here’s what it does:
1. You’ll add more to this chart later in this chapter, so you build the view within a
ZStack, which overlays child views.
3. You can calculate some values you’ll use later from the GeometryProxy passed to
the closure of the GeometryReader. You determine which is smaller: the view’s
vertical or horizontal size. Then you divide it by two to determine the number of
points to display when a value is at the maximum value. To help center the chart
within the view, you calculate the center points in each position by dividing the
width and height by two.
// 4
ForEach(0..<5 id: \.self) { index in
Path { path in
path.move(to: .zero)
path.addLine(to: .init(x: 0, y: -graphSize * values[index]))
}
// 5
.stroke(.black, lineWidth: 2)
// 6
.offset(x: xCenter, y: yCenter)
// 7
.rotationEffect(.degrees(72.0 * Double(index)))
}
196
SwiftUI Animations by Tutorials Chapter 7: Complex Custom Animations
4. You loop between 0 and 4, since values has 5 elements. Remember, values
contains a scaled value between zero and one for each item to show on the chart.
You then create a path that begins at the zero point and adds a line from that
point. In SwiftUI, a negative value indicates a position upward in the view. To
create a vertical upward line, you multiply the negative of the graphSize value
computed earlier by the fraction of the current point.
5. You draw the path on the view using stroke(_:lineWidth:), which draws a
black line of width two.
6. The origin of a drawing in a SwiftUI view is at the leading top corner by default.
To shift this to the center of the view, you apply offset(x:y:), passing the
center locations you calculated in step three.
7. You want to produce five equally spaced lines around the center point. You divide
the 360 degrees of a full circle by five to find that you should rotate each line 72
degrees from the previous one. Since an increased number rotates clockwise in
SwiftUI, succeeding lines will appear clockwise from the first.
With the basics in place, you’ll fill out the rest of the chart in the next section.
197
SwiftUI Animations by Tutorials Chapter 7: Complex Custom Animations
Path { path in
path.move(to: .zero)
path.addLine(to: .init(x: 0, y: -graphSize))
}
.stroke(.gray, lineWidth: 1)
.offset(x: xCenter, y: yCenter)
.rotationEffect(.degrees(72.0 * Double(index)))
This code is identical to the previous code, except it doesn’t scale the height of the
line so that it’s the entire length. You also stroke the line in gray and one point wide.
Since it occurs before plotting the value, the plotted value will overlay it.
Add the following code after the assignment of yCenter and before the current
ForEach:
// 1
let chartFraction = Array(stride(
from: 0.2,
to: 1.0,
by: 0.2
))
ForEach(chartFraction, id: \.self) { fraction in
// 2
Path { path in
path.addArc(
center: .zero,
radius: graphSize * fraction,
startAngle: .degrees(0),
endAngle: .degrees(360),
clockwise: true
)
}
// 3
.stroke(.gray, lineWidth: 1)
.offset(x: xCenter, y: yCenter)
}
This code produces grid lines for the chart that help the reader interpret the values.
198
SwiftUI Animations by Tutorials Chapter 7: Complex Custom Animations
1. You loop through a set of fractions that evenly divide the chart into five sections.
SwiftUI will pass the value to the closure as fraction.
2. For each value, you create a path and add an arc to the path. This arc will sweep
around the center of the view with a radius of graphSize multiplied by the
current fraction. You turn the arc into a circle by setting the start and end
angles to sweep the full 360 degrees. This loop will draw a series of larger arcs as
SwiftUI iterates over the values.
3. You stroke each path as a gray line with a width of one point. As before, you use
offset(x:y:) to set the center point to the center of the view.
Look at your chart in the preview. Adding the grid lines makes it easier to interpret
each value.
In the next section, you’ll add a bit of color to the graph and add it to the app.
199
SwiftUI Animations by Tutorials Chapter 7: Complex Custom Animations
This constant defines a set of colors in an array. If you take them in the same order
as the values in the chart, you’ll notice the colors relate to the measurements: black
for the time, red for temperature, blue for the amount of water, green for the amount
of tea and yellow for the rating.
With this array, you can add color to the chart’s values. Look for the line that
reads .stroke(.black, lineWidth: 2) under comment five and change it to:
.stroke(lineColors[index], lineWidth: 2)
Now you draw each line in a unique color. To finish the radar chart, you’ll draw the
polygon connecting the ends of each measurement line. Since this view is a bit more
complicated, add the following code to the end of the current file:
You created a new view that will encapsulate the polygon part of the view. Separating
this into a separate view will improve readability while reducing clutter and
problems with the SwiftUI compiler.
200
SwiftUI Animations by Tutorials Chapter 7: Complex Custom Animations
Now add the following new code to PolygonChartView after the properties:
You create an AngularGradient and pass your colorArray while appending the first
color to its end. You do this to match the start and end colors of the angular gradient.
Since the gradient starts toward the right, you set the angle property to -90 degrees
to rotate the gradient by a one-quarter revolution so it starts upward.
Now fill in the Path closure in the view’s body with the following code:
// 1
for index in values.indices {
let value = values[index]
// 2
let radians = Angle(degrees: 72.0 * Double(index)).radians
// 3
let x = sin(radians) * graphSize * value
let y = cos(radians) * -graphSize * value
// 4
if index == 0 {
path.move(to: .init(x: x, y: y))
} else {
path.addLine(to: .init(x: x, y: y))
}
}
// 5
path.closeSubpath()
1. Since you’re inside a Path closure, you use the standard Swift for in loop
instead of ForEach. You then get the value for the current iteration.
2. When plotting the values, you determine the angle of this measurement. You use
the same 72 degrees angle and convert it to radians as you did before, since both
sin and cos expect radian values.
201
SwiftUI Animations by Tutorials Chapter 7: Complex Custom Animations
3. Earlier, you let SwiftUI rotate the lines, but you need to do it yourself in this view.
To calculate the x and y values for a point of a specific length at a specified angle,
you multiply the sine of the angle by the distance to calculate x. You multiply the
cosine of the angle by the length to calculate y. You use a negative value for y
because trigonometric functions assume y increases upward and in a counter-
clockwise direction while in SwiftUI angles increase clockwise and y increases
going down the view.
4. The first time through the loop, you need to use move(to:) to start the path. For
the remainder of the shape, you call addLine(to:) to add the new point with a
line back to the previous point.
5. To finalize the path, you call closeSubpath() on the Path. This method draws a
line back to the start of the path to close the polygon.
Now you’ll let SwiftUI handle the offset and apply the gradient. Add the following
code as modifiers to the Path you just added:
The first modifier offsets the Path, so the center lies at the center of the view. You
then apply the gradient and reduce the opacity, so the gradient colors don’t
overwhelm the chart.
PolygonChartView(
values: values,
graphSize: graphSize,
colorArray: lineColors,
xCenter: xCenter,
yCenter: yCenter
)
202
SwiftUI Animations by Tutorials Chapter 7: Complex Custom Animations
Your preview will now show the final chart using the sample data.
Now it’s time to integrate the new chart into your app.
AnimatedRadarChart(
time: Double(ratings[selectedRating].time),
temperature: Double(ratings[selectedRating].temperature),
amountWater: ratings[selectedRating].amountWater,
amountTea: ratings[selectedRating].amountTea,
rating: Double(ratings[selectedRating].rating)
)
.aspectRatio(contentMode: .fit)
.animation(.linear, value: selectedRating)
.padding(20)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(Color("QuarterSpanishWhite"))
)
203
SwiftUI Animations by Tutorials Chapter 7: Complex Custom Animations
You add the radar chart below the swipeable area. It’ll display the chart for the
current rating through the selectedRating index.
Run the app and select either Green Tea or Oolong Tea. Tap the Ratings area, and
the app will show the radar chart. Change between them by swiping or tapping the
indicator squares, and you’ll see the chart changes to match.
You might’ve noticed that despite the implicit animation applied when
selectedRating changes, there’s no animation. In the next section, you’ll learn how
to animate a view with multiple properties using AnimatablePair.
204
SwiftUI Animations by Tutorials Chapter 7: Complex Custom Animations
SwiftUI provides AnimatablePair especially for these cases. As the name implies, it
supports two values instead of a single value. For example, the following code would
expect two animated values for a view:
AnimatablePair<Double, Double>
So how would you handle the five values needed in this view? By nesting
AnimatablePairs. Update the definition of AnimatedRadarChart to:
Now add the following code after the properties for the view:
// 1
var animatableData: AnimatablePair<
// 2
AnimatablePair<Double, Double>,
// 3
AnimatablePair<
// 4
AnimatablePair<Double, Double>,
// 5
Double
>
>
This code looks more complicated than it actually is. Here’s how this block of code
lets SwiftUI pass in animated values:
2. For the first element of the pair, you define another AnimatablePair, which
takes two Double values. Each AnimatablePair has two properties named first
and second used to access their elements. This means that
animatableData.first now consists of an AnimatablePair with elements you
can access by ``animatableData.first.firstandanimatableData.first.second`.
205
SwiftUI Animations by Tutorials Chapter 7: Complex Custom Animations
3. For the second element of the top level AnimatablePair, which you access
through animatableData.second, you define another AnimatablePair.
5. The second element of that last AnimatablePair is a Double. As you can see, this
gives you a total of the five Double values that you need.
This diagram shows how the elements flow from each other and how to access each
element. You can continue this pattern if needed, but as you can see, it’s pretty
complicated at five values.
Next you’ll need to assign each of the values in the nested AnimatablePairs to a
property in the view. You want the first property in the view to match the first value
in animatableData.
206
SwiftUI Animations by Tutorials Chapter 7: Complex Custom Animations
The getter and setter for the property then need to translate between these elements
of the nested structures and the properties on the view. Add the following code
directly after the declaration of animatableData. The first line replaces the lone
closing > symbol in the last code block:
> {
get {
// 1
AnimatablePair(
AnimatablePair(time, temperature),
AnimatablePair(
AnimatablePair(amountWater, amountTea),
rating
)
)
}
set {
// 2
time = newValue.first.first
temperature = newValue.first.second
amountWater = newValue.second.first.first
amountTea = newValue.second.first.second
rating = newValue.second.second
}
}
207
SwiftUI Animations by Tutorials Chapter 7: Complex Custom Animations
Follow the diagram, and you’ll see how the data structure for the AnimatablePair
maps to the properties and the structure’s properties. Here are the specifics for the
two methods:
1. The getter for the property needs to return a value matching the complicated
structure you defined earlier. You create a series of nested AnimatablePair types
with the values set as shown in the diagram.
2. Setting the view’s properties from the AnimatablePair requires you to navigate
the first and second properties.
While complicated, this code wraps up everything needed to animate the view. Run
the app and select either Green Tea or Oolong Tea. Tap the Ratings area, and the
radar chart shows each rating when selected. As you change between the ratings,
you’ll see the view now animates between the charts.
208
SwiftUI Animations by Tutorials Chapter 7: Complex Custom Animations
Key Points
• Transitions are a type of animation. When combining transitions, you’ll find it
easier to use different state changes to control each individually.
• You can apply an animation to a transition that will set the transition’s animation
curve and duration.
• A radar chart provides a way to visualize the relationship between multiple related
values.
• You can use the AnimatablePair type when you need to animate multiple values
in a view that conforms to the Animatable protocol.
• If you need to animate more than two values, you can nest multiple
AnimatablePair structures within each other. While this can quickly become
complicated, it’ll let you support many values.
• For more examples of creating charts in SwiftUI without the Swift Charts API, see
SwiftUI Tutorial for iOS: Creating Charts (https://www.kodeco.com/6398124-
swiftui-tutorial-for-ios-creating-charts).
209
8 Chapter 8: Time-Based
Animations
By Bill Morefield
Using SwiftUI effectively requires adapting an app’s UI based on its state. The
animations you’ve used in the app for the last few chapters all animate based on
state changes. The timer view you created in Chapter 6: Intro to Custom
Animations used Combine to create a timer and publish events your view used to
trigger changes to the timer. In earlier versions of SwiftUI, if you wanted to update a
view at regular intervals, like a timer, then you had to use this method.
210
SwiftUI Animations by Tutorials Chapter 8: Time-Based Animations
TimelineView(.periodic(
from: .now,
by: 1.0
)) { context in
Text(context.date.formatted(
date: .omitted,
time: .complete
))
}
The argument to the Timeline view provides a schedule that SwiftUI uses to update
its content. Here you use the periodic(from:by:) schedule to specify the view
should start updating immediately and refresh once per second.
The context provided to the closure provides the date that triggered in its date
property. You use formatted(date:time:) to show only the time component of the
property. In the preview, you’ll see that you built a functional clock in just three lines
of code.
211
SwiftUI Animations by Tutorials Chapter 8: Time-Based Animations
Note that the view contains no state information and updates without any state to
change. That’s the power of the TimelineView. It lets you create a view that updates
based on time, not state.
You will later set the initial value of timerLength to the value of the passed in timer
and add a control letting the user adjust it. The other three properties will track the
status and remaining time for the timer when active.
Next, uncomment the three methods in the view by selecting them and selecting
Editor ▸ Structure ▸ Comment Selection, or pressing Command-/. These methods
calculate the amount of time remaining on the timer based on the status and
timerEndTime of the timer. Go to AnalogTimerView.swift and replace its body:
VStack {
// 1
Slider(value: $timerLength, in: 0...600, step: 15)
// 2
TimerControlView(
timerLength: timerLength,
timeLeft: $timeLeft,
status: $status,
timerEndTime: $timerEndTime,
timerFinished: $timerFinished
)
.font(.title)
// Place timeline here
}
.onAppear {
// 3
timerLength = Double(timer.timerLength)
}
1. You provide a Slider so the user can adjust the default timer length. Note that
the value bound to the slider must be a Double though you’ll convert it to an Int
when used.
2. You use the already provided TimerControlView, which manages the state of the
timer and provides buttons to control it.
212
SwiftUI Animations by Tutorials Chapter 8: Time-Based Animations
3. When the VStack first appears, you set the timerLength. Notice the need to
convert to a Double as mentioned in step one.
Next, add the new timer view in place of // Place timeline here:
TimelineView(.periodic(
from: .now,
by: 1
)) { context in
let timeString = timeLeftString(timeLeftAt(context.date))
Text(timeString)
.font(.title)
}
This TimelineView updates once per second. Within the view, you use the
timeLeftAt(_:) uncommented earlier to get the number of seconds remaining on
the timer and then convert that to a formatted string using timeLeftString(_:). It
then displays the string on the view.
Notice that you include only the part of the view affected by time change inside the
closure. Since SwiftUI updates all views contained inside the closure of a
TimelineView, including views that don’t change decreases performance without
adding any benefit.
Run the app, and you’ll see it already uses the new AnalogTimerView. Start the
timer. It works much like before and displays the remaining time as text.
213
SwiftUI Animations by Tutorials Chapter 8: Time-Based Animations
Since a TimelineView doesn’t include a state change, you’re responsible for creating
the changing views yourself. In this section, you’ll start developing an analog timer
by combining the TimelineView with another SwiftUI view — Canvas.
Separating the code keeps it manageable and makes your final view neater. Here’s
what this method does:
1. The method’s size parameter provides the desired size for the timer. You create
a CGSize with this value as the width and height.
2. You’ll often use SwiftUI Paths when working with the canvas. In this case, you
use the Path initializer that creates an ellipse using the timerSize from step
one. Since the width and height are equal, the path defines a circle.
3. You have a path and now want to stroke the path onto the canvas. So, you call
stroke(_:with:lineWidth:) on the canvas. It strokes the ellipse in black as a
line three points wide.
214
SwiftUI Animations by Tutorials Chapter 8: Time-Based Animations
// 1
ZStack {
// 2
Canvas { gContext, size in
// 3
let timerSize = Int(min(size.width, size.height))
drawBorder(context: gContext, size: timerSize)
}
}
.padding()
1. A ZStack lets you stack several views that SwiftUI aligns for you. Using the
ZStack lets you separate the animated and non-animated portions of the view, so
the result aligns as though created in a single canvas.
2. Then you declare a Canvas. SwiftUI passes two arguments into the closure. The
first argument is a GraphicsContext you use for drawing. The second contains
information about the view size you use to scale the view to match the container.
3. You determine which dimension is smaller, the width or height of the Canvas,
and convert it to an integer. You pass this value to the
drawBorder(context:size:) you created along with the graphics context.
Build and run the app. Tap any tea type, and you’ll see the border for the new timer.
215
SwiftUI Animations by Tutorials Chapter 8: Time-Based Animations
You’ll also see a problem with the border: the circle’s edges are clipped on three
sides, creating flat spots. The view shows there because you set the edge of the
CGRect at the origin. Some of the extra thickness of the line gets lost outside the
view. The circle also lies along the left side of the view and would look nicer
centered.
First, you’ll add a small border around the timer to fix these two issues. Change the
definition of timerSize to:
You reduce the size of the timer to 0.95 of the canvas’s smallest dimension. This
reduced space allows the timer’s thicker border to show within the view.
Now you’ll calculate an offset to center the timer. Add the following code between
defining timerSize and calling drawBorder(context:size:).
// 4
let xOffset = (size.width - Double(timerSize)) / 2.0
// 5
let yOffset = (size.height - Double(timerSize)) / 2.0
// 6
gContext.translateBy(x: xOffset, y: yOffset)
1. You calculate the offset needed to center the view by subtracting the width of the
timer in timerSize from the total width of the canvas after converting the latter
to a Double. You divide this value by two to evenly split the space on both sides
of the timer.
2. Then, you perform the same calculation in the vertical axis with the canvas
height and the size of the timer. Again, you divide this by two to evenly split the
space on both sides of the timer.
216
SwiftUI Animations by Tutorials Chapter 8: Time-Based Animations
Run the app and tap any tea. The timer now centers on the view and no longer clips
the circle.
// 2
for minute in 0..<10 {
// 3
let minuteAngle = Double(minute) / 10 * 360.0
// 4
let minuteTickPath = Path { path in
path.move(to: .init(x: center, y: 0))
path.addLine(to: .init(x: center * 0.9, y: 0))
}
}
}
217
SwiftUI Animations by Tutorials Chapter 8: Time-Based Animations
Like before, drawing code tends to be long. So this code is split into two parts. Here’s
how it works step-by-step:
1. The size parameter provides the size of the full timer. You divide it by two to get
the number of points from the center of the timer to the edge. You’ll use this to
position the different parts of the timer.
2. This loop counts through all integers between zero and nine. Since the maximum
timer length is ten minutes, this provides minute marks and indicators for all
possible numbers.
3. Then, you calculate the ratio of the current minute value to the total number of
minutes. You then multiply that ratio by the 360 degrees that make a full rotation
to get the fraction of a full rotation for the current position.
4. You create a Path and use move(to:) to move the current position to the edge of
the timer at the right. You then add a line to the point one-tenth of the way back
toward the center. Using a ratio instead of hard-coded points allows the timer to
scale to different sized views.
Now add the following code to the end of the for loop:
// 4
var tickContext = context
// 6
tickContext.rotate(by: .degrees(-minuteAngle))
// 7
tickContext.stroke(
minuteTickPath,
with: .color(.black)
)
5. The GraphicsContext passed into the method is immutable, so you can’t use
methods that modify its state, like a rotation or translation. You create a mutable
copy and change it instead. This step works because the GraphicsContext is a
value type. With a value type, changes to the copy don’t affect the original
context.
6. You rotate the context by the negative number of degrees calculated in step
three. Using a negative degree produces a counter-clockwise rotation.
218
SwiftUI Animations by Tutorials Chapter 8: Time-Based Animations
Go back to the view body and add the following code to the body after the call to
drawBorder(context:size:):
gContext.translateBy(
x: Double(timerSize / 2),
y: Double(timerSize / 2)
)
gContext.rotate(by: .degrees(-90))
drawMinutes(context: gContext, size: timerSize)
You then rotate the canvas by -90 degrees. By default, a zero-degree rotation lies to
the right of the origin. For this view, you want it to be above the origin, and this
rotation accomplishes that. Now no rotation will appear above the center of the
timer. You then call the new method to draw the tick marks.
Now run the app and tap any tea. You’ll see your new tick marks added to the timer.
219
SwiftUI Animations by Tutorials Chapter 8: Time-Based Animations
// 1
let minuteString = "\(minute)"
let textSize = minuteString.calculateTextSizeFor(
font: UIFont.preferredFont(forTextStyle: .title2)
)
// 2
let textRect = CGRect(
origin: .init(
x: -textSize.width / 2.0,
y: -textSize.height / 2.0
),
size: .zero
)
// 3
let minuteAngleRadians = Angle(degrees: minuteAngle -
90).radians
// 4
let xShift = sin(-minuteAngleRadians) * center * 0.8
let yShift = cos(-minuteAngleRadians) * center * 0.8
Drawing text requires some additional setup. Here’s how you prepare for it:
2. You create a CGRect that centers an object with the size you calculated in step
one. You’ll use this later when drawing the text onto the canvas.
3. Up to this point, you used rotate(by:) to rotate objects to the proper location.
That won’t work for this case because it also rotates the text. However, you can
use trigonometric functions to calculate the location of a desired angle and
distance. Since you’re calculating the location, the rotation you applied to the
entire canvas no longer applies. You must subtract 90 degrees from the angle to
set the zero angle vertically above the center instead of to the right. Finally, you
convert the angle from degrees to the radians unit type expected by Swift
trigonometric functions.
220
SwiftUI Animations by Tutorials Chapter 8: Time-Based Animations
4. To calculate the position of a point along an angle, you use the trigonometric
sine function to get the horizontal position and the cosine function to get the
vertical position. Passing the negative of the angle to these functions causes the
numbers to increase clockwise instead of in the default counter-clockwise
direction. You multiply the distance to the edge of the timer by 0.8 to position
the text inside the tick marks drawn in the previous section.
// 5
var stringContext = context
stringContext.translateBy(x: xShift, y: yShift)
stringContext.rotate(by: .degrees(90))
// 6
let resolvedText = stringContext.resolve(
Text(minuteString).font(.title2)
)
// 7
stringContext.draw(resolvedText, in: textRect)
5. You create a second copy of the original graphics context. This copy won’t
contain the changes you made to tickContext. Then you translate the origin by
the amount calculated in step four. While the initial -90 degree rotation doesn’t
apply to your calculation in step four, it will apply to drawing the text. You use
the opposite rotation to undo it. Otherwise, the text would be rotated a quarter
turn counter-clockwise.
6. You can draw a string, but using resolve(_:) on the GraphicsContext provides
more flexibility. Here you use the method to apply font(_:) to format the text.
Note the specified font matches the UIFont you used in step one.
7. The draw(_:in:) on the GraphicsContext draws the text onto the canvas.
Using ResolvedText from step six produces formatted text matching the SwiftUI
view. You use the CGRect calculated in step two to center the text around the
current origin point you set in step five.
221
SwiftUI Animations by Tutorials Chapter 8: Time-Based Animations
Run the app, select any tea and you’ll see the new numbers on the timer.
In AnalogTimerView’s boy, add the following code after the current Canvas and
inside the ZStack:
// 1
TimelineView(.animation) { timeContext in
// 2
Canvas { gContext, size in
// 3
let timerSize = Int(min(size.width, size.height))
gContext.translateBy(
x: size.width / 2,
y: size.height / 2
)
gContext.rotate(by: .degrees(-90))
}
}
222
SwiftUI Animations by Tutorials Chapter 8: Time-Based Animations
1. You create a TimelineView, but pass in a schedule of animation. This value asks
SwiftUI to reevaluate the view as often as possible. Doing so produces the
smoothest animation at the cost of higher resource usage due to the frequency of
redrawn views. Moving the parts of the view not changing outside the
TimelineView reduces this performance cost.
2. You create a new Canvas. Since the ZStack contains both views, it aligns them,
letting you draw on them as though they were a single Canvas.
3. While changes within a Canvas persist, a new Canvas doesn’t inherit any settings
of the other Canvas. Here you apply the centering and rotation you did to the
first Canvas. You only draw relative to the center of the timer, so you can simplify
the calculation and divide the width and height of the Canvas by two.
Now add the code for the following new method just before the body of the view:
func createHandPath(
length: Double,
crossDistance: Double,
middleDistance: Double,
endDistance: Double,
width: Double
) -> Path {
// 1
Path {
path.move(to: .zero)
// 2
let halfWidth = width / 2.0
let crossLength = length * crossDistance
let middleLength = length * middleDistance
let halfWidthLength = length * halfWidth
// 3
path.addCurve(
to: .init(x: crossLength, y: 0),
control1: .init(x: crossLength, y: -halfWidthLength),
control2: .init(x: crossLength, y: -halfWidthLength)
)
path.addCurve(
to: .init(x: length * endDistance, y: 0),
control1: .init(x: middleDistance, y: halfWidthLength),
control2: .init(x: middleDistance, y: halfWidthLength)
)
path.addCurve(
to: .init(x: crossLength, y: 0),
control1: .init(x: middleDistance, y: -halfWidthLength),
control2: .init(x: middleDistance, y: -halfWidthLength)
223
SwiftUI Animations by Tutorials Chapter 8: Time-Based Animations
)
path.addCurve(
to: .zero,
control1: .init(x: crossLength, y: halfWidthLength),
control2: .init(x: crossLength, y: halfWidthLength)
)
}
}
Both of the timer’s hands have a similar design that only varies in width and length.
This method creates the path based on the values you pass to it. Here’s how it builds
the path:
1. You create an empty path and move the current position to the origin. Recall that
you already shifted the origin to the center of the Canvas in the view.
2. You take the desired width and divide it by two to get a half width that you’ll use
to mirror the shape, and also calculate a few values you’ll need for the control
points in the next step.
3. You then add four cubic Bézier curves to the path. A cubic Bézier curve uses two
control points to define the shape of the curve. The combined curves trace out
the hands as two parts, the first a more rounded curve with a longer, smoother
curve at the end. You define the shape and width of the curves with the
parameters passed to the method.
Now, add a method to draw the timer’s hands. Add the following new method after
createHandPath(...):
func drawHands(
context: GraphicsContext,
size: Int,
remainingTime: Double
) {
// 1
let length = Double(size / 2)
// 2
let secondsLeft =
remainingTime.truncatingRemainder(dividingBy: 60)
// 3
let secondAngle = secondsLeft / 60 * 360
// 4
let minuteColor = Color("DarkOliveGreen")
let secondColor = Color("BlackRussian")
224
SwiftUI Animations by Tutorials Chapter 8: Time-Based Animations
crossDistance: 0.4,
middleDistance: 0.6,
endDistance: 0.7,
width: 0.07
)
}
1. You calculate the maximum length of the hands by dividing the size of the timer
by two, as you’ve done before.
3. You determine the ratio of the current number of seconds to the 60 seconds of a
full rotation. You multiply this amount by 360 to convert this ratio to degrees of a
full circle. Note that the remainingTime passed to this method includes
fractional seconds, which allows you to calculate a more granular position and
produce a smoother animation.
4. Then, you define constants for the colors you’ll use for the hands and call
createHandPath(...) to produce a path for the second hand.
With a path for the second hand, you can now draw it. Add the following code to the
end of drawHands(context:size:remainingTime:):
First, you create a copy of the graphics context and rotate it by the angle calculated
in step three above. You then fill and stroke the path in the color you defined in step
four. Doing both operations on the path produces a shape with more weight.
225
SwiftUI Animations by Tutorials Chapter 8: Time-Based Animations
Now you’ll add the calls to draw your clock’s hand. Find the Canvas inside the
TimelineView in your view. Add the following code to the end of the Canvas:
First, you get the number of remaining seconds and store it in remainingSeconds.
You then call drawHands(context:size:remainingTime:) passing the remaining
seconds.
Run your app and start the timer. Watch it smoothly sweep through the seconds as
the timer runs.
// 1
let minutesLeft = remainingTime / 60
// 2
let minuteAngle = minutesLeft / 10 * 360
// 3
226
SwiftUI Animations by Tutorials Chapter 8: Time-Based Animations
This code matches the one you used to create the second hand with a few changes:
1. You divide the remaining time by 60 to get the number of minutes remaining.
Note that the value includes the fraction of a minute. Dividing this value by the
maximum timer length of ten minutes gives the ratio of the maximum timer.
2. You multiply this ratio by 360 to convert the minutes to a rotation in degrees.
3. Then, you create a path with different parameters, resulting in a broader and
shorter hand than you used for the second hand.
4. Again, you create a copy of the context and rotate it by the value calculated in
step one. You then fill and stroke the path as you did with the second hand, but
use a wider width when stroking the path to add more weight to the broader
minute hand.
227
SwiftUI Animations by Tutorials Chapter 8: Time-Based Animations
Rerun your app, select any tea and watch the timer hands move as it counts down.
The minute hand will move very slowly, and you may need to wait several seconds
for it to move enough to notice.
TimelineView(.periodic(
from: .now,
by: 1)
) { timeContext in
228
SwiftUI Animations by Tutorials Chapter 8: Time-Based Animations
This change tells SwiftUI to update the view once per second, beginning
immediately. Run the app now and start it for any tea. You’ll see the second hand
now “ticks”. Instead of the previous smooth motion, it jumps to the next position
every second.
To keep the smooth motion while improving performance, you must find a balance
where the second hand has smooth movement while updating as seldom as possible.
You could do some complex math to calculate the minimum interval based on the
view size, but it’s just as effective to find a value that works for your app by trial and
error. Change the TimelineView to:
TimelineView(
.animation(minimumInterval: 0.1)
) { timeContext in
Run the app, and you’ll see that the second hand appears to move smoothly, but
updates will never occur more than ten times per second.
That solves the primary performance issue, but you must address one more point.
Right now, the views inside the TimelineView update whenever the view is
displayed. When the timer stops or pauses, the views update despite having no
changes. SwiftUI provides a way to let it know when a TimelineView doesn’t need
updating.
229
SwiftUI Animations by Tutorials Chapter 8: Time-Based Animations
TimelineView(
.animation(
minimumInterval: 0.1,
paused: status != .running
)
) { timeContext in
You set the new paused parameter to true to let SwiftUI know there’s no need to
update the views in the closure. This app only needs to update the hands when the
timer is running. You pause updates when the app isn’t in the .running state.
Run the app and start a timer for any tea again. You’ll notice no change when
running since the timer no longer updates when not running.
Challenge
Using what you learned in this chapter, add tick marks and numbers for the second
hand to the timer. See one solution in the challenge project for this chapter.
230
SwiftUI Animations by Tutorials Chapter 8: Time-Based Animations
Key Points
• A TimelineView redraws its content at scheduled points in time. You can specify
this schedule in several ways or create a custom implementation for complex
scenarios.
• Canvas lets you produce two-dimensional graphics inside a view. It resembles the
pre-SwiftUI Core Graphics framework, though it still works with SwiftUI elements.
You can call Core Graphics for complex methods or legacy code if needed.
• You can create a mutable copy of a GraphicsContext. Since it’s a value type, any
changes you make to the copy won’t affect the original GraphicsContext. You can
use this to change a GraphicsContext without affecting its initial state.
• You can find another example using the Canvas and TimelineView in our Using
TimelineView and Canvas in SwiftUI (https://www.kodeco.com/27594491-using-
timelineview-and-canvas-in-swiftui) tutorial. This tutorial also shows how you
can use Core Graphics and SwiftUI views with a Canvas.
231
9 Chapter 9: Combining
Animations
By Bill Morefield
Most of this book’s animations deal with user interaction. In earlier chapters, you
used animation to draw the user’s attention to the desired area in your app. These
animations help guide the user while at the same time adding polish and improving
the app’s visual appearance.
In this chapter, you’ll build an animation to act as a reward for the user when the
steeping timer ends. This animation will show liquid pouring into the view’s
background and filling it up.
Since this is a more complex animation, you’ll build it in two parts. First, you’ll add
the animation that resembles a rising liquid within a container. You’ll then use
SpriteKit’s particle system to add the pouring liquid that appears to fill the
container.
232
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
The start project contains a new group called PourAnimation, which includes the
TimerComplete view shown when a steeping timer finishes. To start the new
animation, create a SwiftUI view file named PourAnimationView.swift in the
PourAnimation folder`.
You’ll use this view to contain the new animation’s views. As with other animations,
starting with a simple version and then expanding upon it to create the final
animation is the easiest. At the top of the generated struct, add the following new
properties:
This code adds a state property you’ll use to control the animation. You also define a
blue color you’ll use as the liquid’s color. Update the body of the view to:
// 1
Rectangle()
// 2
.fill(fillColor)
// 3
.offset(y: shapeTop)
// 4
.onAppear {
withAnimation(.linear(duration: 6.0)) {
shapeTop = 0.0
}
}
1. You define a Rectangle shape that you’ll replace with a more complex Shape
later.
2. You fill the Rectangle with the blue color you defined earlier.
233
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
4. When the view appears, you use an explicit linear animation that takes six
seconds to complete. SwiftUI will apply the animation when you change
shapeTop to zero. The animation will then animate the movement of the
Rectangle from the initial position to the top of the view.
You need to add this new view to the view that shows when the timer finishes. Open
TimerComplete.swift. This view consists of a ZStack, which starts with a
backgroundGradient. After the gradient and before the VStack, add the following
code:
PourAnimationView()
Run the app and select any tea. Start the timer and wait for it to complete. Once the
timer finishes, you’ll see the animation as the blue rectangle fills the view over six
seconds, like a cup filling with liquid. Remember, you can adjust the timer length.
234
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
The clipped area at the bottom seems out of place. By default, SwiftUI keeps a view
from entering the device’s safe area. To eliminate the bar at the bottom, you need to
tell SwiftUI to allow the view to extend into that area.
PourAnimationView()
.ignoresSafeArea(edges: [.bottom])
Run the app, start a timer and let it complete. The Rectangle’s fill color now extends
to the bottom of the screen.
235
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
While implementing actual fluid dynamics would be overkill, you can simulate a
more complex shape to the pour using a sine wave. In this section, you’ll implement
a custom Shape and change the top of the animation to a sine wave.
A Shape returns a Path that defines the shape instead of a View. SwiftUI passes a
CGRect struct as a parameter to the method. It contains the size of the container for
the shape. This initial implementation only returns an empty path, but not for long.
To see your shape in the preview as you develop it, change the preview to:
WaveShape()
.stroke(.black)
.offset(y: 200)
This change strokes the path in black in the preview. It also uses a vertical offset, so
the full path shows on the preview. Otherwise, you’d cut off the top portion when
SwiftUI draws it on the view.
In previous chapters, you used sine and other trigonometric functions in animations
when drawing lines at an angle. Here, you’ll use it since the top of your shape will be
a sine wave.
236
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
The plot of the sine function from zero through 360 degrees looks like this:
A sine graph
It produces a perfect wave shape with the vertical axis ranging between negative one
and one over the distance. Due to the definition of a sine function, the wave varies
regularly over the 360 degrees that make up a single revolution of a circle. After 360
degrees, the values repeat with y taking on the same value it did at the same angle
minus 360 degrees.
// 1
Path { path in
// 2
for x in 0 ..< Int(rect.width) {
// 3
let angle = Double(x) / rect.width * 360
// 4
let y = sin(Angle(degrees: angle).radians) * 100
// 5
if x == 0 {
path.move(to: .init(x: Double(x), y: -y))
} else {
path.addLine(to: .init(x: Double(x), y: -y))
}
}
}
1. You create an an empty Path and accept a path to manipulate in the trailing
closure’s body.
2. You iterate all x positions in the rectangle using a for-in loop. This loop ensures
you perform only the necessary calculations for the shape’s size.
237
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
3. For each x position, you calculate the angle it should reflect by dividing it by the
total width of the rectangle. This result gives you the position as a fraction of the
full width. You then multiply this fraction by 360, giving you the position as a
degree of a full 360-degree circle.
4. You get the sine of the angle from step three using the sin method. You convert
from degrees to radians inside the function, as with other Swift trigonometric
functions. Since this will provide a value between negative one and one, you
multiply it by 100, increasing the wave’s size.
5. The first time through the loop, you move the path location to the current
horizontal position and the vertical position calculated in the last step. After
that, you draw a line from the current position to the following path position.
Since increasing values of y on a Path are downward on the view, you take the
negative of y to flip positive values upward.
The preview for the shape shows you a simple sine wave:
238
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
This property lets the calling view control the location of the sine wave. Update the
code under comment five to:
// 5
if x == 0 {
path.move(to: .init(
x: Double(x),
y: waveTop - y
))
} else {
path.addLine(to: .init(
x: Double(x),
y: waveTop - y
))
}
This change adds the value of waveTop to the vertical position of the view. A positive
value shifts the wave’s position down the shape.
To close the shape, add the following code after the for-in loop:
for-in ends with the position on the right edge of the view. So you add a line to the
bottom-right of the view before adding a line to the left-bottom side of the view. You
then call closeSubpath() on the path to ensure it forms a closed shape.
WaveShape(waveTop: 200.0)
.fill(.black)
The shape fills in from the view’s bottom up to a point specified by waveTop. You no
longer need offset(x:y:) on the shape because you can control the location with
waveTop.
Go to PourAnimationView.swift and change the body to use the new shape you
just implemented:
WaveShape(waveTop: shapeTop)
.fill(fillColor)
.onAppear {
withAnimation(.linear(duration: 6.0)) {
shapeTop = 0.0
}
}
239
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
You’ll see your new shape in the preview, but it immediately jumps to the new
position without the animation. To confirm this, run the app and let a timer finish.
240
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
Run the app and let a timer complete to see that the wave moves smoothly. See
Chapter 6: Introduction to Custom Animations for more about the protocol.
241
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
Elements of a wave.
Add the following new properties to WaveShape after waveTop:
The amplitude determines the height of the wave. By default, the sine function’s
values vary between negative one and one. You can multiply that value by another
number to change the shape’s height. You already modified this in the initial shape
using a fixed value of 100.00.
To implement the amplitude, change the code under comment four to:
// 4
let y = sin(Angle(degrees: angle).radians) * amplitude
This change replaces the constant 100.0 value with the new property allowing any
height wave.
242
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
Right now, the shape creates a single wave filling the entire space. The wavelength
property lets you compress or stretch the wave.
To implement the wavelength, change the code under comment three to:
// 3
let angle = Double(x) / rect.width * wavelength * 360.0
To shift the wave horizontally, change the starting degree. Right now, you begin the
wave at zero degrees, which produces a y of zero. The phase parameter lets you shift
this beginning point so the wave can start at an arbitrary point.
You must adjust the angle calculated in step three to implement the phase
parameter. Change the code to:
// 3
let angle = Double(x) / rect.width * wavelength * 360.0 + phase
You calculate an angle in step three and can change this angle by adding the desired
change in degrees. The phase property provides the angle where the drawn wave
should begin.
These new properties help you control the parameters of the wave. Open
PourAnimationView.swift and change the call to WaveShape() to:
WaveShape(
waveTop: shapeTop,
amplitude: 15,
wavelength: 4,
phase: 90
)
243
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
Run the app and let a tea timer complete. You’ll see your new animation. The wave
shows more peaks and troughs with a smaller height and shifted to the right
compared to before.
244
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
However, you have four properties to animate and only one property in the
AnimatableData protocol. To handle these situations, SwiftUI provides the
AnimatablePair struct. It lets you specify a pair of values for the animatableData
property. In addition, each of the two values in the struct can be animatable,
meaning you can nest values to support the number of properties you need.
// 1
var animatableData: AnimatablePair<
AnimatablePair<Double, Double>,
AnimatablePair<Double, Double>
> {
get {
// 2
AnimatablePair(
AnimatablePair(waveTop, amplitude),
AnimatablePair(wavelength, phase)
)
}
set {
// 3
waveTop = newValue.first.first
amplitude = newValue.first.second
wavelength = newValue.second.first
phase = newValue.second.second
}
}
2. When SwiftUI requests the value for the property, you build an AnimatablePair
struct. The first value of the struct is an AnimatablePair containing the
waveLength and amplitude properties in the Shape. The second
AnimatablePair struct consists of the wavelength and phase properties from
the Shape.
245
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
3. When SwiftUI provides new values, you set the properties in the same order as
you send them in step two. Notice the use of newValue.first to access the
elements wrapped in the first AnimatablePair and newValue.second to access
the second pair.
This diagram shows how the properties map through the AnimatablePair type of
animatableData.
With this change, you can animate all properties of the WaveShape. To put this to
use, open PourAnimationView.swift and add a new computed property to the top
of the view:
This property calculates a wave height equal to the top of the shape divided by ten.
The value of waveHeight starts at 20 and decreases as shapeTop decreases. min caps
the value at 20, so the height isn’t too large at the beginning of the animation.
amplitude: waveHeight,
246
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
Using the new computed property for the shape’s amplitude produces a larger wave
that decreases as the animation nears the end. Run the app and let a timer complete
to see the wave’s height decrease.
phase: wavePhase
247
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
This parameter has the shape use the new state property. Add the following code at
the start of onAppear(perform:):
withAnimation(
.easeInOut(duration: 0.5)
.repeatForever()
) {
wavePhase = -90.0
}
You do the same for the phase as you did when you changed shapeTop to animate a
rising shape. Changing the phase adds a back-and-forth movement to the water in
the view as it rises. You create an ease-in-out animation lasting one-half second.
repeatForever(autoreverses:) tells SwiftUI to repeat the animation forever.
Since autoreverses defaults to true, the animation will reverse before repeating.
Run the app and let a tea timer complete. You’ll see the new motion in the
animation.
248
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
WaveShape(
waveTop: shapeTop,
amplitude: waveHeight * 1.2,
wavelength: 5,
phase: wavePhase2
)
.fill(waveColor2)
This code produces a wave shape based on the existing one. It’s 1.2 times higher and
shows five complete waves across the view. You also use the newly added
wavePhase2 as the phase.
To animate this property of the new shape, add the following code to the
onAppear(perform:) after the withAnimation(_:_:) that changes wavePhase:
withAnimation(
.easeInOut(duration: 0.3)
.repeatForever()
) {
wavePhase2 = 270.0
}
249
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
Run the app, and you’ll see a second, darker blue wave behind the existing one. It
appears behind the first since you placed it first in the ZStack.
It’s possible to write one natively in SwiftUI, but there’s no need in this case since
Apple provides particle systems in several libraries. In this section, you’ll begin
implementing a particle system in SceneKit and SpriteKit to add to your animation.
SwiftUI supports SceneKit through the SceneView view, displaying SceneKit content.
To create the pour animation, you must build up several elements and combine them
into a SceneKit scene. You’ll start with the particle emitter.
250
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
• Change Texture to dropshape to select a drop shaped image for the particle.
• Change Position Range ▸ X to 55 as a lower number reduces the size of the space
where the emitter creates particles.
251
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
The particles take on a blue color that may be hard to see on the default black
background. You can change the Custom color to white to help them stand out.
252
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
import SpriteKit
You import SpriteKit because it includes both SpriteKit and SceneKit, which you’ll
use in this view.
First, you create a SKScene that defines the scene. At the top of the file before
PourSceneView, add:
This bare-bones implementation contains only a single static property that creates
an instance of itself. You’ll use this class to define the view-independent properties
for the scene. Add the following property to the class after the static property:
SKEmitterNode loads the particle emitter you created in the last section. Notice you
don’t need to specify the file’s extension,
You set up a SKScene inside didMove(to:). The framework calls the method when
the scene is presented to the view. Add the following code to your class:
// 3
dropEmitter?.position.x = 100
dropEmitter?.position.y = self.frame.maxY
}
253
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
1. You set the scene’s background color to clear. This change lets anything behind
the scene, like other views, show through.
2. You attempt to unwrap dropEmitter. If successful, you then ensure the emitter
isn’t already present in the scene before adding it as a child of the current scene.
Unwrapping dropEmitter can only fail if PourParticle.sks (the particle file you
created) is missing or corrupt.
With that class in place, you can now use it in your SwiftUI view. Add the following
computed property to PourSceneView:
This property produces the SKScene you’ll use inside your SwiftUI view by:
1. This gets the shared instance of the class through the shared property.
2. You set the size to match the size of the main screen, so the SKScene takes up the
full view. You also set the scale mode to .fill to fill the entire view.
SpriteView(
scene: pouringScene,
options: [.allowsTransparency]
)
254
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
You call SpriteView, passing in the scene’s name from your pouringScene
computed property. You pass .allowsTransparency to the options argument.
Otherwise, the views below this in the stack wouldn’t show through SpriteView and
your self.backgroundColor = .clear setting in didMove(to:) would be ignored.
Check out the preview, where you can see the pouring liquid you created:
255
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
This property controls showing the pouring animation. Add the following code
before the first WaveShape() inside the ZStack:
if showPour {
PourSceneView()
}
Run the app, select any tea and let the timer complete. You’ll see the new particle
animation added to the view.
withAnimation(
.linear(duration: 6.0)
.delay(1)
) {
shapeTop = 0.0
}
256
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
You use the delay(_:) modifier on the linear animation with a value of 1 which
delays for one second before changing shapeTop to zero and beginning the rising
liquid animation.
For performance reasons, you don’t want the particle emitter to keep running once
the animation completes, which occurs when shapeTop reaches zero. If you directly
compared shapeTop to zero, the explicit animation on shapeTop would cause
SwiftUI to apply a transition to the view removal, fading it away. Instead, add the
following code to the end of onAppear(perform:):
This code sets showPour to false after seven seconds, hiding the view. You get seven
seconds from the one-second delay above plus the six seconds length of the
animation. Run the app and let a tea timer complete to see your finished animation.
257
SwiftUI Animations by Tutorials Chapter 9: Combining Animations
Key Points
• You can use animations to draw the user’s attention to an element and add a nice
visual to reinforce the user’s action.
• You can combine multiple animations to produce a finished visual effect for
complex animations.
• The SwiftUI animation system is robust and capable, but you can leverage other
Apple frameworks when creating animations. SwiftUI lets you efficiently use them
in your SwiftUI project.
• SceneKit includes a particle system that works well to produce smoke, rain,
confetti and fire.
For more about SceneKit, see SceneKit 3D Programming for iOS: Getting Started
(https://www.kodeco.com/23483920-scenekit-3d-programming-for-ios-getting-
started).
You can read more about the SceneKit particle system in SceneKit Tutorial with Swift
Part 5: Particle Systems (https://www.kodeco.com/901-scenekit-tutorial-with-swift-
part-5-particle-systems).
258
10 Chapter 10: Recreating a
Real-World Animation
By Irina Galata
For example, take a look at Apple’s Honeycomb grid, the app launcher component on
the Apple Watch:
259
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
The view offers an engaging and fun way of navigation while efficiently utilizing
limited screen space on wearable devices. The concept can be helpful in various apps
where a user is offered several options.
In this chapter, you’ll recreate it to help users pick their topics of interest when
registering on an online social platform:
260
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
Note: The calculations for drawing the grid would not be possible without
Amit Patel’s excellent work in his guide on hexagonal grids (https://
www.redblobgames.com/grids/hexagons/).
This time, you’ll start entirely from scratch, so don’t hesitate to create a new
SwiftUI-based project yourself or grab an empty one from the resources for this
chapter.
Back to the grid. The essential piece of the implementation is the container’s
structure. In this case, it’s a hexagonal grid: each element has six edges and vertices
and can have up to six neighbors.
First, you need to know the fundamentals of the grid, such as its coordinate system
and the implementation of some basic operations on its elements.
261
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
Cube coordinates are the optimal approach for the component you’ll replicate.
If you place this pile of cubes inside the standard coordinate system and then
diagonally slice it by a x + y + z = 0 plane, the shape of the sliced area of each
cube will form a hexagon:
262
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
As you’re only interested in the grid itself, namely the area created by the plane
slicing the pile of cubes, and not in all the cubes’ volume below or above the plane,
from now on you will work with coordinates belonging to the x + y + z = 0 area.
That means, if x is 5, and y is -3, z can only be -2, to satisfy the equation, otherwise
the said point doesn’t belong to the plane, or to the hexagonal grid.
3. In terms of hexagonal grids, the cube coordinates are easily translatable to the
axial coordinate system because the cube coordinates of each hexagon must
follow the x + y + z = 0 rule. Since you can always calculate the value of the
third parameter from the first two, you can omit the z and operate with a pair of
values - x and y. To avoid confusion between the coordinate system you’re
working with in SwiftUI and the axial one, you’ll refer to them as q, r and s in
this chapter. You may often see this same approach in many other resources on
hexagonal grids’ math, but in the end the names are arbitrary and are up to you.
263
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
Create a new file named Hex.swift. Inside the file, declare Hex and add a property of
type Int for each axis of the coordinate system:
struct Hex {
let q, r: Int
var s: Int { q - r }
}
Since the value of s always equals -q - r, you use a computed property for its value.
Often, you’ll need to verify whether two hexagons are equal. Making Hex conform to
Equatable is as easy as adding the protocol conformance to the type:
You can add two hexagons by adding their q and r properties, respectively. Swift
includes another protocol you can use to naturally add and subtract two types
together — AdditiveArithmetic. Add the following conformance to the bottom of
the file:
264
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
Since each of the directions from a hexagon piece has its own relative q and r
coordinate, you can use Hex to represent them according to the chart above. Add the
following code as an extension to Hex:
extension Hex {
enum Direction: CaseIterable {
case bottomRight
case bottom
case bottomLeft
case topLeft
case top
case topRight
265
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
Now fetching one of the current hex’s neighbors is as easy as adding two Hex
instances. Add the following method to your Hex struct:
1. Using the direction enum, you indicate which neighbor you want to get.
2. Then, you get the direction’s coordinate and add it to the current coordinate.
Since obtaining a neighboring hexagon is now possible, you can also add a function
to verify whether two hexagons are, in fact, neighbors:
To check whether two hexagons stand side-to-side, you iterate over all six directions
and check if a hexagon in the current direction equals the argument. Using
contains(where:) will return true as soon as it finds a matching neighbor, or
return false if hex isn’t a neighbor of the current coordinate.
Finally, you must obtain its center’s (x, y) coordinates to render each element.
To calculate the center’s position of a hexagon with the coordinates of (q, r) relative
to the root hexagon in (0, 0), you need to apply the green (pointing sideways) vector
- (3/2, sqrt(3)/2)- q times and the blue (pointing down) vector - (0, sqrt(3)) -
r times.To allow for the scaling of a hexagon, you need to multiply the resulting
values by the size of the hexagon.
266
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
First, in ContentView.swift, add the following constant above to the top of the file
so you can change it later if you need to:
Here, you add the value for the diameter of the circle you’ll draw in place of each
hexagon on the grid. Where the size of a hexagon usually refers to the distance from
its center to any of its corners:
Therefore, a regular hexagon’s width equals 2 * size, and the height is sqrt(3) *
size.
Add the following method calculate the Hex’s center, inside the struct:
return CGPoint(x: x, y: y)
}
1. First, you construct the green and blue vectors from the diagram above.
2. Then, you calculate the size of the hexagon based on the formula for the height.
3. You calculate the total horizontal and vertical shifts by multiplying a vector’s
coordinates by the hexagon’s coordinates and size. Because a regular hexagon
has uneven height and width, you use the same value for both height and width
to fit it into a “square” shape because you’re going to draw circles in place of
hexagons, which would leave blank spaces on the sides otherwise.
267
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
struct HexData {
var hex: Hex
var center: CGPoint
var topic: String
}
Besides the grid’s coordinates, HexData contains the coordinates of its center to
render it and a topic, which the hexagon will display.
Make HexData conform to Hashable so you can iterate over a collection of it later:
In the current case, the represented topic can’t be reused multiple times and, in a
way, is a unique identifier of a HexData instance, sufficient for hash generation.
268
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
You’ll iterate over the elements moving along a spiral from the center of the grid
toward the last ring. To keep track of the current coordinates and a ring’s index, add
the following variables to the top of the newly created method:
var ringIndex = 0
var currentHex = Hex(q: 0, r: 0)
Then, add a variable to append the hexes you’re about to create, and initialize it with
the root hexagon:
Now, the Direction enum you added earlier comes in handy since you need to move
from one hexagon to another along a spiral. Add a variable to keep the directions
along with their indices:
To start the iterations, first, create an outer while-loop until you reach the necessary
amount of elements:
repeat {
ringIndex += 1 // 4
269
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
1. First, you iterate over the directions to reach the hexagons along the whole
spiral.
2. As you progress along the spiral, the amount of hexagons grows with each
consecutive ring. One of the six segments of each ring always has one less
element than the five others though: the first spiral’s ring contains only five
elements. Likewise, the second one has 6 (amount of directions) * 2 (ring
index + 1) - 1 = 11 elements, the third - 6 * 3 - 1 = 17, and so on:
3. For a smaller segment you use ringIndex as the amount of hexagons in it, and
ringIndex + 1 otherwise.
4. After iterating over all the directions, you always increment the index of the
spiral’s ring.
270
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
Here’s a breakdown:
1. As a precaution, you verify if any new hexagons are still needed. Otherwise, you
break out of the inner loop.
2. You update the currentHex by adding the current direction’s hex to it, namely
adding their respective parameters - q, r and s, and append the result to the
hexes array.
Finally, update the return-statement to map the array you’ve just computed to an
array of HexData:
Above, you calculate the center for each hexagon and fetch the respective topic from
the array of strings.
Create a new SwiftUI View file named HexView.swift, and add a property of type
HexData inside the generated struct:
Inside HexView‘s body, add a ZStack containing a Circle and a label to represent
the grid’s hexagon:
ZStack {
Circle()
.fill(Color(uiColor: UIColor.purple))
Text(hex.topic)
.multilineTextAlignment(.center)
.font(.footnote)
.padding(4)
}
.shadow(radius: 4)
.padding(4)
.frame(width: diameter, height: diameter)
271
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
HexView(
hex: HexData(
hex: .zero,
center: .zero,
topic: "Tech"
)
)
Run the preview, and you should see a circle representing the current hex piece, with
a topic in it:
VStack {
Text("Pick 5 or more topics you're most interested in:")
.font(.subheadline)
// TODO
}
272
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
Then, add these properties to the ContentView to keep the HexData array and a
collection of topics:
There are two methods you must implement to conform to this new protocol:
Create a new Swift file named HoneycombGrid.swift and add the following struct to
it:
import SwiftUI
You’ll use the hexes property to fetch each hexagon’s center and position it inside
its container.
273
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
Now, the compiler will prompt you to implement the methods mentioned above so
the struct conforms to the Layout protocol:
func sizeThatFits(
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) -> CGSize {
// TODO
}
func placeSubviews(
in bounds: CGRect,
proposal: ProposedViewSize,
subviews: Subviews,
cache: inout ()
) {
// TODO
}
CGSize(
width: proposal.width ?? .infinity,
height: proposal.height ?? .infinity
)
subviews.enumerated().forEach { i, subview in
let hexagon = hexes[i]
// TODO
}
274
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
// 2
subview.place(
at: position,
anchor: .center,
proposal: proposal
)
1. To calculate the position for each subview, you sum the coordinates of the
container’s origin, hexagon’s center and half of the corresponding container’s
measurement. The origin is important to consider because a view’s bounds often
don’t correspond to the whole screen’s bounds, and its origin can differ from the
(0, 0) point. Since you use the (0, 0) coordinates for the root hexagon, you add
bounds.width / 2 and bounds.height / 2 to center the subviews around the
HoneycombGrid’s center instead of its origin.
Back in ContentView, below Text, add a HoneycombGrid containing all the hexagons
to display in place of the // TODO comment:
HoneycombGrid(hexes: hexes) {
ForEach(hexes, id: \.self) { hex in
HexView(hex: hex)
}
}
.onAppear {
hexes = HexData.hexes(for: topics)
}
275
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
Next, you’ll enable dragging gesture handling to let users pan the component to
access the corner cells. Additionally, users must be able to pick topics, so you’ll also
implement tap gesture handling.
276
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
Gesture Handling
Start with dragging gestures. Add a new @GestureState and @State properties to
the ContentView to keep track of the offset:
First, update dragOffset when the gesture is over to prevent the grid from jumping
back to its initial position by appending the latest translation state to the values of
dragOffset:
dragOffset = CGSize(
width: dragOffset.width + state.translation.width,
height: dragOffset.height + state.translation.height
)
If you take a closer look at the original Apple Watch honeycomb grid component,
you’ll notice that once you stop dragging the view, it moves slightly further, as if
inertia was affecting it.
That may sound complicated to implement. But SwiftUI comes to the rescue and
offers the predictedEndTranslation property of the DragGesture.Value, which
produces a similar result if you apply it to the offset over time.
When a user drags a view, SwiftUI calculates the velocity and direction of the gesture
and computes the approximate end translation. The actual end translation is often
slightly shorter than the predicted one. Therefore the difference between those
values comes in handy to recreate the effect from the original component.
To apply the difference between two offsets, first, create a variable right at the
beginning of onDragEnded(with:) to keep the initial value of the offset:
277
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
state.predictedEndTranslation.width * 1.25
var endY = initialOffset.height +
state.predictedEndTranslation.height * 1.25
You add the width and height of the predicted translation to initialOffset and the
1.25 multiplier to exaggerate the effect slightly.
Then, you must ensure the user can’t accidentally drag the grid out of the screen’s
bounds. To do so, you’ll verify that the offset distance value is always smaller than
the distance from the center to the last hexagon. Add the following code below the
variables you just added:
If the offset value is larger, you replace it with the maximum value allowed. The 0.7
multiplier ensures a few more circles are always visible to prevent the screen from
going almost completely blank when the dragging value reaches its maximum.
After enforcing the dragging bounds, apply the calculated translation by adding the
following code:
withAnimation(.spring()) {
dragOffset = CGSize(
width: endX,
height: endY
)
}
Now, similar to the way you did for the seating chart in earlier chapters, add a
DragGesture to HoneycombGrid and invoke the newly created onDragEnded(with:)
in its onEnded callback:
.simultaneousGesture(DragGesture()
.updating($drag) { value, state, _ in
state = value.translation
}
.onEnded { state in
onDragEnded(with: state)
278
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
}
)
The last step is to apply the offset to the HoneycombGrid. Add .offset
above .onAppear:
.offset(
CGSize(
width: drag.width + dragOffset.width,
height: drag.height + dragOffset.height
)
)
279
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
.onTapGesture {
onTap()
}
HexView(
hex: HexData(
hex: .zero,
center: .zero,
topic: "Tech"
),
isSelected: false,
onTap: {}
)
HexView(
hex: hex,
isSelected: selectedHexes.contains(hex)
) {
select(hex: hex)
}
Then, add a new property to ContentView to keep the currently selected cells:
280
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
if !selectedHexes.insert(hex).inserted { // 1
selectedHexes.remove(hex)
}
withAnimation(.spring()) { // 2
dragOffset = CGSize(width: -hex.center.x, height:
-hex.center.y)
}
1. You attempt to insert the selected hex into the set and check if it was successfully
inserted. Since Sets only include unique values, adding the same value more
than once will return false for inserted, in which case you will remove it from
the set instead.
2. Then, you update dragOffset to the opposite value of the center of hex. This
way, the grid moves to center the selected hexagon on the screen.
To give the user a hint of how many more topics they need to choose, add a text and
a progress indicator at the bottom of the root VStack of ContentView’s body:
Text(
selectedHexes.count < 5
? "Pick \(5 - selectedHexes.count) more!"
: "You're all set!"
)
ProgressView(
value: Double(min(5, selectedHexes.count)),
total: 5
)
.scaleEffect(y: 3)
.tint(selectedHexes.count < 5 ?
Color(uiColor: .purple) : .green)
.padding(24)
281
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
To make ProgressView update its state smoothly, attach the animation view
modifier to it as follows:
282
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
If you have an Apple Watch nearby, look closely at its launcher component again.
When you drag the view around, the closest bubble to your finger and those
surrounding it are slightly dimmed and shrunk.
You can implement this effect by adding one more gesture handler.
Then, add an .overlay to the Circle right below .fill to dim it if the user is
touching it:
.overlay(
Circle()
.fill(touchedHexagon == hex ? .black.opacity(0.25) : .clear)
)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in // 1
withAnimation(.easeInOut(duration: 0.5)) {
touchedHexagon = hex
}
}
.onEnded { _ in // 2
withAnimation(.easeInOut(duration: 0.5)) {
touchedHexagon = nil
}
}
)
1. When the gesture is ongoing, you update touchedHexagon with the current
HexData.
283
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
HexView(
hex: HexData(hex: .zero, center: .zero, topic: "Tech"),
isSelected: false,
touchedHexagon: .constant(nil),
onTap: {}
)
Then add a new variable inside ForEach above HexView’s initializer to determine
whether the current cell should dim:
HexView(
hex: hex,
isSelected: selectedHexes.contains(hex),
touchedHexagon: $touchedHexagon
) {
select(hex: hex)
}
.scaleEffect(hexOrNeighbor ? 0.9 : 1)
284
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
285
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
Add a new method to calculate the positions of additional hexagons below the
hexes(for:) static function you added earlier inside HexData.swift:
//TODO
return newHexData
}
The method receives the source hexagon, the one a user selected, the current array
of HexData and new subtopics.
First, iterate over the potential neighbors of the source hexagon to see whether
there are any empty spaces to insert the new hexagons into:
if newHexData.count == topics.count { // 3
return newHexData
}
}
286
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
2. If the array doesn’t contain a hexagon at that position, you append a new
hexagon to newHexData.
3. At the end of each iteration, you check if you’ve already added all the needed
hexagons and return newHexData in such a case.
In a scenario when the source hexagon doesn’t have enough space around it to
insert all the needed cells, you need to append them further away. Add the following
condition below the loop:
newHexData.append(contentsOf: hexes(
from: source.neighbor(at:
Hex.Direction.allCases.randomElement()!),
array + newHexData,
topics: Array(topics.dropFirst(newHexData.count))
))
Here, you pick a random neighboring hexagon and try to insert the needed hexagons
near it recursively.
There can be multiple ways to approach this problem. You could always pick the last
hexagon of the array as the new source or the first neighbor of the source or even
come up with an algorithm to search for the nearest corner hexagon with enough
spare space. In the end, the grid shouldn’t contain that many elements for the
approach to make a difference: a user must be able to navigate through the whole
grid without easily getting lost.
if shouldAppend {
hexes.append(contentsOf: HexData.hexes(from: hex.hex, hexes,
topics: [
"\(hex.topic)'s subtopic 1",
"\(hex.topic)'s subtopic 2",
"\(hex.topic)'s subtopic 3"
]))
}
}
287
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
You add the subtopics based on whether the current hexagon represents a subtopic
or if it already contains subtopics somewhere else in the grid.
if selectedHexes.insert(hex).inserted {
appendHexesIfNeeded(for: hex)
} else {
selectedHexes.remove(hex)
}
DispatchQueue.main.async {
withAnimation(.spring()) {
dragOffset = CGSize(width: -hex.center.x, height:
-hex.center.y)
}
}
.transition(.scale)
288
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
289
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
GeometryReader is handy for determining the borders of the parent view. Wrap
HoneycombGrid into a GeometryReader:
GeometryReader { proxy in
HoneycombGrid { ... }
}
Create a new method in ContentView to compute the size for each hexagon
depending on its position relative to the borders of the parent view:
First, you need to calculate the total offset of a hexagon from the origin point (0, 0),
counting in the position of its center and the drag gesture’s offset. Add these two
variables in the beginning of the method:
Then, you calculate the amount of “excess” along the x-axis and y-axis, namely what
distance the hexagon traveled behind the borders of the container along each axis
starting from the (0, 0):
290
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
You add the total value of the diameter instead of a half because once the center of
the circle is precisely at the border and only half of its diameter is technically behind
the borders, you want it to shrink to 0, thus deducting its full diameter.
1. You pick the largest excess measurement out of the two. To preserve the 1:1 ratio
of the cell’s width and height, you need to decrease both by the same amount.
Moreover, you only consider the values bigger than 0; a negative value would
mean that the hexagon is still not close enough to a border.
2. Then, you deduct the excess from the size of a hexagon and return the result.
To apply the computations you just implemented, add these two variables below
hexOrNeighbor inside ForEach:
.scaleEffect(max(0.001, scale))
Using 0.0 as a scale multiplier may produce unexpected values in the projection
matrix SwiftUI applies under the hood, which you could observe from the console
logs. Until this issue gets fixed, use a small value barely above 0, like 0.001, to
achieve the needed effect.
291
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
Run the app and drag the grid around to see how the cells constantly change their
size to fit into the container:
Only one small detail is missing to recreate the fish eye effect precisely. In Apple’s
component, when the corner cells shrink, the distance between the centers of those
cells decreases as well.
292
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
Since you’ll move the corner hexagons slightly further away from the borders and
decrease them simultaneously, you need to “split” the excess value you calculated
above between the resizing and repositioning.
Then, add the position calculations above return and update the return value:
293
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
This way, you apply a third of the excess on both axes. Depending on whether the
value of the offset is positive or negative, you set a negative or positive shift,
respectively.
In total, you are negating 1.0/3.0 * excess + 3.0 * excess / 4.0, which is
slightly above the excess value. You do it as a precaution against the cells not
having sufficient spacings or “colliding”.
Go back to ContentView’s body and replace the size and scale variables inside
ForEach, since now you receive a pair of values instead of a single one from the
calculations:
.offset(CGSize(
width: measurement.shift.x,
height: measurement.shift.y
))
294
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
Build and run the app one final time to see the outcome:
295
SwiftUI Animations by Tutorials Chapter 10: Recreating a Real-World Animation
Key Points
1. When recreating an existing UI component, it’s often helpful to break larger
concepts into smaller ones. For instance, find a way to build the outer parts of
the component, the parent container, recreate its layout and proceed with the
smaller views or child controls.
2. One optimal way to build a hexagonal grid is cube or axial coordinates, with the
third, s, parameter computed as -q - r.
3. Apple’s new Layout protocol offers a convenient way to build more complex
containers. You only need two methods to implement it:
sizeThatFits(proposal:subviews:cache:) and
placeSubviews(in:proposal:subviews:cache:).
However, if you want a deeper dive into the topic of hexagonal grids, go to Red Blob
Games blog (https://www.redblobgames.com/grids/hexagons/). You’ll find the best
and most extensive overview of the math behind the hexagonal grids, the
implementation peculiarities, different coordinate systems and the existing
solutions for various programming languages. Some of the functionality of the grid
container in this chapter and the theoretical sections were implemented relying on
this resource.
296
11 Conclusion
The journey has come to an end, and what a delightful journey it has been. You
started by learning some simple animations and how to apply them to your app,
blazed through using both built-in and custom transitions and finished up with an
entirely custom component from a real-world product!
The technical, creative and conceptual concepts you’ve learned in this book will aid
you in adding much more liveliness and richness to your apps. From small, yet
experience-enhancing animations, all through bold and impactful transitions.
We hope your learning journey has been pleasurable and you’re motivated to explore
the many options SwiftUI provides for creating your very own animations. We’re
looking forward to seeing what you make! ;]
In a swift transition, if you want to dive deeper into SwiftUI itself, you might enjoy
some of our other books, such as SwiftUI Fundamentals and SwiftUI by Tutorials.
If you have any questions or comments as you work through this book, please stop by
our forums at https://forums.kodeco.com and look for this book’s forum category.
Thank you again for purchasing this book. Your continued support is what makes the
books, tutorials, videos and other things we do at kodeco.com possible. We truly
appreciate it!
297