Pro Swiftui
Pro Swiftui
PRO
SWIFTUI
Unleash SwiftUI’s full
potential and build
smarter, faster apps
Paul Hudson
Pro SwiftUI
Paul Hudson
Version: 2022-10-25
Contents
Preface 5
Welcome
www.hackingwithswift.com 3
Customizing layout animations
Performance 245
Delaying work…
…or skipping it entirely
Watching for changes
The SwiftUI cycle of events
4 www.hackingwithswift.com
Preface
www.hackingwithswift.com 5
Welcome
SwiftUI makes it astonishingly easy to create beautiful, fast, native apps for all of Apple’s
platforms, and I don’t think I’ll ever grow tired of watching folks be amazed at how fast we
can put apps together.
However, once you’re past the basics it’s common to find some parts of SwiftUI confusing –
you try some code out and wonder why it doesn’t behave the way you expect, and it’s easy to
fall into the trap of experimenting with various modifiers and workarounds until you get
exactly the result you want.
Although I can’t solve all your problems with SwiftUI, the core goal of this book is to help
you build a better understanding of what SwiftUI is doing behind the scenes – to really
understand what it’s doing and why, and in understanding that learn to write better code. So,
rather than just show you a huge range of different APIs that are on offer, we will instead be
focusing on the real core fundamentals of SwiftUI so you can see exactly what makes it tick.
We’ll accomplish this partly by looking at the small amounts of the source code for SwiftUI
that gets exposed by Swift’s interface file, but also by writing a whole bunch of code ourselves
so you can see exactly how SwiftUI responds to various scenarios.
If you work your way through the whole book, including trying all the sample code, you
should come away with a much deeper understanding of what SwiftUI is doing when it runs
our code.
Of course, a book full of behind the scenes explanations wouldn’t be much fun, so I’ve tried to
include a variety of more graphical techniques too – I feel confident everyone will learn
something while following along!
6 www.hackingwithswift.com
Welcome
xed /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/
Developer/SDKs/iPhoneSimulator.sdk/System/Library/Frameworks/SwiftUI.framework/
Modules/SwiftUI.swiftmodule/x86_64-apple-ios-simulator.swiftinterface
If that command works immediately, great – you should see Xcode open with an editing
window full of code! But don’t worry if you get an error, because I’ll address that in a
moment.
The command above asks Xcode to open SwiftUI’s public interface file for reading. That’s not
the same as the generated interface Xcode generates for us using Open Quickly or Jump to
Definition, but is instead the public API interface file SwiftUI actually ships with.
These two files have many things in common, but the public API interface file provides a great
deal more detail, and, critically for us, also includes small amounts of Swift code that
implement various SwiftUI features. This is required for performance reasons, because some
parts of SwiftUI are so trivial or so commonplace that Swift literally copies Apple’s own
SwiftUI code into our files at build time as part of a process called inlining.
The reason I’m saying this up front is two-fold: so I don’t need to keep explaining that this
public interface file provides useful snippets of Apple’s own source code that show us how a
feature is implemented, but also because you might get an error when running it.
So, if you see an error like this one: “xcode-select: error: tool 'xed' requires Xcode, but active
developer directory '/Library/Developer/CommandLineTools' is a command line tools
instance”, it means your Mac has a small misconfiguration that is easily corrected. Run this
command to fix the problem: sudo xcode-select -s /Applications/Xcode.app. That will
prompt you for your password, but once that’s done you’ll be able to run the original command
without problem.
Anyway, you’re welcome to close the file for now; I’ll let you know when I want to dig into it.
www.hackingwithswift.com 7
Preface
Every book contains a word that unlocks bonus content for Frequent Flyer Club members. The
word for this book is HARMONY. Enter that word, along with words from any other Hacking
with Swift books, here: https://www.hackingwithswift.com/frequent-flyer
Updates
When you buy your books from Hacking with Swift, you get Swift updates for free. You can
read the full version of my update policy at https://www.hackingwithswift.com/update-
policy, but the abridged version is this: whenever I release to the public an updated book or
video to reflect these changes, all existing buyers will get that update free.
Dedication
This book is dedicated to my father, who died earlier this year. After he passed I looked
through photos I had taken of him, and realized although I had a great many of him with my
children I had very few of him with me. So, if you’re reading this and are lucky enough to
have your parents or even grandparents around, go and make some memories with them!
8 www.hackingwithswift.com
Chapter 1
Layout and Identity
www.hackingwithswift.com 9
Parents and children
At the core of SwiftUI is its three-step layout process:
It sounds so trivial, and you’re probably wondering why I’m starting out by mentioning
something that is so straightforward, but the simple truth is that this simple process unlocks a
huge amount of power and the more you really understand it the more you’ll be able to get
SwiftUI doing exactly what you want.
The key to the power is answering a simple question: what is the “parent view” in that
process? When you’re learning SwiftUI, the answer seems obvious. For example:
VStack {
Text("Hello, world!")
.frame(width: 300, height: 100)
Image(systemName: "person")
}
If I asked a learner what the parent of the text view was, they would probably answer “the
VStack.” And honestly that’s a perfectly fine answer, and I wouldn’t correct a beginner who
said it – it feels natural, and it fits the hierarchy we can see when the code runs. However, it’s
also wrong, and once you have sufficient experience with SwiftUI it’s important you
understand why the answer is wrong, and more importantly what is right.
Let’s start simple: when we use any modifier in SwiftUI, we are most of the time creating a
new view that wraps the original view to add some extra behavior or styling. For example, this
is just one view:
10 www.hackingwithswift.com
is just one view: Parents and children
Text("Hello, world!")
Text("Hello, world!")
.frame(width: 300, height: 100)
This makes sense if you break it down and keep the three-step layout process in mind: the text
view can’t position itself, because that’s the job of the parent. So, the only way for “Hello,
world!” to be aligned center in a 300x100 container is for the parent – the frame – to be that
300x100 container. This is also why we can stack many modifiers to create more complex
effects: we aren’t modifying the original view again and again, but instead modifying a new
view that modifies the original.
Once you understand this process of creating new views using modifiers, so much of the rest
of SwiftUI makes sense. This is why I repeatedly encourage folks to print out the underlying
types of their views, like this:
Text("Hello, world!")
.frame(width: 300, height: 100)
.onTapGesture {
print(type(of: self.body))
}
When you do that, you’ll see the ModifiedContent type appear a lot – not exactly once for
every modifier, because again not all modifiers create new views. ModifiedContent is itself a
struct that conforms to the View protocol, and I’d like you to look it up using Open Quickly.
www.hackingwithswift.com 11
Layout and Identity
If you aren’t familiar with it, Open Quickly is an Xcode feature that lets you type to search
through your code and also Apple’s own APIs; activate it using Shift+Cmd+O, then type
ModifiedContent and press return. This will open Xcode’s generated interface file for SwiftUI,
and you should be able to find this in there:
These things aren’t magic, and they aren’t secret – you can use ModifiedContent directly if
you want, because it’s public API. For example, back in ContentView.swift we could create a
custom a modifier like this one:
That’s obviously a lot more wordy than the normal SwiftUI code we’d write, but what I want
you to understand is that the end result is identical to what we’d get by using modifier() like
12 www.hackingwithswift.com
Parents and children
this:
Text("Hello")
.modifier(CustomFont())
.onTapGesture {
print(type(of: self.body))
}
This is what SwiftUI’s result builder does for us: it repeatedly transforms our modifiers into
ModifiedContent views, nesting them again and again to get exactly the right result. This is
all done at compile time rather than run time: the actual underlying type of these two views are
identical, rather than just the finished, rendering layouts.
Text("Hello, world!")
.frame(width: 300, height: 100)
That creates the original text view, plus a new ModifiedContent around it that happens to
contain the fixed frame instructions. The text still has its original frame – the original size it
wants to work with – but now we’ve added a second frame around it.
You can see the original frame is alive and kicking by passing in a custom alignment for the
outer frame:
Text("Hello, world!")
.frame(width: 300, height: 100, alignment: .bottomTrailing)
If you think about it, the only way the text can be aligned to the bottom trailing edge is if it
knows its original frame. So, our text view has its own default frame that exactly matches the
natural size for its text, and no amount of futzing from us can ever force that text to extend its
bounds beyond the natural width and height of its lines.
However, by applying the frame() modifier we’re creating a new ModifiedContent view
www.hackingwithswift.com 13
Layout and Identity
around the text that takes up more space – it’s not strictly a “frame” view in its own right, but I
think it’s helpful to talk about it like that.
14 www.hackingwithswift.com
Fixing view sizes
Let’s look at this simple code again:
Text("Hello, world!")
.frame(width: 300, height: 100)
Like I said, despite attaching a frame() modifier, no amount of futzing from us can ever force
that text to extend its bounds beyond the natural width and height of its lines – that’s just not
how SwiftUI works.
Of course, the real question here is this: what is the natural size for the text? Well, the answer
is that text likes to live on one long line, and that’s exactly what it will do unless you ask for
something else. For example, we might say that our frame had a width of only 30 rather than
300, like this:
Text("Hello, world!")
.frame(width: 30, height: 100)
Now there isn’t enough space for the text, it’s forced to wrap across several lines to fit into the
tiny box we’ve allocated to it.
www.hackingwithswift.com 15
Layout and Identity
On the surface this sounds like it breaks the second rule of SwiftUI’s layout system: “the child
chooses its own size and the parent view must respect that choice.” In this case the child is the
text view and the “frame” view is its parent, so how come the text is being forced to accept the
size of its parent, the frame?
What’s really happening here is that all views use six values to decide how much space they
want to use for layouts, and understanding how they interact is key to getting the most out of
SwiftUI’s layout system.
• Minimum width and minimum height, which store the least space this view accepts.
Anything lower than these values will be ignored, causing the view to “leak” out of the
space that was proposed to it.
• Maximum width and maximum height, which store the most space this view accepts.
Anything greater than these values will be ignored, meaning that the parent must position
the view somehow inside the remaining space.
• Ideal width and ideal height, which store the preferred space that this view wants. It’s okay
16 www.hackingwithswift.com
Fixing view sizes
to provide values outside these, as long as they still lie in the range of minimum through
maximum. (If you’re coming from a UIKit background, think of this as being like the
intrinsic content size of your view.)
It’s that last one that stores the natural size for our text: the text has ideal width and height
suitable to store its characters on one single line, but it has no minimum size – it doesn’t care if
we try to squeeze the text into a limited width, because it will automatically wrap its letters
around to multiple lines.
This is where the fixedSize() modifier comes in, which has the job of promoting a view’s ideal
size to be its minimum and maximum size. It’s used like this:
Text("Hello, world!")
.fixedSize()
.frame(width: 30, height: 100)
When that code runs, our text will appear at its original width, despite us trying to override it.
Take a moment to think about it: what do you think is actually happening internally with that
code?
Tip: I know it’s really tempting to skip ahead and read my discussion for this question, but I
promise you’ll learn more if you just pause for a moment and think of your own answer to the
question: how will SwiftUI interpret that code, and in what order? Remember, the fixedSize()
modifier create a parent view around the text, then in turn the frame() modifier creates parent
view around the fixedSize().
Still here?
• We have three views in total: two ModifiedContent views and a Text view.
• In terms of parent-child relationships, our frame is the overall parent, and it has a fixed size
view for its child, which in turn has a text view for its child.
• When we create a 30x100 frame, it will offer that full space to fixedSize() child.
www.hackingwithswift.com 17
Layout and Identity
• The view created through fixedSize() proposes that same size to its Text view.
• The text has no idea it’s going to be placed in a 30x100 frame, so it says, “well, my ideal
size is 95x20, but I’m happy to take up less space if needed.”
• The fixedSize() modifier then uses that same information, except now it turns the ideal size
into a fixed size – it effectively returns the equivalent of self.frame(width:
text.idealWidth, height: text.idealHeight).
• So now the frame gets told it has to position a child much bigger than the size it proposed,
and does so – it doesn’t have a choice.
So, fixedSize() is how we promote ideal size up to be fixed size. You can use fixedSize() with
no parameters to get both axes fixed at the same time, or provide Boolean parameters to fix
one specific axis if you prefer.
In the case of text views, remember that fixing its horizontal size will default to it going over
one line no matter how long its text is. If that’s what you want, great! If not, you might find
that fixing only its vertical axis is more useful, because it allows the text to be squashed
horizontally while still growing as tall as needed to handle its lines wrapping.
But what will other view types do? Consider code like this:
Image("singapore")
.frame(width: 300, height: 100)
On its surface that appears to request a 300x100 image, but any SwiftUI veteran will know it
doesn’t work like that – the image will be its original size, happily overflow the 300x100
frame we allocated for it. If you’re using Xcode’s preview canvas in selection mode you’ll be
able to see the thin outline of the frame right there.
18 www.hackingwithswift.com
Fixing view sizes
You can see it more clearly if you use the clipped() modifier to see what’s really happening:
Image("singapore")
.frame(width: 300, height: 100)
.clipped()
Perhaps now you have a better idea of what’s happening here: image views get their ideal
width and height directly from the image data you load into them, and just like text views no
amount of futzing from us can override that.
“But Paul,” I hear you say, “surely we can override the ideal size by making the image
resizable?” Nope! Again, no amount of futzing from us can override the ideal size of an image
– you can see it for yourself with code like this:
Image("singapore")
.resizable()
www.hackingwithswift.com 19
Layout and Identity
.fixedSize()
.frame(width: 300, height: 100)
That makes the image resizable, but then promotes the ideal size into a fixed size – lo and
behold, the image is back to its original size again.
Earlier I said, “when we use any modifier in SwiftUI, we are most of the time creating a new
view that wraps the original view to add some extra behavior or styling.”
Well, resizable() is one of the modifiers that doesn’t create a new view: it sends back an image
with the resizing behavior in place, but that isn’t wrapped in some kind of “resizable” modifier
– all we did was tell it to have a flexible width and height, but the underlying ideal size is still
there.
The key here is to remember that whatever frame you try to apply to the image will
automatically inherit values from the image that you don’t specifically override.
For example, a common problem SwiftUI learners hit is when they use a very wide image
alongside some text, like this:
VStack(alignment: .leading) {
Image("wide-image")
Text("Hello, World! This is a layout test.")
}
If that image is very large compared to the device you’re using, e.g. 2000x1000, then it will
stretch the width of the VStack beyond the edges of the screen, which will cause the text to be
placed off screen too. This is rarely what you want – how would you go about fixing it?
20 www.hackingwithswift.com
Fixing view sizes
To fix this without squashing the image, the simplest thing to do is wrap it in a frame with a
completely flexible width, like this:
VStack(alignment: .leading) {
Image("wide-image")
.frame(minWidth: 0, maxWidth: .infinity)
Text("Hello, World! This is a layout test.")
}
Critically, if you remove the minWidth parameter there, the frame will get its minimum width
from the image, which again wants to show its entire picture at its natural size. And even with
both minimum and maximum width provided, adding fixedSize() afterwards shows that the
ideal width and height of the image is still there!
www.hackingwithswift.com 21
Layout and Identity
Another common problem beginners face is making two views the same width or height
depending on their content. For example, they might have a layout like this:
HStack {
Text("Forecast")
.padding()
.background(.yellow)
Text("The rain in Spain falls mainly on the Spaniards")
.padding()
.background(.cyan)
}
In that layout, the HStack proposes the available space to its children, then splits up the space
based on what they sent back. In practice, the larger text view will need significantly more
space than the smaller one, and when space is restricted the text will wrap – how can we make
them the same size?
22 www.hackingwithswift.com
Fixing view sizes
them the same size?
Well, the background() modifiers will create frames using whatever size they receive from the
text they wrap, but if we add a custom frame to them then the backgrounds become free to
expand to fill more space:
HStack {
Text("Forecast")
.padding()
.frame(maxHeight: .infinity)
.background(.yellow)
Text("The rain in Spain falls mainly on the Spaniards")
.padding()
.frame(maxHeight: .infinity)
.background(.cyan)
}
Now, remember: even though we’ve told the background it has a flexible maximum height, we
haven’t overridden its ideal height – that still comes through from the text it contains. So, each
piece of text has its own ideal height that exactly fits its content, the background inherits that
ideal height, and the HStack around it calculates its own ideal height to be the maximum of
the ideal heights of the two views it contains. As the text views have infinite maximum heights
and will therefore expand to fill all the available height, the HStack will also expand to fill all
the available height.
www.hackingwithswift.com 23
Layout and Identity
As a result, if we make the HStack use fixedSize(), we can make our two text views have the
same height with very little code:
HStack {
Text("Forecast")
.padding()
.frame(maxHeight: .infinity)
.background(.yellow)
Text("The rain in Spain falls mainly on the Spaniards")
.padding()
.frame(maxHeight: .infinity)
.background(.cyan)
}
.fixedSize(horizontal: false, vertical: true)
Telling the HStack to fix its size is different from telling each of the text views to fix their
size: we want them to resize upwards to some upper limit, which in this case is the ideal size of
their container.
24 www.hackingwithswift.com
Layout neutrality
Not all views have a meaningful ideal size, and in fact some views have very little sizing
preference at all – they will happily adapt their own size based on the way we use them
alongside other views. This is called being layout neutral, and a view can be layout neutral for
any combination of its six dimensions.
That will fill the whole space with red, because the color will occupy whatever space is
available. On the other hand, we could use the color as a background:
Text("Hello, World!")
.background(.red)
Because the color doesn’t actually care how much space it takes up, it will simply read the
ideal and maximum sizes from its child, the text, and use that for itself. It doesn’t read the
minimum size because like I said earlier the text view is itself layout neutral for its minimum
width and height – the text doesn’t mind being squeezed smaller, so the background color
doesn’t mind either.
If we wanted to describe this behavior accurately, we’d say that the background color has an
ideal width, ideal height, maximum width, and maximum height, but is layout neutral for its
minimum width and minimum height. In practice this means it will fit snugly around the text it
wraps rather than expanding to fill all the available space, but it’s also happy to be squeezed
downwards if needed.
www.hackingwithswift.com 25
Layout and Identity
What do you think that might do, and why? Remember to work backwards – the layout starts
with background(.red) as the parent, and works inwards from there.
• The background has the whole screen to work with, and Color.red is completely layout
neutral so it’s happy to occupy whatever is available. If this were the entirety of our layout,
the color would expand to fill the screen.
• The background passes on the size proposal it received (the whole screen) to its child,
frame(), which is layout neutral for minimum width, minimum height, maximum width,
and maximum height.
• The frame proposes the whole screen to its child, the text, which is layout neutral for
minimum width and minimum height, but cares very much about its ideal width, ideal
height, maximum width, and maximum height.
• The text sends back to the frame the four values it cares about, but because the frame has
provided its own ideal width and height those two are effectively ignored – the frame will
use its own ideal width and height to override whatever the text asked for. However,
because the frame is layout neutral for its maximum width and height, it will inherit those
from the text.
• The frame then sends its final size up to its parent view, the background: it has the
300x200 ideal size we set, but a maximum size of whatever the text says it needs, e.g.
26 www.hackingwithswift.com
Layout neutrality
95x20.
• That 95x20 space then gets filled with the red background.
So, it really is important to think about all six sizing values when working with layout – they
combine together in really interesting ways that may not necessarily make sense at first, but if
you break it all down into a sort of blow-by-blow conversation then hopefully the exact
behavior will become clear.
Helpfully, all six of these sizing values are optional, which means you can switch between
layout neutrality and a fixed value by using either a number or nil. This is most commonly
done using a ternary conditional operator, like this:
• When it’s true, the frame will propose 300 points of width to the text
• When it’s false, the frame will propose to the text whatever size it receives from the
VStack – it effectively does nothing at all.
So, if nil forces a view to be layout neutral for one particular dimension, what happens if we
www.hackingwithswift.com 27
Layout and Identity
have a view that’s layout neutral for every dimension? We can certainly do this with the
frame() modifier, but at some point every view has some kind of size data, even if that’s just a
nominal ideal size in order to keep our layouts from exploding.
ScrollView {
Color.red
}
Usually Color.red is happy to fill all the available space, but it wouldn’t make any sense here
because it would lead to an infinitely sized scroll view. In this situation, the red color will get a
nominal 10-point height – enough that we can see it’s being placed, but it won’t expand
beyond that.
You can get a better idea of what’s happening if you try attaching a frame to the color, like
this:
ScrollView {
Color.red
.frame(minWidth: nil, idealWidth: nil, maxWidth: nil,
minHeight: nil, idealHeight: nil, maxHeight: 400)
}
}
28 www.hackingwithswift.com
Layout neutrality
We’re now explicitly giving the color a maximum height to work with, but it won’t matter – it
will still stay 10 points high, because our frame is layout neutral for its ideal height. This isn’t
just that the color remains small while the frame grows empty around it, which you can see if
you try coloring the frame blue:
ScrollView {
Color.red
.frame(minWidth: nil, idealWidth: nil, maxWidth: nil,
minHeight: nil, idealHeight: nil, maxHeight: 400)
.background(.blue)
}
}
You won’t see any background there, because the frame still has an ideal height matching the
color it contains. In order to get the color to expand, we need to override its ideal height in its
frame parent, like this:
ScrollView {
Color.red
.frame(minWidth: nil, idealWidth: nil, maxWidth: nil,
minHeight: nil, idealHeight: 400, maxHeight: 400)
}
}
Now the red color will expand to be 400 points high – internally the color has an infinite
maximum height but gets capped to the 400 points it is offered by the frame, but now the
frame won’t adopt the 10-point ideal height of the color and will instead use 400 points so that
the color can grow freely.
www.hackingwithswift.com 29
Multiple frames
Many SwiftUI modifiers can be stacked to create interesting effects, such as perhaps adding
multiple backgrounds to some text:
Text("Hello, World!")
.frame(width: 200, height: 200)
.background(.blue)
.frame(width: 300, height: 300)
.background(.red)
.foregroundColor(.white)
However, it’s possible and indeed common to apply a frame() modifier twice back to back –
with no other modifiers in between. This is because SwiftUI separates the concepts of fixed
frames and flexible frames: a single view can have a fixed width or height, or it can have
flexible dimensions provided, but it can’t have both.
30 www.hackingwithswift.com
Multiple frames
Of course, often we do want both. For example, if you were designing a macOS app you might
say want a fixed width for part of your UI, but have a minimum height so that users can’t make
the window really tiny:
Text("Hello, World!")
.frame(width: 250)
.frame(minHeight: 400)
I don’t want to sound like a broken record, but remember the golden rule here: no amount of
futzing from us can ever force that text to extend its bounds beyond the natural width and
height of its lines. We aren’t making the text flexible at all, because it isn’t possible. Instead,
we’re wrapping it in a new view that has a fixed width of 250 points, then wrapping that in
another new view that has a flexible height of at least 400 points.
The best way to check your understanding is correct is to look at code like this:
Text("Hello, World!")
.frame(width: 250)
.frame(minWidth: 400)
What do you think that will do when run? It sounds like we’re giving SwiftUI completely
contradictory instructions, but we really aren’t, and hopefully the answer becomes clear if you
try to think like SwiftUI does.
Again, pause for a moment and think it through to decide for yourself before I walk you
through what actually happens.
• Our text has an ideal width and height matching its contents.
• We place that inside a frame that is 250 points wide.
• We place that frame inside another frame that is at least 400 points.
So, there is no contradiction at all, and if you try adding background colors after the text and
www.hackingwithswift.com 31
Layout and Identity
Text("Hello, World!")
.background(.blue)
.frame(width: 250)
.background(.red)
.frame(minWidth: 400)
.background(.yellow)
32 www.hackingwithswift.com
Inside TupleView
You’ve seen how using modifiers with a single view gets transformed by @ViewBuilder into
nested ModifiedContent views, but the other side of this coin is how Swift handles multiple
views. Try this:
VStack {
Text("Hello")
Text("World")
}
.onTapGesture {
print(type(of: self.body))
}
TupleView isn’t underscored, which means it’s public API, and I encourage folks to look it up
using Open Quickly because it explains one of the key restrictions in SwiftUI. If you look for
where TupleView is used, sooner or later you’ll find this:
public static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7,
C8, C9>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5:
C5, _ c6: C6, _ c7: C7, _ c8: C8, _ c9: C9) -> TupleView<(C0,
C1, C2, C3, C4, C5, C6, C7, C8, C9)> where C0 : View, C1 :
View, C2 : View, C3 : View, C4 : View, C5 : View, C6 : View,
C7 : View, C8 : View, C9 : View
That’s a generic result builder method that accepts 10 views, and you’ll also find alternatives
that accept 9 views, 8 views, and so on – but, critically, you won’t find one that accepts 11
views, which is why SwiftUI isn’t able to statically represent more than 10 views in its type
www.hackingwithswift.com 33
Layout and Identity
system. (If you look for other examples of buildBlock you’ll see there are some examples that
are significantly more complex – see TableColumnBuilder for a real eye opener!)
There’s no software restriction making 10 a hard limit, instead it’s a pragmatic choice by the
SwiftUI team: they need to draw a limit somewhere, and 10 is a reasonable amount. If you
wanted, you could use Open Quickly to look for buildBlock in SwiftUI’s generated interface,
then copy it into your own code and add to it to allow 11 views, like this:
extension ViewBuilder {
public static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7,
C8, C9, C10>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4,
_ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8, _ c9: C9, _ c10: C10)
-> TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8, C9, C10)>
where C0 : View, C1 : View, C2 : View, C3 : View, C4 : View,
C5 : View, C6 : View, C7 : View, C8 : View, C9 : View, C10 :
View {
TupleView((c0, c1, c2, c3, c4, c5, c6, c7, c8, c9, c10))
}
}
That uses 11 different views, so now you’ll find you can include 11 children inside any
container. Heck, you could even just create a TupleView directly whenever you needed using
as many views as you want, like this:
34 www.hackingwithswift.com
Inside TupleView
TupleView((
Text("1"),
Text("2"),
Text("3"),
Text("4"),
Text("5"),
Text("6"),
Text("7"),
Text("8"),
Text("9"),
Text("10"),
Text("11"),
Text("12"),
Text("13"),
Text("14"),
Text("15")
))
Tip: Note the double opening and closing parentheses – the first is because we’re calling the
TupleView initializer, and the second is to mark a tuple of our views, which is why we need to
use commas to separate each view.
This isn’t magic, or a hack – it’s exactly what SwiftUI does internally, albeit now extended to
go one higher than the SwiftUI team chose. In fact, you can see all this for yourself because all
these TupleView instances are directly inlined into our code at build time for maximum
efficiency using SwiftUI’s public interface file.
I mentioned this in the introduction to the book, but I’d like you to open it now. Run this
command:
xed /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/
Developer/SDKs/iPhoneSimulator.sdk/System/Library/Frameworks/SwiftUI.framework/
Modules/SwiftUI.swiftmodule/x86_64-apple-ios-simulator.swiftinterface
www.hackingwithswift.com 35
Layout and Identity
Modules/SwiftUI.swiftmodule/x86_64-apple-ios-simulator.swiftinterface
(If you hit the error about CommandLineTools, see the book introduction for how to fix it!)
If you look for extension SwiftUI.ViewBuilder { in the file that gets opened, you’ll see all the
TupleView creation, like this:
extension SwiftUI.ViewBuilder {
@_alwaysEmitIntoClient public static func buildBlock<C0,
C1>(_ c0: C0, _ c1: C1) -> SwiftUI.TupleView<(C0, C1)> where
C0 : SwiftUI.View, C1 : SwiftUI.View {
return .init((c0, c1))
}
}
That’s literally doing the same thing we did, except it can use the shorthand .init(()) rather than
TupleView(()) because Swift can see the return type of the method.
SwiftUI doesn’t care how your TupleView instances are structured, meaning that you can
place a TupleView inside a TupleView inside another TupleView and it will still get flattened
down to a single collection of views. This means we can use Swift’s partial block results
builders to allow any number of view children – try adding this:
extension ViewBuilder {
static func buildPartialBlock<Content>(first content:
Content) -> Content where Content: View {
content
}
36 www.hackingwithswift.com
Inside TupleView
Just having those two partial block builders will cause SwiftUI to nest many TupleView
instances, but that’s okay – it really doesn’t care how the views are structured, because
internally it uses runtime reflection that inspects the type metadata to figure out exactly how
many children existed. If you desperately wanted to get the exact same TupleView layout as
SwiftUI has by default, while also permanently removing the 10-view limit, you would need to
add a bunch more buildPartialBlock() methods to handle the full set of C0 through C9 views.
www.hackingwithswift.com 37
Understanding identity
Every view in SwiftUI must be uniquely identifiable, by which I mean that SwiftUI needs to
know exactly which view is located where at all times. This means all views have an identity:
something about it that makes the view unique.
• An explicit identity, where we tell SwiftUI the identity for a particular view.
• A structural identity, where SwiftUI implicitly generates identities for our views based on
where we use them in our code.
Regardless, all views must have an identity: SwiftUI will provide these as much as possible,
but there are two key places where we use explicit identity:
1. When we’re dealing with dynamic data, such as looping over an array.
2. When we need to refer to a particular view, such as scrolling to a particular location.
Understanding why identity is so important actual boils down into what might be the single
biggest misunderstanding about SwiftUI: “when you make a change in your view hierarchy,
SwiftUI performs tree diffing to figure out what changed.” Tree diffing would effectively
mean Swift looking at the state of your view hierarchy before your change, and looking at it
after the change, then walking through each to figure out what was added and removed.
Previously I said that learners often think the parent of a view is whatever directly contains it –
a text view’s parent might be a VStack, for example – and that it’s okay to take this approach
while you’re learning. I think this is a similar situation: tree diffing feels natural when you’re
learning because you can imagine exactly how it happens at runtime, and so I think it’s a
perfectly fine answer that helps folks make progress.
However, in practice tree diffing never happens thanks to the concept of identity: as it builds
your view code, the Swift compiler has complete oversight into every subview you’re using,
alongside every modifier, every condition, every loop, and more, and these all get encoded
38 www.hackingwithswift.com
Understanding identity
directly into the type of your views. We saw this earlier when using type(of: self.body) to
examine what the actual type of our view body was, but it’s really important you understand
this extends to logic as well.
VStack {
if Bool.random() {
Text("Hello")
} else {
Text("Goodbye")
}
}
.onTapGesture {
print(type(of: self.body))
}
When that runs and you tap the text you’ll see it prints the following into Xcode’s console:
ModifiedContent<VStack<_ConditionalContent<Text, Text>>,
AddGestureModifier<_EndedGesture<TapGesture>>>. If we break that down, you can
see:
1. At the top level we have a ModifiedContent view that contains a VStack as its view and
an AddGestureModifier as its modifier.
2. Inside the VStack is a _ConditionalContent view, which contains two Text views.
That second part is what’s important here: the _ConditionalContent view, which is
underscored because it’s considered a private implementation detail rather than something we
should attempt to manipulate directly, is literally our if statement encoded into Swift’s type
system.
Like I said, _ConditionalContent is not exposed as public API, but we can at least see where
it’s being made – use Open Quickly (Shift+Cmd+O) to look for “buildEither”, and you should
www.hackingwithswift.com 39
Layout and Identity
So, that handles any kind of view for the true case, and any kind of view for the false case.
When our condition changes – which it might whenever the body property is reinvoked,
because it’s random – SwiftUI doesn’t need to do any view diffing because it just flips from
the TrueContent view to the FalseContent view.
It even does this when handling switch statements, which it collapses down into a binary tree
of all possible states. Try this to see what I mean:
enum ViewState {
case a, b, c, d, e, f
}
40 www.hackingwithswift.com
Understanding identity
Capsule()
case .f:
RoundedRectangle(cornerRadius: 25)
}
}
I’ve wrapped the various states up in a separate property to make the type easier to read, but
when you press the button you’ll see the type is
_ConditionalContent<_ConditionalContent<_ConditionalContent<Text, Image>,
_ConditionalContent<Circle, Rectangle>>, _ConditionalContent<Capsule,
RoundedRectangle>> – it’s a binary tree covering all our cases, so SwiftUI would need to
jump through true, true, true to get some text, or true, true, false to load the image, and so on.
This behavior of converting logic into types has two important side effects, both of which
directly affect how we use SwiftUI:
• SwiftUI needs to be able to statically (i.e. at compile time) represent complex view layouts
such as a stack containing three text views, then an image, then a nested stack, then a
button, etc.
• That complex view layout is the actual underlying type of our view body.
Remember, using modifiers transforms our views into nested ModifiedContent views, and
using things like VStack causes SwiftUI to group multiple views into a TupleView. All these
transformations create extremely long types for our views, which is where Swift’s opaque
return types come in: when we write some View as the return type for our view body, it means
www.hackingwithswift.com 41
Layout and Identity
we don’t want to explicitly have to write out the return type for our layout beyond saying “it
will be some type that conforms to the View protocol,” but – importantly – we aren’t trying to
hide that information from Swift.
Remember, SwiftUI needs to know exactly what is in our view hierarchy in order to be able to
update its layouts efficiently, but if we had sent back a regular protocol – if we were able to
return View rather than some View, for example – then we’re explicitly hiding data from
Swift. Elsewhere in our code that is often what we want, usually because we want to retain
some flexibility for the future, but with SwiftUI it’s a very bad idea because it wants to identify
all our views based on their type and position within our view hierarchy.
This is all made possible because the View protocol contains this line of code:
That explicitly marks the body property of our views as using @ViewBuilder – a result
builder that converts our layout into a carefully curated collection of TupleView,
ModifiedContent, _ConditionalContent, and more. I used this explicitly earlier because only
body gets it automatically applied by the protocol.
So, ModifiedContent is generic over some kind of view and some kind of modifier, and
TupleView is generic over all the views it contains, all so that SwiftUI has a complete
overview of our layout – it can literally guarantee the contents of a VStack, for example, even
when using conditions and loops to assemble it, which in turn it means all the views have clear
structural identity.
Now, maybe you’re wondering why all this matters, and the answer is that the identity of our
view dictates its lifetime: as soon as the identity of a view changes, either structurally or
explicitly, the view is destroyed.
From a performance perspective this is pretty bad, because SwiftUI will throw away your
platform views – the underlying UIView or NSView used to render your SwiftUI layout to the
screen – when their matching SwiftUI lifetimes end, but more importantly it will also destroy
any data your views were storing, because as far as SwiftUI is concerned you asked for your
42 www.hackingwithswift.com
Understanding identity
view to be destroyed.
You already saw how _ConditionalContent is generic over its true and false content types, so
perhaps you can see where this is leading: whenever we flip between two views using an if
condition, that causes SwiftUI to throw away its platform views and all the state we created
each time.
www.hackingwithswift.com 43
Layout and Identity
.padding()
}
}
That renders the same view in two slightly different ways: one scaled up to 200%, and one at
its default size. The scaling happens using an animated Boolean, and ExampleView stores
some state for how many times its button was tapped.
1. The scaling effect happens as a fade – the smaller button fades out, while the larger one
fades in.
2. The tap count for your view gets reset to 0 every time you toggle the switch.
Both of these happen because of that _ConditionalContent flip: SwiftUI destroys the original
ExampleView along with its platform rendering, and creates a new ExampleView in its place
– the fade effect happens because we’re seeing a transition, rather than an animation. As for
the @State being lost, again this is because SwiftUI considers the original view to be
destroyed, so it removes all its data at the same time.
We’re losing data, we’re losing smooth animations, and we’re making SwiftUI do a lot of
extra work, because as far as SwiftUI is concerned these are two separate views.
This problem remains even if we removed the view modifier so all that was changing was the
way ExampleView was created:
44 www.hackingwithswift.com
Understanding identity
}
.scaleEffect(scale)
}
}
Where things get interesting is what happens if we add some explicit identity, like this:
www.hackingwithswift.com 45
Layout and Identity
You might expect that to work, because now we’re telling SwiftUI that both our
ExampleView instances are the same, but no dice – we’ll still get the views being destroyed
and recreated, with all the associated problems of that.
The problem isn’t having two ExampleView instances; that’s actually fine, and thanks to the
id() modifier SwiftUI is able to figure out that they are the same view. The actual problem is
_ConditionalContent, because as we saw earlier it explicitly stores its data as two separate
views – it is hard-coded to think of its two pieces of data as being distinct, no matter what
identifiers we attach to them. This means from a structural identity perspective our two views
are different, no matter what explicit identifiers we give them.
Remember, _ConditionalContent exists because the View protocol explicitly marks its body
property as using @ViewBuilder. If we get our code out of the body then we don’t get
@ViewBuilder unless we ask for it, which means we don’t get _ConditionalContent, and
that in turn means SwiftUI is able to rely on the explicit identity we provide.
We can see this for ourselves if we move our two ExampleView instances into a computed
property, like this:
46 www.hackingwithswift.com
Understanding identity
return ExampleView(scale: 1)
.id("Example")
}
}
Now SwiftUI will work as expected: it will preserve the state between both views, animate
correctly, and perform efficiently. Even better, we can actually remove the id() modifier here
and SwiftUI can still figure out it’s the same view.
As usual, I’m going to explain why in a moment, but first I’d like you think about it before
reading my answer – how is SwiftUI able to understand the two ExampleView instances are
the same now, even without an explicit id() attached, when it couldn’t before?
Still here?
Instead, because the exampleView property is used in a fixed position in our layout, SwiftUI
can rely on good old structural identity to realize that the two structs should both map to the
same underlying view – the property might return ExampleView(scale: 2) or
ExampleView(scale: 1), but either way it’s an instance of ExampleView so SwiftUI considers
www.hackingwithswift.com 47
Layout and Identity
This might sound ridiculous, or perhaps even dangerous: our code specifies two different
ExampleView instances being sent back from a single property, so why should SwiftUI
consider them the same? Understanding this gets to the very heart of what @State does and
why it even exists.
You see, every time SwiftUI evaluates a view’s body property it creates new instances of all
the view structs inside – it has to, because structs always have a unique owner, and can’t
somehow be “reused” from a previous body invocation.
So, even a trivial view struct will be recreated often, which means SwiftUI regularly needs to
map the old struct to the new struct – to recognize that two instances of a view struct are the
same and should share the same state – so that it can preserve state correctly. When our
exampleView property returns the same view type without using @ViewBuilder, it’s really
no different from it having no condition at all and just returning a single view.
Of course, by ditching @ViewBuilder we’ve lost all the things that make SwiftUI natural – we
have to return one specific type of view from both our condition cases, so we’re out of luck if
one case wants to add a background color while the other doesn’t.
What SwiftUI really wants – and the kind of code we should really be striving for – is for us to
use the ternary conditional operator, like this:
48 www.hackingwithswift.com
Understanding identity
}
}
Using this approach we’re back to having structural identity do all the hard work for us:
regardless of the value of scaleUp we have an ExampleView as the first child of our VStack,
so SwiftUI will keep it alive as the Boolean changes.
I think the problem is pretty clear in our current code, but it might take a little more thought
when dealing with modifiers depending on which iOS version you’re targeting. For example,
we might have some code to show a message in bold if the user hasn’t read it yet:
How could we write that as a single view? In iOS 16 and later the bold() modifier takes a
Boolean saying whether it’s active or not, but if you’re targeting 15 or earlier – you either add
the modifier or you don’t.
If you aren’t able to target 16 or later, the fix here is to remove bold() entirely and replace it
with fontWeight(), which does accept other options:
Text("Message title
here").fontWeight(isNewMessage ? .bold : .regular)
www.hackingwithswift.com 49
Layout and Identity
We could get even simpler because fontWeight() actually accepts an optional weight, where
nil means “the default”:
Some SwiftUI modifiers simply refuse to accept a customization parameter, with the most
notorious being hidden() – it unconditionally hides a view in our layout, while leaving space
where it was. This means using it puts back to the state loss problem from earlier:
Button("Toggle") {
withAnimation {
shouldHide.toggle()
}
}
}
}
}
Remember, the problem here is @ViewBuilder, not ExampleView – even if you tried to build
an improved hidden() modifier that accepts a Boolean, you’ll fall foul if you adopt
50 www.hackingwithswift.com
Understanding identity
@ViewBuilder:
extension View {
@ViewBuilder func hidden(_ hidden: Bool) -> some View {
if hidden {
self.hidden()
} else {
self
}
}
}
Once again the key is to use a ternary conditional operator, which means no more need for
@ViewBuilder:
extension View {
func hidden(_ hidden: Bool) -> some View {
self.opacity(hidden ? 0 : 1)
}
}
That preserves our view’s identity regardless of the value of hidden, which ensures the same
view stays alive the entire time.
So, while it might take a little extra work sometimes, using identity properly increases
performance, preserves program state, and creates better animations.
www.hackingwithswift.com 51
Intentionally discarding identity
There are a handful of places where you want to intentionally discard the identity of your
views – to tell SwiftUI that two instances of a view are different no matter what it might
otherwise think.
For example, consider the following list that shuffles its contents when a button is tapped:
Button("Shuffle") {
withAnimation {
items.shuffle()
}
}
.buttonStyle(.borderedProminent)
.padding(5)
}
}
}
Using withAnimation() here will trigger the default iOS list animation, where each row will
slide from its old position to its new position. That might be what you want, but in many places
this effect is rather hard on the eye – when a single row moves this animation looks great, but
when everything moves it just causes a jumble.
52 www.hackingwithswift.com
Intentionally discarding identity
To fix this we can provide SwiftUI with an explicit identity for our list, but use a random value
for that identity so that it changes every time the view is evaluated. This means SwiftUI sees
the same structural location for the view but a different explicit identity, and so will consider
the two lists to be different. In practice, that means it will remove one and insert the other
using a default fade transition just by changing the code to this:
That immediately looks better when many rows are changing at once, but it also means we
now have complete control over how the animation happens rather than being forced to use the
default list reorder animation. So, we could make a 1-second ease-in-out animation like this:
Button("Shuffle") {
www.hackingwithswift.com 53
Layout and Identity
withAnimation(.easeInOut(duration: 1)) {
items.shuffle()
}
}
So, now we have complete control over the animation, which means you can create something
less intense as the default row slide, or perhaps something just plain different like our edge
transition. If you tap Shuffle quickly, you’ll even see multiple overlapping lists arrive and
depart in swift succession.
54 www.hackingwithswift.com
Intentionally discarding identity
Remember, discarding identity does have the downside that SwiftUI will destroy any
underlying data storage and recreate any platform views, so be careful – there is a cost to this
work, particularly when dealing with more complex views such as List.
In the simplest case, this technique is useful when you want to let the user cycle through
various options. For example, we could make a simple icon generator by selecting random
colors and SF Symbols:
www.hackingwithswift.com 55
Layout and Identity
Image(systemName: "figure.\(symbols.randomElement()!)")
.font(.system(size: 144))
.foregroundColor(.white)
}
.transition(.slide)
.id(id)
Button("Change") {
withAnimation(.easeInOut(duration: 1)) {
id = UUID()
}
}
.buttonStyle(.borderedProminent)
.padding(.bottom)
}
}
}
Just changing the id property to a new value is enough to pick a new random color, a new
random SF Symbol, and having the changes animate in smoothly – all by explicitly discarding
identity.
56 www.hackingwithswift.com
Intentionally discarding identity
www.hackingwithswift.com 57
Optional views, gestures, and
more
We all know that optionals are a core feature of the Swift language, but don’t underestimate
the usefulness of optionals in SwiftUI – you’ll find optionals are baked right into key places
for extra flexibility.
For example, if you were just to look at Xcode’s autocompletion options you would see that
the background() modifier accepts any kind of View, Shape, or ShapeStyle, but it doesn’t
accept optionals – you need to provide a concrete instance of one of those types.
However, SwiftUI is really smart here, and to see why I’d like you to try this code:
Text("Hello")
.background(Color.blue)
.onTapGesture {
print(type(of: self.body))
}
When that runs you’ll see the type is now _BackgroundModifier<Optional<Color>> – the
background of our view is a Color?, meaning that it might be there or might not depending on
the result of our random Boolean.
This is possible because Optional uses conditional conformance to become a View in its own
right. To see how, use Open Quickly to bring up the generated interface for SwiftUI by
searching for something like NavigationStack, then search in there for extension Optional.
You’ll see that SwiftUI extends the Optional enum to conform to a range of protocols where it
58 www.hackingwithswift.com
Optional views, gestures, and more
So, Optional conforms to Commands where the thing inside the optional also conforms to
Commands, etc.
www.hackingwithswift.com 59
Chapter 2
Animations and
Transitions
60 www.hackingwithswift.com
Animating the unanimatable
Almost everything can be animated in SwiftUI, although you’ll find there are quite a few
things that take a little… encouragement, shall we say?
First the easy stuff. We can trigger an explicit animation using withAnimation():
www.hackingwithswift.com 61
Animations and Transitions
}
}
Tip: Do not use implicit animations without providing the value parameter – that’s deprecated
from iOS 15 and later because it would animate every change, including device rotation.
But not everything works this way. For example, if we had several overlapping views, we
might want to animate the Z index of one view:
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(.red)
.zIndex(redAtFront ? 6 : 0)
ForEach(0..<5) { i in
RoundedRectangle(cornerRadius: 25)
.fill(colors[i])
.offset(x: Double(i + 1) * 20, y: Double(i + 1) *
20)
.zIndex(Double(i))
62 www.hackingwithswift.com
Animating the unanimatable
}
}
.frame(width: 200, height: 200)
}
}
}
That code is correct, but won’t work: the red box will jump to the front, despite the animation
request. This is because Z index can’t be animated with SwiftUI – or at least not by default.
We can make our Z index code animate with a surprisingly small change, and the technique
involved can be applied in numerous other places to animate practically anything.
The key is to create a new ViewModifier that conforms to the Animatable protocol, which
has the job of handling whatever you need in your your animation. So, we might write this:
www.hackingwithswift.com 63
Animations and Transitions
Tip: The name of the view modifier is “animatable” not “animated” – we’re providing the
ability for this change to be animated, but whether or not it actually is animated depends on
how it’s used.
While it’s possible to apply modifier structs directly to a view, it’s usually a better idea to wrap
them in a View extension to make our code easier:
extension View {
func animatableZIndex(_ index: Double) -> some View {
self.modifier(AnimatableZIndexModifier(index: index))
}
}
And now rather than using zIndex() on the view we want to change, we use
animatedZIndex() instead:
RoundedRectangle(cornerRadius: 25)
.fill(.red)
.animatableZIndex(redAtFront ? 6 : 0)
You see, all the Animatable protocol really does is give us the ability to read and write some
kind of interpolated value over time. As we’re animating between the values 0 and 6,
64 www.hackingwithswift.com
Animating the unanimatable
Animatable will send us values like 0.1, 1.35, 4.825, and so on, as it moves smoothly from 0
through to 6 based on whatever timing curve the animation is using. That’s all it does: it sends
in the interpolated value, and it’s down to us to decide what should happen to it.
In this case, those interpolated values are exactly what we want for our Z index, so that our
view moves smoothly from Z index 0 through to 6. So, when the Animatable protocol
attempts to provide some animating data for us, we just need to assign that to our index
property – add this property to AnimatableZIndexModifier now:
That’s it! If you run the code again you’ll see our animation works great.
Tip: You can just create a regular stored property called animatableData to get the same
result, but it might result in quite clumsy code.
If you’re curious, try adding a print() statement into the setter we just made so you can see
exactly what the Animatable protocol is doing:
When that runs you’ll now see all the interpolated values being passed in – it’s another feature
of SwiftUI that looks like magic, but is actually surprisingly simple internally.
Having this kind of control is particularly important if you need to support iOS versions below
16, because a number of things could not be animated in iOS 15.6 and below. For example,
animating the system font to a new size works out of the box in iOS 16 and later, but if you
need backwards compatibility it means relying on Animatable like this:
www.hackingwithswift.com 65
Animations and Transitions
need backwards compatibility it means relying on Animatable like this:
Again, it’s a good idea to create a View extension to make it easier to use:
extension View {
func animatableFont(size: Double) -> some View {
self.modifier(AnimatableFontModifier(size: size))
}
}
66 www.hackingwithswift.com
Animating the unanimatable
0.5)) {
scaleUp.toggle()
}
}
}
}
The result looks fantastic, but keep in mind that using it means SwiftUI has to create the
system font at every size increment passed in by Animatable – it’s a great effect, but it’s
easily overused. I’m hoping that Apple’s own solution from iOS 16 and later is somehow more
optimized!
www.hackingwithswift.com 67
Avoiding pain in iOS 15.6 and
below
If you need to target iOS 15.6 and below (or similar versions of macOS, tvOS, and watchOS),
there is one particular thing that isn’t animatable by default even though it seems like it ought
to be, and that’s the foregroundColor() modifier. This kind of code won’t work at all:
We could go down a complex route of making it animatable, but there is no neat solution with
this approach – you’d need to store both the before and after colors right inside the view you
want to work with, then use the values from the Animatable protocol to manually interpolate
between the RGBA values of those two colors.
Fortunately, we can cheat a little, because the colorMultiply() method is animatable. This
multiplies the original color of a view with some other color, meaning that the red value of the
original is multiplied by the red value of our other color, then the green, then the blue, and then
the alpha. If we use white as our original color, then multiplying by any other color will return
that same color because we’re multiplying each of its components by 1.
68 www.hackingwithswift.com
Avoiding pain in iOS 15.6 and below
that same color because we’re multiplying each of its components by 1.
So, if we give the text a white color we can multiply over it using the colors we’re trying to
animate between, like this:
Text("Hello, World!")
.foregroundColor(.white)
.colorMultiply(isRed ? .red : .blue)
And that will work, without having to go into the mess of trying to make something custom. If
you wanted, you could still wrap up this behavior in a neat modifier, like this:
extension View {
func animatableForegroundColor(_ color: Color) -> some View {
self
.foregroundColor(.white)
.colorMultiply(color)
}
}
Fortunately for all of us, foregroundColor() is animatable in iOS 16.0 and later.
www.hackingwithswift.com 69
Creating animated views
I said it earlier, but it bears repeating: all the Animatable protocol really does is give us the
ability to read and write some kind of interpolated value over time. This means it isn’t
restricted to ViewModifier, and actually works perfectly fine with a plain old View too.
As an example, we could make a view that knows how to animate a number between various
values – we’d start off by making something that knows how to draw some text with a specific
fraction length, like this:
Text(value.formatted(.number.precision(.fractionLength(fraction
Length))))
}
}
That’s barely doing anything, but thanks to SwiftUI the next step is trivial – how do you think
we upgrade that so it supports animation?
Simple: we just add an animatableData property to get and set value, like this:
Now we can go ahead and use it just like any other view:
70 www.hackingwithswift.com
Creating animated views
The point is that Animatable sends in whatever value should be used and it’s up to us what we
do with it – we might display it immediately, we might apply it to a bunch of other modifiers,
or perhaps we stash the values away for use later on.
I’d like you to try it yourself: try creating a TypewriterText view that accepts a string to
display, and is able to type it out using an animation.
Have a go and see how you get on! I’ll add my solution below.
www.hackingwithswift.com 71
Animations and Transitions
Button("Type!") {
withAnimation(.linear(duration: 2)) {
value = message.count
}
}
Button("Reset") {
value = 0
}
}
}
}
That works well, but we could improve the effect a little more by adding a hidden copy of our
text inside a ZStack, so that SwiftUI preallocates the right amount of space for the text:
72 www.hackingwithswift.com
Creating animated views
ZStack {
Text(string)
.hidden()
.overlay(
Text(stringToShow),
alignment: .topLeading
)
}
But we can do better! Having this typewriting effect is nice for lots of folks, but what about
folks who rely on VoiceOver, or folks who have specifically asked apps to reduce the amount
of animation they use? If we factor in both those we can make this view even better.
@Environment(\.accessibilityVoiceOverEnabled) var
accessibilityVoiceOverEnabled
@Environment(\.accessibilityReduceMotion) var
accessibilityReduceMotion
And now we can modify the body property to return two different things depending on
whether we want the animation or not:
www.hackingwithswift.com 73
Animations and Transitions
if accessibilityVoiceOverEnabled || accessibilityReduceMotion {
Text(string)
} else {
let stringToShow = String(string.prefix(count))
ZStack {
Text(string)
.hidden()
.overlay(
Text(stringToShow),
alignment: .topLeading
)
}
}
With that in place we have a great solution that works for everyone.
74 www.hackingwithswift.com
Custom timing curves
SwiftUI gives us fine-grained control over how our animation movements take place: rather
than relying on linear movements or ease-in-out, for example, we can instead create
completely custom cubic Bézier paths that match whatever acceleration and deceleration we
want.
For example, we could create a timing curve that very slowly around the center of an
animation, but bounces hard on the edges:
extension Animation {
static var edgeBounce: Animation {
Animation.timingCurve(0, 1, 1, 0)
}
Notice how I’ve added two variations of the same curve: one as a property, and one as a
method that accepts a duration. This matches the same way Apple’s own timing curves are
created – e.g. .easeIn and .easeIn(duration:) – so it makes it more natural to use our custom
curves.
With that extension in place, we can now create animations using our custom timing curve just
like we would use one of the built-in curves:
www.hackingwithswift.com 75
Animations and Transitions
Text("Hello, world!")
.offset(y: offset)
.animation(.edgeBounce(duration:
2).repeatForever(autoreverses: true), value: offset)
.onTapGesture {
offset = 200
}
}
}
A particularly common animation curve is called “ease in out back”, which is like a double
spring animation where the change goes in the wrong direction first, then moves forward
normally, then overshoots the destination, then move back to the finished value. You’ll often
see this in Apple’s own designs, such as the App Store: when you tap on one of their featured
stories in the Today tab, the image shrinks a little, then scales up to fill the screen.
extension Animation {
static var easeInOutBack: Animation {
Animation.timingCurve(0.5, -0.5, 0.5, 1.5)
}
76 www.hackingwithswift.com
Custom timing curves
Rather than try to guess the various X/Y values for your Bézier curves, a much better idea is to
use a website such as https://cubic-bezier.com that lets you drag handles around visually to
control exactly how the movement should work.
Once you’re done, that site lets you preview the movement compared to other common curves,
and provides the current parameters to input into your timing curve code – it really is the
easiest way to get the exact effect you want, and gives you lots of chance to experiment to
create some unique animation effects.
www.hackingwithswift.com 77
Animations and Transitions
78 www.hackingwithswift.com
Overriding animations
Animations can be triggered in all sorts of ways and places in SwiftUI, but we have API
available to us that helps control the way animations happen – we can inject custom
functionality into the process to get whatever specific result we’re aiming for.
Previously I showed you how we can make an Animatable view selectively disable its
animations by watching the environment, but it’s not always possible to write code to bypass
the animation in that way. In fact, a lot of the time you shouldn’t even be calling
withAnimation() unless you actually want animation to happen.
So, rather than having view modifiers try to override an animation request, we could write a
small global function to give us more control over the process, like this:
As that’s a free function, we don’t have access to the SwiftUI environment to query the current
setting for reducing motion, but UIAccessibility.isReduceMotionEnabled works just fine.
Using this approach allows us to make our intent clear: when we say withAnimation() we
mean this is a non-movement animation such as an opacity change, whereas when we use
withMotionAnimation() we mean this involves movement and therefore might need to be
skipped based on the user’s settings.
www.hackingwithswift.com 79
Animations and Transitions
That solves the problem for times when we create an explicit animation: just switch
withAnimation() for withMotionAnimation() and our function takes care of the rest. But that
doesn’t solve implicit animations like this one:
Even with withMotionAnimation() being used, our implicit animation will ignore the Reduce
Motion setting – the implicit overrides the explicit. We could fix this by adding a new modifier
that only selectively applies the animation, based on the user’s preferences:
80 www.hackingwithswift.com
Overriding animations
extension View {
func motionAnimation<V: Equatable>(_ animation: Animation?,
value: V) -> some View {
self.modifier(MotionAnimationModifier(animation: animation,
value: value))
}
}
And now we can use that to get implicit animations that automatically respect the user’s
settings:
Button("Tap Me") {
scale += 1
}
.scaleEffect(scale)
www.hackingwithswift.com 81
Animations and Transitions
That’s a big step forward, but it still only solves part of the problem: what if we need to
override the implicit animation on a case-by-case basis, rather than always overriding it? That
is, what if we want the default animation most of the time, but in one particular event – when a
particular button is clicked, for example – we don’t want it?
In this instance we need to use a transaction, which gives us control over what’s happening in
the current animation. Transactions are SwiftUI’s stores all the context for an animation that is
currently in flight, allowing it to be passed around the view hierarchy. We can create them by
calling withTransaction() then customizing the new transaction, which is effectively what
withAnimation() is doing – albeit with less code.
Button("Tap Me") {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
scale += 1
}
}
.scaleEffect(scale)
.animation(.default, value: scale)
That means we’ll get the default animation for all changes, except for those triggered by the
button tap.
This behavior is so useful that I find it best to make another global animation function to wrap
82 www.hackingwithswift.com
Overriding animations
When we use withAnimation() we are effectively creating a new transaction with whatever
new animation we want, so I think creating this similar withoutAnimation() function is a
great counterpart.
That global function works great for the times when you want to blanket disable animations,
but transactions let us go further: what if we have an implicit animation that we want to
override – we want a different animation to happen, rather than just skipping animations
entirely? Transactions are perfect here, because if set disablesAnimations to true we still get
to apply our own animation in its place.
Once again, this kind of functionality is best wrapped up in another global function for easier
access:
www.hackingwithswift.com 83
Animations and Transitions
That has a default implicit animation, but we’re explicitly overriding it with a 3-second linear
animation – we get the implicit animation most of the time, but an explicit override for the
times we need it.
But there’s one more situation you’re likely to encounter: what happens if part of your view
hierarchy wants to override an animation?
This is another place where transactions solve the problem for us, but this time they are applied
differently: we don’t want to create a new transaction to replace our global transaction, but
instead we want each view to selectively override just their part of the transaction.
This is done using the transaction() modifier, which provides us with an inout transaction
object to modify – we can just go ahead and modify it in place, and it will be used for any
animation transactions that apply to this view.
84 www.hackingwithswift.com
Overriding animations
Important: Apple very strongly recommends against using the transaction() modifier on
container views, because it could generate huge amounts of work. Instead, use it on leaf views
– views that don’t have any children.
To demonstrate this modifier in action, here’s an example view that creates a grid of circles in
either red or blue:
That view has no idea about animations – we haven’t added them anywhere, implicitly or
explicitly, but thanks to the way SwiftUI works we can trigger an animation externally like
this:
Spacer()
www.hackingwithswift.com 85
Animations and Transitions
Button("Toggle Color") {
withAnimation(.easeInOut) {
useRedFill.toggle()
}
}
}
}
}
Earlier I said that using withAnimation() effectively creates a new transaction with whatever
new animation we want, and here’s where that behavior becomes important: that code says
“start a new transaction, inside that set a Boolean to be true, which will cause all the circles to
turn red.” That color adjustment will take place with our custom transaction in place, which
means the circles will change in a particular way.
Now, even though all those circles in the grid have no idea an animation is taking place, they
can still exert control over any animations that do take place – they can examine or override
any animation that affects them.
For example, we could say that our circles don’t actually care what animations they have, as
long as they start with a delay:
Circle()
.fill(useRedFill ? .red : .blue)
.frame(height: 64)
.transaction { transaction in
transaction.animation =
transaction.animation?.delay(Double(i) / 10)
}
With that in place, any animation that happens to the circle will now happen with a delay – we
haven’t touched the rest of the animation, just that one small part.
86 www.hackingwithswift.com
Overriding animations
haven’t touched the rest of the animation, just that one small part.
The end result is quite beautiful, I think: even though the circles have no idea what kind of
animation if any is taking place, we’ve now made them change color in a wave, and you can
even press the Toggle Color button multiple times to move smoothly between the two states.
www.hackingwithswift.com 87
Advanced transitions
SwiftUI’s transitions system allows us to customize the way views are inserted or removed,
but because the built-in selection is pretty tame you’d be forgiven for thinking the transitions
system isn’t that capable. Well, the truth is that transitions can do pretty much whatever the
heck you want with your views: you can insert a whole range of new views around whatever
you’re transitioning, create local state, add complex animations, and much more.
To demonstrate this I want to recreate a small but complex animation: the “heart” animation
from Twitter. When you like a tweet, several things happen:
Honestly, it’s best if you just try favoriting something on Twitter a few times – it’s a very fast
animation, but there’s a lot going on.
We can recreate this entirely with SwiftUI’s transitions, meaning that we get a simple, reusable
way of adding this kind of animation to any view that is being shown.
As this involves quite a few simultaneous animations, we’re going to start small and build our
way up.
Tip: When working with complex animations like this one, I recommend you go to the
simulator’s Debug menu and select Slow Animations so you can see exactly what’s happening.
88 www.hackingwithswift.com
Advanced transitions
First, we’ll create a simple view modifier that stores three properties and renders its content
unchanged. The properties are:
• The speed the animation will take place. This will be used in various places as both
animation duration and delay, so having it one central place makes our code easier to
follow.
• The color to render the effect. This will be used to render the circle and confetti.
• How big to draw the confetti. This is helpful if the user is bringing in a larger view, where
chunkier confetti look much better.
Again, that body() method does nothing right now – it just renders whatever it’s given, which
is fine given that we’re just setting up a skeleton.
www.hackingwithswift.com 89
Animations and Transitions
To make that easier to use, we’re going to extend AnyTransition with both a property and a
method, just like SwiftUI’s own built-in transitions. The property will force default values
of .blue and 3 for color and size, whereas the method will allow the user to customize them as
needed.
extension AnyTransition {
static var confetti: AnyTransition {
.modifier(
active: ConfettiModifier(color: .blue, size: 3),
identity: ConfettiModifier(color: .blue, size: 3)
)
}
90 www.hackingwithswift.com
Advanced transitions
And finally we can create a simple test view that renders an SF Symbol using three fonts, so
you can see how it looks at various sizes:
www.hackingwithswift.com 91
Animations and Transitions
}
}
Okay, that’s our set up code done. You’re welcome to run it if you want but you won’t be
impressed – our view modifier really does nothing at all, so it will just flip between the unfilled
and filled heart symbols.
That skeleton does give us a great place to build on, though, because now we can start to build
up the animation step by step. If you remember, the first step in the animation is creating a
circle that grows out from the center, meaning that it starts invisibly small and grows to fill all
the available space and then some – it needs to be larger than the icon itself, not least because
the final filled heart icon uses a spring animation and so overshoots its target size a little.
To make this circle animation, we’ll start by adding a new property to ConfettiModifier to
track the circle’s growing size, starting at a very low value:
Important: SwiftUI will complain loudly if you try to scale something to 0.0, so small values
like 0.00001 are preferred.
Now I’d like you to add several modifiers to the content line in body():
• We’ll use hidden() to make the actual view we’re transitioning invisible, but still reserve
space for it. Remember, the finished heart icon animates in separately at the very end, so
we need to get the actual view out of the way.
• We’ll use padding(10) to make our circle area bigger than the view we’re transitioning, so
we have space to overshoot.
• We’ll use overlay() to render the circle.
• We’ll use padding() a second time, this time with a value of -10.
• Finally, we’ll use onAppear() to start an animation to make circleSize 1.
Now, you might wonder why padding() is in there twice, and the answer is simple: we need to
92 www.hackingwithswift.com
Advanced transitions
add some padding before the overlay in order that the overlay is able to take up more space
than our transitioning view, but we don’t want that padding to stick around after the overlay –
we don’t want it to move over views away from our buttons. So, whatever padding we add
before the overlay needs to be removed after the overlay, to keep things balanced.
content
.hidden()
.padding(10)
.overlay(
// circle here
)
.padding(-10)
.onAppear {
withAnimation(.easeIn(duration: speed)) {
circleSize = 1
}
}
Now, I left out the important overlay code because it deserves its own explanation. You see, if
we place a circle into our overlay it will automatically take up all the available space – it will
automatically be the same size as our transitioning view, plus the 10 points of padding.
This circle needs to be filled in with a color, but the second step in this animation is to make
the circle shrink from the inside out. That is, once it has grown to its full size, it starts to
become hollow, and shrinks thinner and thinner until it has finally disappeared.
Getting this effect means we need to stroke our circle rather than fill it, and in particular we
need to make sure the entire stroke is inside the circle, and also that the stroke width exactly is
exactly half the available space so that it is always filled in completely – or least until we come
to implement the hollowing out in the second animation step.
www.hackingwithswift.com 93
Animations and Transitions
Getting exactly half the available space means using a GeometryReader, and because we’ll be
adding colorful confetti later we’ll place that inside a ZStack. So, replace the // circle here
comment with this:
ZStack {
GeometryReader { proxy in
Circle()
.strokeBorder(color, lineWidth: proxy.size.width / 2)
.scaleEffect(circleSize)
}
}
Go ahead and run the app again and you should see our first animation step is done. I know, I
know, it’s pretty dull, but things get faster from here on!
The second animation step is where we need to hollow out our circle. This is actually pretty
94 www.hackingwithswift.com
Advanced transitions
straightforward because we’re using strokeBorder(): if we reduce the lineWidth of our stroke
it will automatically hollow out our circle for us.
So, first add a new property to track how much of the stroke we want to draw:
Second, modify your strokeBorder() modifier to multiply the line width by that multiplier:
And finally add another withAnimation() call after the previous one, setting strokeMultiplier
to a very small value:
withAnimation(.easeOut(duration: speed).delay(speed)) {
strokeMultiplier = 0.00001
}
Notice how I’ve made that delay by speed seconds, so that it waits until the previous
animation has completed before starting.
Now if you run the app you’ll see our animation is starting to come together!
www.hackingwithswift.com 95
Animations and Transitions
The third step of our animation is to make some colorful confetti fly out from the edges of the
circle. These have fairly precise movements: they start near the circle edge, move out a small
amount, then disappear by shrinking away to nothing.
We can get a similar effect by adding three new properties to ConfettiModifier: one to track
whether the confetti should be visible, one to track how far the confetti has moved, and a third
to track the scale of the confetti. We’ll measure movement relative to the size of our
GeometryReader, where 1.0 will mean “the very edge of the view”.
Those need to be animated to alternative values, namely false, 1.2, and 0.00001, which means
96 www.hackingwithswift.com
Advanced transitions
adding two more withAnimation() calls alongside the others. These don’t need to be put in
any specific order because they don’t depend on each other, but it’s generally a good idea to
structure them in the order they will execute.
Again, note the careful use of delays to make sure these happen at exactly the right time.
Drawing the confetti particles takes more work, and in fact this the most complicated part of
the whole transition because there are lots of very precise modifiers. To make things easier to
follow, I’ll break this down into small parts, starting with something easy – add this inside the
GeometryReader, below the circle code:
ForEach(0..<15) { i in
Circle()
.fill(color)
// more modifiers to come
}
First, we need to give these circles a frame, otherwise they’ll all be huge. We already added a
size property, but if we pass our i loop variable through sin() we’ll be able to modulate the size
just a little – some confetti will be a bit bigger and some a bit smaller.
www.hackingwithswift.com 97
Animations and Transitions
Next, we need to scale our confetti up or down depending on the value of confettiScale, which
is an easy one:
.scaleEffect(confettiScale)
Moving on, we need to move our confetti outwards as the effect animates. This means pushing
our circles outwards by our radius (half the proxy width), multiplied by whatever is in
confettiMovement. When the view is first created that will put them at 70% of the radius
because confettiMovement has an initial value of 0.7, but our animation moves them out to
120% of the radius so they fly outwards a good distance.
That works, but’s a bit dull because every confetti piece would move exactly the same
distance. To make things a bit more varied, we’re going to add some extra movement to every
other piece, like this:
It’s a small difference, but when you see the final effect I think you’ll appreciate it!
At this point we’ve moved all our confetti pieces out to the side of our circle, but they are all
on the same side. So, our next step is to rotate the circles by 24 times i so they spread out
across the entire circle, and we’re using 24 because we have 15 circles being created – 24 x 15
is 360, which covers all the angles.
98 www.hackingwithswift.com
Advanced transitions
.rotationEffect(.degrees(24 * Double(i)))
We have just two more modifiers to go here, but the first one might be a bit confusing: we’re
going to use offset() again.
The reason for this should become clear if you break down what’s happening:
1. When we create views inside a GeometryReader they are placed in the top-left corner.
2. Our first offset() pushed our confetti views half way across the GeometryReader
horizontally.
3. Our rotationEffect() modifier caused those views to rotate around their origin, which again
is the top-left corner.
4. So our confetti views are now fanned out in a circle around the top-left corner of our
GeometryReader.
5. We want them to be centered, which means offsetting them again, this time by half the
width and height of our proxy.
Now, even though the confetti views are small, for real accuracy here we need to subtract half
our confetti size from these offsets, because we want to make sure the particles are centered
rather than positioned from their top-left.
Hopefully you can see why that’s needed, but if not try commenting out the modifier once
you’ve seen it working – when it’s not active it will be immediately obvious what the problem
is!
The final modifier is there to make sure our confetti stays hidden until we say we’re ready for
it, like this:
.opacity(confettiIsHidden ? 0 : 1)
www.hackingwithswift.com 99
Animations and Transitions
That completes the confetti work – if everything has gone to plan your finished code should
look like this:
ForEach(0..<15) { i in
Circle()
.fill(color)
.frame(width: size + sin(Double(i)), height: size +
sin(Double(i)))
.scaleEffect(confettiScale)
.offset(x: proxy.size.width / 2 * confettiMovement +
(i.isMultiple(of: 2) ? size : 0))
.rotationEffect(.degrees(24 * Double(i)))
.offset(x: (proxy.size.width - size) / 2, y:
(proxy.size.height - size) / 2)
.opacity(confettiIsHidden ? 0 : 1)
}
Go ahead and try it out and see what you think! I think the default size of 3 looks about right
for the smaller buttons, but the bigger one would probably benefit from a custom size.
100 www.hackingwithswift.com
Advanced transitions
Anyway, we still have one last step to write in order to complete the Twitter animation: our
filled heart icon needs to spring out from the center, bouncing to its final position.
Just like our other work, this means adding a property to track its movement, placing a view
somewhere in our layout, then animating it. Start with this new property:
Again, using a value of 0.00001 rather than 0.0 avoids warnings from SwiftUI.
The view for this is simple, because it’s just the content parameter that was passed into the
method, albeit with a scale effect so we can animate it. Add this after the GeometryReader
but still inside the ZStack:
content
.scaleEffect(contentsScale)
www.hackingwithswift.com 101
Animations and Transitions
And now to finish off the whole effect we need to add one final withAnimation() call next to
the others. I said earlier that it’s a good idea to structure your animation code in the order it
executes, but it’s a bit trickier here because we want a spring animation rather than a specific
duration. So, if I were you I’d place this in the middle of the four existing animations – after
the strokeMultiplier animation but before the confettiIsHidden animation – because it has a
delay that makes it fit into that spot well.
Now run the project again and see what you think! It’s not identical to Twitter’s animation, but
it’s close enough that you’d be hard pressed to tell the difference unless you zoomed in close
and compared the two side by side.
102 www.hackingwithswift.com
Advanced transitions
Yes, it did take quite a bit of code, but that’s only because there are numerous overlapping
animations taking place and I’ve tried to make it fairly accurate to Twitter’s original.
Hopefully it’s given you a good idea of just how powerful SwiftUI’s transitions can be – with
the ability to insert completely custom views and animations, there’s really no limit to what
they can do.
Want to go further?
At this point you might well have had enough of transitions, but if you’re keen to take this to
the next level there is one small but important change we can make: rather than forcing our
transition to use a color, we can in fact let it use any kind of shape style including gradients.
First, we need to make the modifier generic over some kind of ShapeStyle:
www.hackingwithswift.com 103
Animations and Transitions
Second, we need to change its color property to be of type T rather than Color:
var color: T
And third we need to adjust the confetti() method inside our AnyTransition extension so that
it’s also generic over some kind of ShapeStyle:
And that’s it – we can now transition using a much wider variety of styles. For example, rather
than using .red for the color, we can now use this:
.transition(.confetti(color: .red.gradient))
.transition(.confetti(color: .angularGradient(colors:
[.red, .yellow, .green, .blue, .purple, .red], center: .center,
startAngle: .zero, endAngle: .degrees(360))))
104 www.hackingwithswift.com
Advanced transitions
www.hackingwithswift.com 105
Chapter 3
Environment and
Preferences
106 www.hackingwithswift.com
The environment
When you apply a modifier to a view, we are most of the time creating a new view that wraps
the original view to add some extra behavior or styling – this is something I’ve said a few
times now, but it matters!
This isn’t always the case. One common example is Text, where there are a whole batch of
modifiers we can apply directly to some text without creating wrapped views, like this:
Text("Tap")
.font(.title)
.foregroundColor(.red)
.fontWeight(.black)
.onTapGesture {
print(type(of: self.body))
}
When you tap that text in the simulator, you’ll see its type is still just Text – it silently just
absorbs all the modifiers into itself. This is what allows us to create complex text with various
fonts and colors, then use operator overloading to bring it all together into a single Text view.
You can see this for yourself in the SwiftUI interface file – search for “internal var modifiers”,
and you’ll see that all Text views store an array of enum cases with associated values for their
modifiers. (If you forgot the xed command to use, and the fix in case you have problems,
please see the introduction to this book!)
www.hackingwithswift.com 107
Environment and Preferences
Tip: This is exactly how Text views have different natural sizes when changing the font – the
font gets absorbed directly into the view, and is used as part of its size calculations.
However, there’s a whole batch of modifiers that are more complex, because they propagate
changes downwards into child views. SwiftUI doesn’t mark these out very clearly, or indeed at
all, but you can see them in action if we modified our code to this:
VStack {
Text("Tap")
}
.font(.title)
.onTapGesture {
print(type(of: self.body))
}
Now we’re applying title() to the VStack rather than directly to Text, and the resulting type of
our view will be very different – you’ll see _EnvironmentKeyWritingModifier in there.
First, this dual behavior of font() is possible because of Swift’s approach to overload
resolution, which really boils down to “the most constrained wins.” In the case of font(), if you
look in the SwiftUI interface file you’ll see func font is in there twice: once on Text, and once
108 www.hackingwithswift.com
The environment
on View. Because Text is the more constrained of the two (it’s one specific struct rather than a
whole group of structs that conform to a protocol), when we call font() directly on a Text view
we’ll get Text.font() rather than View.font().
Second, you can see exactly why _EnvironmentKeyWritingModifier comes back in our type
when you look at the font() method attached to View rather than Text: it’s marked
@inlinable, which means Swift has the option of replacing a call to this View.font() method
with the actual body of the method – it’s able to copy the code from the method right into our
view at compile time. Obviously this requires Swift to have access to the code to copy, which
is why we can see exactly what SwiftUI is doing here:
Tip: Search the SwiftUI interface for return environment(\ to see other instances of this
behavior.
So, we get two different results for font() depending on where it’s called: for Text views it
gets absorbed into an internal array of enum values, but for all other views it is merely
syntactic sugar that silently gets converted into the following:
VStack {
Text("Tap")
}
.environment(\.font, .title)
.onTapGesture {
print(type(of: self.body))
}
The question is: why? Understanding the answer is the key to understanding the environment
in SwiftUI: this approach lets a view modifier flow downwards through all the child views of
www.hackingwithswift.com 109
Environment and Preferences
our VStack, rather than just being applied to a single view – we can adjust the font of
everything inside the VStack at once, even without the view internally realizing it was
happening.
The power of SwiftUI’s environment is that it flows downwards to every view contained in
wherever you apply it, but views only need to read a value if they care about it.
To demonstrate this, we could make a simple TextField wrapper that understands the concept
of required fields by showing a small red asterisk next to required fields.
This process takes at least two steps for every new environment key you want to add. First, we
make a new struct that conforms to the EnvironmentKey protocol, which requires that
provide a default value for times when there is nothing in the environment:
Second, we make an extension on EnvironmentValues telling the system how to read and
write our setting from the environment:
extension EnvironmentValues {
var required: Bool {
get { self[FormElementIsRequiredKey.self] }
set { self[FormElementIsRequiredKey.self] = newValue }
}
}
Now we can go ahead and use it. For our TextField wrapper this means using
@Environment(\.required) to read the current required state in our environment, then
creating the view as normal:
110 www.hackingwithswift.com
The environment
if required {
Image(systemName: "asterisk")
.imageScale(.small)
.foregroundColor(.red)
}
}
}
}
Now we can go ahead and the new RequirableTextField view anywhere we want it, sending
in the environment value for \.required as needed:
www.hackingwithswift.com 111
Environment and Preferences
Earlier I showed you how the View.font() modifier is nothing more than a wrapper
around .environment(\.font), and honestly this is good practice because it makes our code
easier to read. In this case it would mean adding a new View extension like this:
extension View {
func required(_ makeRequired: Bool = true) -> some View {
environment(\.required, makeRequired)
}
}
With that in place, we can now just called required(), like this:
Tip: As you can see, making the Boolean parameter have a default value of true makes for
much more natural use at the call site – saying required() is the same as required(true).
Now, as we’re applying required() directly to our custom text field, the environment approach
might seem overkill – why not just pass it directly into the RequirableTextField initializer?
112 www.hackingwithswift.com
The environment
Now we’re making the whole form required at once, which flows the environment key
downwards into each text field automatically – they could even have been in a different
subview entirely, but would still have been able to access the environment data.
www.hackingwithswift.com 113
Environment and Preferences
I’d like you to try making a custom environment key now: can you create one that stores a
stroke width for all the shapes you want to draw?
Have a go and see how you get on! I’ll add my solution below.
extension EnvironmentValues {
var strokeWidth: Double {
get { self[StrokeWidthKey.self] }
set { self[StrokeWidthKey.self] = newValue }
}
}
114 www.hackingwithswift.com
The environment
extension View {
func strokeWidth(_ width: Double) -> some View {
environment(\.strokeWidth, width)
}
}
And then set that value at some higher point in the environment, like this:
www.hackingwithswift.com 115
Environment and Preferences
}
}
Tip: We’ll be using StrokeWidthKey and CirclesView in the next chapter, so stash your code
somewhere safe and put mine in its place for easier reference.
116 www.hackingwithswift.com
@Environment vs
@EnvironmentObject
We can write any kind of data into environment keys, but the environment never watches for
changes in observable objects. This means if you try to store a class in there then update it, the
environment won’t know to update any views that are watching.
In practice, this makes @Environment more suited to value type data, as compared to
@EnvironmentObject, which is specifically designed to store class instances – the clue is
right there in the name.
There are two compelling reasons why, where possible, you should aim to use simple
environment keys rather than passing in environment objects.
The first is simple: the EnvironmentKey protocol requires that we provide a default value for
any custom keys we create, whereas environment objects can be missing entirely – and will
trigger a hard crash in your code when this happens. Yes, in theory this is the kind of thing we
should spot in development, but “should” in software development really means “might”, so
why leave things up to chance?
The second is a little more complex: when an observable object announces that it has changed,
SwiftUI makes all views that use it get refreshed. That sounds straightforward, but it has an
important impact for times when we are publishing lots of data, only some of which views
might care about.
To demonstrate this, we could add another custom environment key alongside the
StrokeWidthKey we made in the previous exercise, this time to store the font that should be
used for title text:
www.hackingwithswift.com 117
Environment and Preferences
extension EnvironmentValues {
var titleFont: Font {
get { self[TitleFontKey.self] }
set { self[TitleFontKey.self] = newValue }
}
}
extension View {
func titleFont(_ font: Font) -> some View {
environment(\.titleFont, font)
}
}
Now we can send both values into the environment, like this:
Button("Default Font") {
titleFont = .largeTitle
}
118 www.hackingwithswift.com
@Environment vs @EnvironmentObject
Button("Custom Font") {
titleFont = TitleFontKey.defaultValue
}
}
.strokeWidth(sliderValue)
.titleFont(titleFont)
}
}
That works great: the stroke width in CirclesView changes with the slider, and the font style in
ContentView changes as the buttons are pressed.
I’d like to add one more line of code so you can see what’s going on – modify the body
property of CirclesView to this:
www.hackingwithswift.com 119
Environment and Preferences
return ForEach(0..<3) { _ in
Circle()
.stroke(.red, lineWidth: strokeWidth)
}
}
That prints out a message every time body is called, which is helpful here because it lets us run
the project back and see the message being printed again and again as we drag around the
slider. But, importantly, it won’t be printed when pressing the buttons: those also change the
environment, but SwiftUI knows CirclesView doesn’t actually use the titleFont environment
key so it doesn’t need to reinvoke body.
So, that’s how environment keys work, but what happens if we had used an environment
object here instead? To find out, we would start by making some kind of class to store our two
values together as theme data:
In CirclesView we aren’t going to watch for precise keys, but instead we’ll expect to receive a
whole environment object of theme data:
return ForEach(0..<3) { _ in
Circle()
.stroke(.red, lineWidth: theme.strokeWidth)
120 www.hackingwithswift.com
@Environment vs @EnvironmentObject
}
}
}
And now in ContentView we would make an instance of that using @StateObject, the inject
it into the environment:
Button("Default Font") {
theme.titleFont = .largeTitle
}
Button("Custom Font") {
theme.titleFont = TitleFontKey.defaultValue
}
}
.environmentObject(theme)
}
}
When you run the project now you’ll see an important difference: every time titleFont is set
our "In CirclesView.body" message is printed, even though the CirclesView doesn’t care
www.hackingwithswift.com 121
Environment and Preferences
about it. This generates significantly more work for our views, with no actual benefit at all –
the body property is even reinvoked if titleFont is set to its existing value!
From a SwiftUI perspective this behavior makes absolute sense: the class is sending a change
notification, so SwiftUI doesn’t really have a way of checking exactly what changed inside the
view – maybe strokeWidth also changed as a result of titleFont changing.
Try to think of it like this: every time you make a view use an @ObservedObject or an
@EnvironmentObject, you are effectively creating a dependency on that data. It doesn’t
matter if the actual body property doesn’t change as one of the @Published values changes,
because if you recall Swift doesn’t perform tree diffing.
So, when you bring together the extra safety of always having a default value and the extra
performance of skipping unnecessary work, I hope you can see why using environment keys
are preferable where possible!
Of course, as with many things there is a middle ground: you can store your data as a shared
theme, then expose it using environment key. This works when you want to be able to read and
write the data in one object, but you’re still keen to avoid surprise crashes from missing data.
Using this approach we might abstract our theme information into a protocol, like this:
protocol Theme {
var strokeWidth: Double { get set }
var titleFont: Font { get set }
}
We can then make structs adopting that protocol, based on whatever theme requirements we
have:
122 www.hackingwithswift.com
@Environment vs @EnvironmentObject
Next, we’re going to wrap that in a class that is able to publish changes as the theme updates.
Apps only ever have one theme active at a time, so we could implement this as a singleton:
Now we can expose all that to the environment, focusing only on the internal Theme struct:
extension EnvironmentValues {
var theme: any Theme {
get { self[ThemeKey.self] }
set { self[ThemeKey.self] = newValue }
}
}
We need to expose this to our views in a natural way, but we can’t just make a simple View
extension this time because we need to actually watch the ThemeManager for changes. We
don’t own this theme manager object because we’re using a singleton, so a simple
@ObservedObject is the right choice:
www.hackingwithswift.com 123
Environment and Preferences
extension View {
func themed() -> some View {
modifier(ThemeModifier())
}
}
With that all done, we can switch ContentView over to using @ObservedObject for its
theme manager, so it’s able to read and write data:
That means using theme.activeTheme everywhere, because now we want to modify the theme
struct directly. Once that’s done, add a themed() modifier to ContentView so the active theme
is sent into the environment.
The interesting part is in CirclesView, where now we can watch the environment key rather
than using @EnvironmentObject:
return ForEach(0..<3) { _ in
Circle()
124 www.hackingwithswift.com
@Environment vs @EnvironmentObject
You’ll see that still triggers when the stroke width changes or when the font changes, but
doesn’t change when the font is changed to its existing value – SwiftUI is smart enough to
discard that.
But we can get even better: because @Environment uses a key path rather than always
accepting a whole observable object like @EnvironmentObject does, we can actually tell
SwiftUI we want access to only part of the theme, which means only that part of it will be a
dependency for this view:
return ForEach(0..<3) { _ in
Circle()
.stroke(.red, lineWidth: strokeWidth)
}
}
}
And now SwiftUI will only reinvoke the body property when that one specific value changes –
we’re back to where we were originally in terms of performance, while also benefiting from
having the environment object available elsewhere if needed.
This approach obviously takes more work, but it gives us the ability to synchronize changes
everywhere with some kind of theme control panel, but also means we get the guaranteed
www.hackingwithswift.com 125
Environment and Preferences
126 www.hackingwithswift.com
Overriding the environment
As we’ve seen, SwiftUI’s environment flows down through our views, which allows parents to
set some data for their children that can then be read out as needed.
For example, we might create a simple welcome view for our app:
That works well enough, but what if we wanted to customize the font for the SF Symbols
image we’re using? Maybe our designer wants that to be much bolder, so the image stands out
more clearly.
www.hackingwithswift.com 127
Environment and Preferences
Image(systemName: "sun.max")
.font(.largeTitle.weight(.black))
But now we’ve introduce a problem: that font will override whatever comes in from the
environment, so if we later changed ContentView to use .font(.headline) instead we’ll have a
mismatch – our symbol will use a large title, whereas the text below will use a headline font.
In this situation, the second font() modifier isn’t really what we meant – we don’t want to
force a wholly new font, we just want to bold up whatever font we were asked to use. SwiftUI
has a wholly separate modifier for this, called transformEnvironment(): it is able to
transform any one specific environment key somehow, and passes us an inout reference of
whatever the current value is.
Image(systemName: "sun.max")
.transformEnvironment(\.font) { font in
font = font?.weight(.black)
}
128 www.hackingwithswift.com
Overriding the environment
This approach is really similar to the transaction() modifier: any kind of font applied to this
image will automatically be transformed using our custom closure.
www.hackingwithswift.com 129
Preferences
We’ve seen how SwiftUI’s environment flows downwards, but sometimes you want
information to flow upwards too – to send data from a child view upwards to its ancestor
views. In SwiftUI this is done using preferences, and the canonical example of this in action is
the navigationTitle() modifier:
NavigationStack {
VStack {
Image(systemName: "sun.max")
Text("Welcome!")
}
.navigationTitle("MyApp")
}
In that code, the VStack describes its navigation title, but that data flows upwards to the
NavigationStack containing it. This makes sense from a UI perspective, because of course the
navigation stack can push and pop views freely, and it needs to be able to update itself to show
the titles of each of those views.
Just like the environment, preferences flow upwards continuously rather than just stopping at
130 www.hackingwithswift.com
Preferences
the first container. In our simple ContentView code, this means we can put navigationTitle()
on a view inside the VStack, if we wanted:
NavigationStack {
VStack {
Image(systemName: "sun.max")
Text("Welcome!")
.navigationTitle("MyApp")
}
}
That will give exactly the same result – the title preference just flows upwards until it’s used.
Of course, that might set off some alarm bells: what happens if we have multiple navigation
titles? We can find out easily:
NavigationStack {
VStack {
Image(systemName: "sun.max")
.navigationTitle("Image")
Text("Welcome!")
.navigationTitle("Text")
}
.navigationTitle("VStack")
}
As you’ll see when that code runs, the navigation view just picks the first one it finds – it will
show “Image” as its title.
This kind of preference system is open to us to use as needed, although you should keep in
mind that having data flowing freely both downwards and upwards might result in spaghetti
code.
www.hackingwithswift.com 131
Environment and Preferences
It’s not identical, because we get to decide how the single value is selected – maybe we choose
the first one like navigationTitle(), or maybe we combine values together somehow.
Enough talk: let’s try and implement a preference key ourselves, which will let a child view
report its size upwards to containers.
The first step is to create a new struct that conforms to the PreferenceKey protocol. This
requires us to provide a default value for the preference, just like EnvironmentKey, but now
we also need to provide a reducer function – code that chooses which value to use, when
several come in.
In our case, we want to track the width of some view, but if we get multiple widths coming in
we’ll just track the last one:
Now we can make a view that sets a value for that preference, like this:
132 www.hackingwithswift.com
Preferences
That changes its width whenever it’s tapped, which is helpful so you can see what’s
happening.
Finally, we need to place that view somehow, and watch for changes to its preferences. This
watching is done using the onPreferenceChange() modifier, which runs code of our choosing
whenever some specific preference data changes, like this:
That code works great: you can run the project, then tap the red square to see its width adjust
www.hackingwithswift.com 133
Environment and Preferences
Of course, rather than just displaying the value, we can put it to use somehow. For example,
we could use the value to set the widths of other, unrelated views, like this:
Text("100%")
.frame(width: width)
.background(.red)
Text("150%")
134 www.hackingwithswift.com
Preferences
Text("200%")
.frame(width: width * 2)
.background(.blue)
}
.onPreferenceChange(WidthPreferenceKey.self) { width =
$0 }
.navigationTitle("Width: \(width)")
}
}
}
Or we can add multiple resizing views just fine, thanks to the reducer we wrote:
www.hackingwithswift.com 135
Environment and Preferences
VStack {
SizingView()
SizingView()
SizingView()
}
.onPreferenceChange(WidthPreferenceKey.self) { width = $0 }
.navigationTitle("Width: \(width)")
We made the reduce() method always use the final value it’s given, so when that code runs
only the third SizingView will have its width reflected in the navigation title. Of course, it
doesn’t have to be that way: we could make our reducer sum the preferences instead, like this:
Now the final value for our preference will be the total of all three widths, and will
automatically adapt when any of the three changes.
136 www.hackingwithswift.com
Preferences
Even better, if you wanted to mimic the “first preference only” approach of navigationTitle(),
it takes literally zero code:
Because that never calls nextValue(), it means “you give me the first value and the next one,
but I don’t care – do nothing with them.”
www.hackingwithswift.com 137
Anchor preferences
You’ve seen how preferences allow us to send data from a child view up to its ancestors, and
how it’s up to us to decide what to do – if anything – with that information. Well, SwiftUI
provides a handful of specialized preference modifiers that are specifically aimed at making it
easier to share sizing data and make use of it easily.
To demonstrate this, we’re going to create a simplified copy of part of the Airbnb app: at the
top of the app’s Explore tab there are some options you can select, and whichever one is
selected shows a line underneath. Sure, we could do this by giving every icon an underline that
gets shown or hidden depending on the selection status, but the Airbnb app uses just one line
that moves around and resizes based on the category selection.
Solving this problem will demonstrate not only how the more advanced preferences work, but
you’ll also see how preference data can be more complex – it’s definitely a fun problem to
tackle.
138 www.hackingwithswift.com
Anchor preferences
First, we can define what one category in our app looks like. We’ll provide two properties
here: one for the identifier, which will be things like “Beach”, “Golfing”, or “Tropical”, and
one for the SF Symbol that will be used to add an icon. We’ll make this struct be Identifiable
so we can loop over arrays of them in SwiftUI, and also Equatable so we can compare one
category to another. Start with this code:
Next will be the preference data we want to share. Previously this was a simple number, but
this time I want to share a custom struct containing two pieces of information: the category that
it refers to, and an anchor. Anchors are opaque geometry stores, which means they can store a
reference to some position and size on the screen but we can’t read it out directly because it
wouldn’t be useful. Fortunately, SwiftUI knows how to read them for us, and in doing so
automatically resolves the anchor’s geometry into coordinates that are useful for us.
If that sounds fuzzy, relax; it will make sense in a moment. For now, add this second struct to
store the category and an anchor storing its geometry data:
Next we’re going to add a third struct that conforms to PreferenceKey. This is similar to the
preference key we wrote for SizingView, except now we’re going to send back an array of
data all at once – we’ll collect whatever array of CategoryPreference values we’re given and
add them into a collection of all such values
www.hackingwithswift.com 139
Environment and Preferences
That completes all the underlying data we need for our work, so now we need two SwiftUI
views to render it all: one to handle a single category button on the screen, and one to render
all the buttons plus an underline and whatever else we want.
First, the category button. This will be given the category to show, along with a binding that
will store which category is currently selected. This binding is important, as it allows our
category button to adjust external state – to say “my category was selected” when it is tapped.
140 www.hackingwithswift.com
Anchor preferences
.accessibilityElement()
.accessibilityLabel(category.id)
}
}
We’ll come back to that in a moment, but first I want to create an initial version of
ContentView, which has the job of showing several category buttons in a HStack. We’ll add
more to this shortly, so I’ll also add a VStack where we can add extra things later, but the
important part for now is that it has an array of categories and a single piece of state that stores
the selected category – that’s what gets passed into CategoryButton as a binding.
let categories = [
Category(id: "Arctic", symbol: "snowflake"),
Category(id: "Beach", symbol: "beach.umbrella"),
Category(id: "Shared Homes", symbol: "house")
]
www.hackingwithswift.com 141
Environment and Preferences
At this point we have something fairly unimpressive: we’ve created the data model for our
preferences, and also the views to show categories, but we haven’t actually linked them
together. To do this means introducing two new modifiers: one to send the value upwards as a
preference, and one to read the array of those values back out once they have been through our
reducer function.
Now, previously we used the preference() modifier for sending a preference up to ancestors,
but here we’re going to use a specialized preference specifically for working with geometry.
This modifier is called anchorPreference() and takes three parameters:
1. The preference key you want to send. For us that will be CategoryPreferenceKey.self, just
like we’d have with a simple preference.
2. What part of the geometry we want to send. Perhaps you might want to send just the
leading edge, for example, but here we’re going to request .bounds to get the whole frame
wrapped up.
3. A transformation function that accepts an Anchor containing our bounds, and needs to
convert that into whatever input your preference key expects. If you remember, we made
ours work with CategoryPreference arrays, so we’ll convert the anchor into an array
containing one CategoryPreference instance.
.anchorPreference(key: CategoryPreferenceKey.self,
value: .bounds, transform: { [CategoryPreference(category:
category, anchor: $0)] })
So, we’re telling SwiftUI we want to express an anchor preference for the
CategoryPreferenceKey, that it needs to use the bounds of the button we attached the
preference to, and we want to receive that bounds as an anchor and place it inside a
CategoryPreference object.
142 www.hackingwithswift.com
Anchor preferences
That sets the category preference key for each button, but we still need to add code to read the
preference and act on it. This is where the second new modifier comes in: rather than just using
onPreferenceChange() to read values coming in, we’re going to use
overlayPreferenceValue(). This has the job of reading preferences and converting them into
an overlay – it’s effectively a combination of onPreferenceChange() and overlay() in one
modifier, which helps make our code simpler.
This is where SwiftUI performs a beautiful trick. Remember earlier when I said that anchors
are opaque geometry stores? That means they contain geometry data, but don’t let us read that
data back out because it wouldn’t have any meaning – just knowing X:35 Y:58 by itself
doesn’t mean anything unless you know exactly what coordinate space you’re coming from
and going to, for example.
SwiftUI solves this brilliantly using GeometryReader: if we use one of these anywhere in our
view layout, we can pass an anchor to its proxy object and have it send back a relevant frame
for us as a CGRect. That means the GeometryProxy figures out how to convert the original
frame into whatever coordinate space our GeometryProxy is working in – it takes away all the
hassle of figuring out where the two are in relation to each other, and just sends us back the bit
we actually care about: a CGRect telling us the frame we actually want to use to refer to the
original bounds we set with anchorPreference().
Enough chat, let’s put the code in place so you can see exactly how it works. Please add this
modifier to the VStack in ContentView:
.overlayPreferenceValue(CategoryPreferenceKey.self)
{ preferences in
GeometryReader { proxy in
if let selected = preferences.first(where: { $0.category ==
selectedCategory }) {
let frame = proxy[selected.anchor]
Rectangle()
.fill(.black)
www.hackingwithswift.com 143
Environment and Preferences
I’m going to break that down and walk through every line, but first I want you to build and run
the code so you can see what it does – you should find you can now tap on any button to see a
line animate between them, automatically moving and resizing so that it always underlines
each button correctly. It’s a great effect, although I should repeat that it’s from Airbnb rather
than something I came up with by myself!
144 www.hackingwithswift.com
Anchor preferences
The real magic in that code is proxy[selected.anchor], which takes care of all the geometry
conversion for us – hopefully you can see now why Anchor is opaque!
The real power of this approach is that the rest of our UI has no idea that preferences are being
used at all, which means if we added more to the VStack it would Just Work™ without any
special extra work from us.
For example, we could add a List below the buttons HStack, showing all the categories and
letting the user select them that way:
if selectedCategory == category {
Spacer()
Image(systemName: "checkmark")
}
}
www.hackingwithswift.com 145
Environment and Preferences
That adjusts selectedCategory when each row in the list is tapped, which triggers body being
reinvoked and will update the overlay preference. If you try it out, you’ll see SwiftUI
automatically moves the underline rectangle around, just like we had when tapping the buttons
directly.
We can also show the selected category by adding another view to the VStack, below the List:
if let selectedCategory {
Text("Selected: \(selectedCategory.id)")
}
Again, that will automatically update no matter how the selected category changes – we get
both the underline and text updating smoothly, all thanks to SwiftUI’s preferences system.
146 www.hackingwithswift.com
Anchor preferences
So, even though I think it takes a little understanding at first, I hope you can appreciate the
power being exposed here: we’re able to send complex data to parent views, convert
coordinate spaces, and more, all to quite elegantly achieve a very specific result.
www.hackingwithswift.com 147
Chapter 4
Custom Layouts
148 www.hackingwithswift.com
Adaptive layouts
Switching between layouts can be a complicated beast in SwiftUI, because as you saw earlier
it’s easy to get stuck with view builder conditional content views that toss away your state,
destroy your platform views, and screw up any animations. However, if done right it’s possible
to move smoothly from one container view to another, e.g. from a HStack to a VStack – not
only does it avoid the aforementioned problems, but SwiftUI will even animate between the
two layouts for us.
The key here is SwiftUI’s AnyLayout view, which is a type-erased wrapper around a
container capable of performing layout. You might see the “Any” in the name and imagine this
should be avoided much like AnyView, but relax: this wrapper is specifically designed to let
us dynamically switch between different layouts, e.g. horizontal and vertical, without
destroying any state along the way.
You’re already familiar with HStack, VStack, and ZStack, but SwiftUI provides special
alternatives to those called HStackLayout, VStackLayout and ZStackLayout – all of which
are designed to work with AnyLayout, so we can swap between them freely and have SwiftUI
rearrange our views automatically. These have different names not because Apple is changing
their mind or because they plan to remove the old names, but simply for compiler reasons: if
Apple made the original stack types work with AnyLayout it would cause our code to build
significantly slower as the compiler tried to figure out which implementation we meant in our
code.
Anyway, let’s get into some code, so you can see all this in practice. First, I want to make a
simple test view that we can use repeatedly, so create this new SwiftUI view now:
www.hackingwithswift.com 149
Custom Layouts
counter += 1
} label: {
RoundedRectangle(cornerRadius: 10)
.fill(color)
.overlay(
Text(String(counter))
.foregroundColor(.white)
.font(.largeTitle)
)
}
.frame(width: 100, height: 100)
.rotationEffect(.degrees(.random(in: -20...20)))
}
}
Those views will provide enough variation that several of them will look suitably different on
the screen, but they also have their own individual state – each view will have its own counter
that increments when tapped.
What is more interesting is how we place a bunch of those on the screen at the same time. I
already mentioned that AnyLayout has the job of wrapping another specific layout type, so in
order to cycle through various layouts we’re going to create an array of them and cycle through
them to use one at a time.
So, that contains three different ways of laying out views: vertical, horizontal, and depth.
Which one we use will depend on another property that points to an index in that array, so add
this next:
150 www.hackingwithswift.com
Adaptive layouts
And our final property will be responsible for returning one layer from the array, based on the
value of currentLayout:
For the body of our view, we’re going to create a VStack with four things inside:
We’ll also make the VStack take up all available space, and have a dark background color so
our example views stand out clearly. Replace the current body property of ContentView with
this:
VStack {
Spacer()
layout {
ExampleView(color: .red)
ExampleView(color: .green)
ExampleView(color: .blue)
ExampleView(color: .orange)
}
Spacer()
Button("Change Layout") {
www.hackingwithswift.com 151
Custom Layouts
withAnimation {
currentLayout += 1
if currentLayout == layouts.count {
currentLayout = 0
}
}
}
.buttonStyle(.borderedProminent)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.gray)
That’s already enough to demonstrate the power of AnyLayout: when you run the app now
you’ll see our four example views rearrange themselves smoothly every time the button is
tapped. More importantly, as we switch between layouts each of the views will retain their
state – you can tap on any of them to increase their counter, and those values will be preserved
between layout changes.
152 www.hackingwithswift.com
Adaptive layouts
That’s neat, right? But SwiftUI goes two steps further. First, alongside VStackLayout and
others we can also use GridLayout to lay out views in a grid. To try it out, first modify your
layouts array to this:
layout {
GridRow {
ExampleView(color: .red)
ExampleView(color: .green)
}
GridRow {
ExampleView(color: .blue)
ExampleView(color: .orange)
www.hackingwithswift.com 153
Custom Layouts
}
}
That splits up our views into two rows, so if you run the app now you’ll see we get a 2x2 grid
layout alongside the other three.
If you pause to think about it, something neat is happening here: three of our four layouts
aren’t grids, and yet SwiftUI won’t bat an eyelid about them containing GridRow. That
particular view only does anything when contained inside a grid, and for all other layouts it
behaves identically to a Group.
So now we’re cycling between four completely different layouts for our views, with SwiftUI
preserving animation, state, and platform views throughout. But I said SwiftUI goes two steps
further, so what’s the second one?
Well, all four of these layout types conform to an underlying protocol named simply Layout.
154 www.hackingwithswift.com
Adaptive layouts
Not only can we create our own types that conform to Layout, but when doing so those
custom layouts can be used with AnyLayout too – you can move smoothly from the built-in
layouts to ones you built yourselves.
www.hackingwithswift.com 155
Implementing a radial layout
The first custom layout we’re going to build is by no coincidence also the easiest, and is
designed to place its views in a circle. To build this layout you’ll need to meet the two most
important methods in the Layout protocol:
• The sizeThatFits() method is given a proposed size for our layout, along with all the
subviews that are inside, and must return the actual size our container wants to have.
(Remember the three-step layout process: step 1 is the parent proposing a size, step 2 is the
child deciding on its actual size, and step 3 is the parent placing the child based on that
size.)
• The placeSubviews() method is given the actual CGRect the parent has allocated for the
child, which will match the size we returned from sizeThatFits(). It will also be given the
original proposed size, because it’s possible the parent proposed multiple sizes before one
was finally chosen, and we’ll also get the subviews ready to place.
There’s one more thing both these methods accept, and we’ll be covering it in a later chapter: a
cache, so that you’re doing slow calculations to create your layout you can skip doing the work
more often than is necessary.
Anyway, let’s begin by creating a new struct with stubs for the two required methods:
}
}
156 www.hackingwithswift.com
Implementing a radial layout
Swift will complain because we aren’t returning something from sizeThatFits(), but that’s
okay: that entire method is just one line of code, so we might as well fill it in now. Put this
inside sizeThatFits():
proposal.replacingUnspecifiedDimensions()
Before I explain what it does, I should make one thing clear: although that is the correct and
only line of code for this radial layout, many if not most of the custom layouts you will make
in the future will need significantly more logic to work. So, if you were thinking “wow, these
custom layouts are easy,” you shouldn’t get your hopes up too much!
SwiftUI calls sizeThatFits() with a proposed view size and all our subviews. Very often you’ll
want to query those subviews to ask how much space they want before deciding how much the
whole container wants, but for a radial layout we don’t care – we just want to take all the space
that was offered to us.
The proposed view size we receive into sizeThatFits() comes from our container’s parent, and
it might call the method several times to get a full understanding of what our layout is happy to
use. Sometimes we’ll be passed in a specific size the parent wants to give us, e.g. 300x200,
sometimes we’ll be passed only part of a size, e.g. we can have 300 points horizontally and no
vertical limit, and sometimes we’ll be passed one of three special values:
• Unspecified: “I don’t have a particular size in mind for you, so tell me your ideal size.”
• Infinity: “You can have as much space as you want, so what’s the most you’ll take?”
• Zero: “Space is really tight, so what’s the least you can work with?”
The point is that this proposal isn’t just a simple width and height, because even without the
infinite and zero values it’s still possible to get nil for either or both width and height.
www.hackingwithswift.com 157
Custom Layouts
all the space you offered, but if you didn’t specify something I’ll ask for just 10 points.” Yes,
10 points isn’t really enough for a good layout, but there isn’t really a good alternative here –
we can’t really create a circular layout unless we know the amount of space available to us.
This situation might seem familiar to you: back in the chapter on layout neutrality I mentioned
that Color.red inside a scroll view would be given a nominal 10-point height because it
wouldn’t make sense to use anything else. Hopefully now you can see where that value of 10
comes from – internally the Color is using replacingUnspecifiedDimensions() to replace its
nil inputs with 10.
That’s enough talk, so let’s move on to the placeSubviews() method. This is more challenging,
because it’s our job to figure out how to place all our subviews in a circle. It takes a small
amount of trigonometry, but hopefully it won’t challenge you too much!
To begin with, we need to calculate the radius of the bounds we’re working with, then divide
360 degrees by the number of subviews so we can see how many degrees of our circle should
be allocated to each view.
Note: The Angle.degrees(…).radians part is intentional – it’s a little easier to think about 360
degrees in a circle, but we need the final “angle per subview” value as radians.
Now we know the radius of our circle and how many degrees of our circle should be allocate
to each view, we can start to place each view. This means going over all the views in
subviews, figuring out how much space it wants, then placing it on our circle as appropriate.
158 www.hackingwithswift.com
Implementing a radial layout
If we place every view at the very edge of our circle, we’ll hit a problem because a large part
of each view will lie outside the circle’s perimeter. For example, if the radial layout goes
horizontally edge to edge on the screen, the views on the left and right edge will both be
hanging half way off the screen, which is a poor experience.
Rather than placing our views at the very edge of our circle, we’ll instead ask each view how
much space it wants, then subtract half that from the position it would otherwise have been
given – rather than “edge of the circle” it’s “edge of the circle minus half the view’s size” so
it’s fully inside the circle.
So, the first line we’ll add inside our loop will be to ask each subview for its ideal size, like
this:
• We know the angle that needs to be allocated to each view to split up our circle fairly.
• If we multiply that angle by our loop index, we’ll find the angle where this particular view
should be placed.
• Calculating the cosine of that angle will tell us how much X movement should happen to
reach that position, in a range of -1 through +1.
• We can then multiply that by our radius to get that X movement in the range of -radius to
+radius.
• Calculating the sine of the angle will tell us how much Y movement should happen to
reach the view’s position, and again we will multiply that by our radius to get the actual
location.
1. Like I said, we need to subtract half the width and height of the view from the final X and Y
www.hackingwithswift.com 159
Custom Layouts
positions.
2. SwiftUI considers 0 radians to be directly to the right, whereas users will expect 0 to be
directly up. We can fix this by subtracting half of pi from our angle before putting it
through sin() and cos().
At this point we know where to place this view inside our container, but there are still two
more small complications.
First, we’ve calculated where the view should be by multiplying its angle by our container’s
radius, with a little extra logic in there for handling 0 degrees being up and ensuring views
always lie inside the circle. What we haven’t done is offset this position so that it’s relative to
the center of our container, which means right now those xPos and yPos values are offsets
from the top-left corner of our container.
To fix this, we’re going to convert our two position values into a CGPoint, and while doing so
add in the midX and midY of our bounds, so that our circle is centered on the center of our
container as you’d expect.
The second small complication comes by asking a question: when placing the subview at
point, are we saying the top-leading of the subview should be there? Or the center of the
subview? Or something else? If we don’t specify SwiftUI will assume we mean top leading,
160 www.hackingwithswift.com
Implementing a radial layout
but we’ve actually calculated the center position so we need to say that.
Attached to this is a chance to tell the subview exactly how much space we have allocated to it.
Remember, children choose their final sizes and parents must respect that, so this space
allocation is just another proposal – the child can do what it likes. We don’t care how much
size the view takes up, so we’ll use .unspecified here.
So, that asks each view for its ideal size, then uses it to place it inside our circle’s perimeter.
The whole thing isn’t a lot of actual code, but it does take quite a bit of explaining because
there’s a lot of power here.
We’ll look into these layouts more in coming chapters, but first I want you to try it out in a
SwiftUI view – try replacing your ContentView struct with this one:
www.hackingwithswift.com 161
Custom Layouts
}
}
That creates lots of circles in a radial layout, but adds a stepper to increment or decrement the
circle count using animation. Try running it now – it’s a simple view, and again our radial
layout code is pretty short, but I think you’ll be impressed by how good the results are!
162 www.hackingwithswift.com
Implementing an equal width
layout
The next custom layout we’re going to look at will create a HStack where each view is
allocated exactly the same width.
In the “Fixing view sizes” chapter I showed you how we could make two views in the same
HStack have the same height by using .frame(maxHeight: .infinity) on the views
and .fixedSize(horizontal: false, vertical: true) on the HStack, but this is different: here we
want all the views to have the same width, which is trickier to solve.
Now, you might think one solution is to give each child view .frame(maxWidth: .infinity),
which will cause each view to resize freely horizontally – the HStack will just divide its
available space by the number of views. However, the problem with this approach is that it
makes all the views take up more space than is needed, causing our HStack to grow.
Yes, we want all our views to have the same size, but ideally that size is whatever is the largest
of all the subviews – if subview A is 100 wide, B is 50 wide, and C is 150 wide, we want to
give A, B, and C 150 points each, because that’s the largest width of the subviews.
Let’s start writing some code – add this empty struct now:
Before we write sizeThatFits() and placeSubviews(), we’re going to write two helper
methods that do work shared in both those other places. There are extremely concise ways of
writing both of these functionally, but honestly the main focus here is understanding how the
Layout protocol works so I’m going to give you longer code that is much easier to understand.
The first helper method has the job of going over all the subviews we’re laying out and figure
out the maximum size – the maximum width of all the views, and the maximum height of all
the views. This can be done by:
www.hackingwithswift.com 163
Custom Layouts
the views. This can be done by:
So, we’re not saying the maximum width and height for any one view, but instead the
maximum height across all subviews and maximum width across subviews.
return maximumSize
}
The second helper is a more complex one, but it solves an important problem that we can
mostly ignore when working with SwiftUI: some views like to have a certain amount of
distance between themselves and other views. I don’t mean because of padding; that’s part of
164 www.hackingwithswift.com
Implementing an equal width layout
the view’s size. Instead, this is a really neat feature of SwiftUI that allows a Text view to have
more or less spacing depending on whether its neighbor is another Text view or is an Image.
Even better, this automatic spacing automatically varies across platforms, so you’ll get
different values on watchOS and tvOS because of the space differences.
Anyway, this second helper method is going to create a Double array containing spacing
values, one for each subview. This can mostly be done by asking SwiftUI how much distance
should be placed between one view and the next one, but the last subview is a special case
because it doesn’t have a neighbor afterwards – we’ll return 0 for that.
return spacing
}
That’s most of the hard work done now, so we can finally turn our eyes to the sizeThatFits()
method. Remember, this is given a proposed size and all the subviews it needs to lay out, and
should return a CGSize containing the actual size it wants to use.
www.hackingwithswift.com 165
Custom Layouts
1. Call maximumSize() to find the largest width and height across all our subviews.
2. Call spacing() to get an array of the spacing between all the views, then sum those numbers
into a single value.
3. Return our maximum width multiplied by how many subviews we have, because each view
will have the same size, then add to that the total spacing value.
4. Return our maximum height. No further calculations are needed because it’s a horizontal
stack.
The placeSubviews() method is a little trickier, but it still leans heavily on those two helper
methods we wrote. So, start with this:
In our radial layout example we used an .unspecified proposal size for each view because we
didn’t care how much size each view was, but here we do care: we want every view to have the
166 www.hackingwithswift.com
Implementing an equal width layout
same size, which means creating a specific size proposal and giving it to the view. Again, that
view might ignore the proposed size, but it’s important we give it the chance to take it into
account.
The size proposal we’ll be sending every subview is simple: we already calculated the
maximum width and height across our subviews, so that becomes our proposed size for every
view. Add this line next:
The final step is to lay out the views. To make this happen, we’ll create an x variable that
represents the center X position of the next view we’re laying out. When we’re just starting out
this will have the position of our left edge, plus half our maximum size – we’re centering the
views, remember.
And now all that remains is to loop over the subviews, placing each one at the x position and in
the vertical center of our container. Remember, it’s important we tell SwiftUI that we’re
specifying the center of our views rather than the top-leading edge or something else, and this
time we are going to use the proposal parameter because we’re going to ask each view to fit
itself into the shared ProposedViewSize we made a moment ago.
The key thing here is that every time we place a view we need to modify x upwards by adding
our maximum width value, but also by adding the spacing for the view we just added so that
this view has the correct amount of space between it and the next view.
www.hackingwithswift.com 167
Custom Layouts
That completes our layout, so now all that remains is to try it out in a SwiftUI view:
Text("This is long")
.background(.green)
Text("This is longest")
.background(.blue)
}
}
}
When that runs you’ll see the views spread out across the screen, with “This is long” dead in
the center. This is exactly what we want: the views themselves have retained the natural size
(which is why the colored boxes are small), but their container has allocated each of them
equal space.
168 www.hackingwithswift.com
Implementing an equal width layout
Even though you might think this effect is easier to achieve than a radial layout, you’ll notice it
actually took more code – and that’s even with adding two helper methods. Now, to be fair we
could dramatically reduce the code length if we switched to a more condensed, functional
approach:
www.hackingwithswift.com 169
Custom Layouts
However, keep in mind that the radial layout didn’t even need those methods in the first place:
we always placed views at their natural size, and didn’t care about any spacing requests they
had because they were being placed in a circle. Still, I wanted to show you this layout because
sizing and spacing are both important skills and I’m sure you’ll use them in your own work!
If you’re looking for a challenge, how about you try implementing an EqualHeightVStack?
170 www.hackingwithswift.com
Implementing a relative width
layout
Have you ever wanted to make SwiftUI views take up a proportional amount of space in a
HStack? I certainly have – to be able to say “give this view 20% of the space, this other view
30% of the space, and this final view the remaining 50%” is something that appeared briefly in
the very earliest SwiftUI beta. Sadly it disappeared before SwiftUI 1.0 shipped and has yet to
return, but with the power of the Layout protocol we can bring it back.
This is made possible because of SwiftUI’s layoutPriority() modifier, which controls how
willing a view is to shrink or stretch. All views have a default layout priority of 0, but if you
give a higher value to something it will grow to fill all the available space more readily.
This layout priority can be read through the Layout protocol, so we’re going to hijack it here
as a way of specifying how much relative space should be allocated to a view. Rather than
forcing developers to add numbers up to 1 or similar, we will instead sum up all the priorities
for the views we’re trying to lay out, then calculate the relative size for a view based on its
priority compared to the total. So, the total might be 1.0, 100, 12, or any other number
depending on what works at the call site.
We can start by creating a struct for our layout, giving it the one property we care about: how
much space we want to have between our views. This will be a single fixed value provided by
the user rather than querying each subview for how much spacing it wants, because the rest of
the space will be allocated proportionally.
All the hard work for this layout is contained in one helper method, which has the job of
www.hackingwithswift.com 171
Custom Layouts
calculating all the frames for all the views in one pass. This will be called by sizeThatFits() so
we know how much space we need, and also called again by placeSubviews() so we can
actually assign each view its frame.
Because there’s a lot of code here, I’m going to break it down step by step so I can explain as I
go. Start by adding this method stub:
Already you can see we’re passing in all the subviews, along with whatever is the total width
allocated to our container.
Our first job will be to figure out how much total spacing we’ll be using across our layout. We
already added a spacing property to control the amount of space between individual views, so
to find the total spacing we just need to multiply spacing by 1 less than our column count.
Why one less? Well, if we have two columns, we need only one space – the one that’s between
the two columns. We don’t need to add space on either side of the layout, because that’s
something that will be decided by whomever is placing the container.
Now we know how much space we have to allocate to each view, which is our total width
minus our total spacing:
The final constant I want to set up front is an important one: what is the total of all the layout
priorities of the views we’re working with? This might add up to 1.0, but really it’s arbitrary –
this is a relative layout, after all.
172 www.hackingwithswift.com
Implementing a relative width layout
this is a relative layout, after all.
Now it’s time to start calculating frames, which means creating two variables:
And now we have the main chunk of this method, which needs to go over all the subviews we
have, figuring out how much space each one should be allocated. Start with this:
return viewFrames
Inside that loop comes the real work. Remember, this is calculated using the layout priority
assigned to the view rather than trying to take into account how one view should be spaced
from others.
We already know how much space we have to allocate to all views (availableWidth), and we
know the sum of all our subview priorities (totalPriorities), so we can calculate the width for
this subview by multiplying the available width by the view’s priority, then dividing the result
by the total priorities, like this:
www.hackingwithswift.com 173
Custom Layouts
Now that we know how much space this particular subview can have, we can put it forward as
a proposal and see what comes back:
Again, that size is decided solely by the child, but it will take into account our request: we’re
asking for a specific width, but saying it can grow as high as it likes.
We can then combine our x value with the size we received into a CGRect, and add it to our
array:
And finally we need to add to x the width of the view we placed, plus the spacing for the
whole container, so the next view we lay out will go into the correct location:
x += size.width + spacing
That completes our helper method – as you can see, it does all the hard work of calculating
frames for our views, which makes the rest of this layout straightforward.
174 www.hackingwithswift.com
Implementing a relative width layout
got back from the frames() method – the bottom edge of the lowest view.
www.hackingwithswift.com 175
Custom Layouts
Note how we’re proposing a size to the view based on the return value from the frames()
method – if you remember, that’s the size we got back when we called subview.sizeThatFits()
for this view, so it should be acceptable.
That’s our layout complete! To give it a try, create some flexible views then assign layout
priorities however you want.
RelativeHStack(spacing: 50) {
Text("First")
.frame(maxWidth: .infinity)
.background(.red)
.layoutPriority(1)
Text("Second")
.frame(maxWidth: .infinity)
.background(.green)
.layoutPriority(2)
Text("Third")
.frame(maxWidth: .infinity)
.background(.blue)
.layoutPriority(3)
}
Or we could use 4, 4, 8 to get two views the same size then one that’s twice as large, or use 30,
50, 20 if you prefer to think in percentages, or really whatever numbers work best for your
layout.
176 www.hackingwithswift.com
Implementing a masonry layout
Masonry layouts – sometimes called “waterfall” layouts – allow us to create a grid that’s
ragged, which means although there are distinct columns in place there aren’t “rows” because
each view is just slotted in wherever it fits according to its aspect ratio. This is a really
common layout on the web, and in apps are mainly used for content walls – when the user is
scrolling through a category of pictures looking for something specific, like Pinterest.
Implementing this is actually not as hard as you might think, particularly now that you’ve
implemented three other layouts already. In fact, as you’ll see our approach is almost identical
to creating RelativeHStack – large parts of the code are almost identical.
Start with a struct for the layout, giving it two properties: how many columns we have, and
how much spacing we want between each item in our layout. The number of columns must be
at least 1 otherwise the layout makes no sense, so we’ll add a custom initializer that ensures the
column count is always at least 1.
Just like with RelativeHStack, all the real work for this layout is contained in one helper
method that calculates and returns frames for all the views. Start by adding this method stub:
www.hackingwithswift.com 177
Custom Layouts
Just like before, we can figure out much total spacing we have by multiplying spacing by 1
less than our column count, like this:
Now we know how much spacing we have in total, we can calculate how much space is left for
the columns by subtracting totalSpacing from totalWidth. If we then divide that number by
our column count, we’ll have how much space should be allocated to each column. Add this
next:
There’s one last number I want to set up front, which is how much space we need to allocate
for one column including its spacing, because it makes our code a little easier to read:
At this point we know all the values we need to calculate our frames, so our first job is to
create a ProposedViewSize that we can present to each view. This will be the same for all
views: “you can use as much height as you want, but I’d like your width to be the same as our
column width.”
When it comes to calculating frames, masonry layouts assign views to columns in a very
specific way – we don’t do it randomly because that would just cause a mess. Instead, our goal
for any given view is to place it into the shortest column so that we aim for some balance. That
178 www.hackingwithswift.com
Implementing a masonry layout
doesn’t mean we’ll get balance because perhaps the final view we place in a column is much
longer than all the others, but we’re at least aiming for it.
This means we need two arrays: one storing all our view frames across all columns, and one
storing the heights of each column we have. Add these lines now:
We can now loop over all our subviews, figure out which column is the shortest, and place our
view there. As we go through, we’ll also stash the latest view frame away in our viewFrames
array, which is the value that will be returned from this method.
return viewFrames
Like I said, the first step in that loop is to figure out which column is shortest. If we assume
that the shortest is the first one and set a gigantic height for it, we can loop over all the other
columns and check whether they are shorter or not. If we find one that is shorter, we’ll make
that our new selected column and use its height for our shortest height.
var selectedColumn = 0
var selectedHeight = Double.greatestFiniteMagnitude
www.hackingwithswift.com 179
Custom Layouts
selectedHeight = height
}
}
At this point we know exactly which column should be used to place our subview, so we can
figure out the X and Y coordinates for it. The X value is simply the index of the column
multiplied by the columnWidthWithSpacing, and the Y value is the current height of
whichever column was shortest. Add this just after the previous loop:
To calculate the view’s size, we just need to ask it: given the proposed size we made earlier,
how much space does it actually want? Add this next:
Again, the view is free to ignore that proposal entirely, which would make our layout a mess.
That’s how SwiftUI works, though: the parent proposes a size, but the child gets to make the
final choice and the parent must respect that.
At this point we know the full set of data for the frame of this view, so we can create a
CGRect from it:
To end this loop we need two more things. First, we just added a subview to a column, so we
need to adjust the height of that column to include the subview’s height plus our spacing
property. That doesn’t mean we’re always adding spacing below the finished, placed views;
these column heights are just used for calculating where views ought to go.
180 www.hackingwithswift.com
Implementing a masonry layout
And the final part of the loop is to add the frame value we created to our viewFrames array,
which is being returned from the method:
viewFrames.append(frame)
That completes our frames() method – it’s not vast amounts of code, but it is done very
precisely to make sure we balance each column as best as we can. The point is that eventually
it returns the viewFrames array, which contains all the frames for all subviews.
With that hefty helper method in place, we can now turn to sizeThatFits() and
placeSubviews(), both of which lean on it heavily. In fact, both these two are almost identical
to their equivalents from RelativeHStack, so we can take a shortcut – copy and paste those
two methods from your RelativeHStack code into your MasonryLayout, like this:
www.hackingwithswift.com 181
Custom Layouts
proposal: ProposedViewSize(frame.size))
}
}
The only actual change is in the place() method inside placeSubviews(), because we want our
views placed by their top-leading edge rather than just their leading edge. This is the default
behavior when placing views, which means making two changes:
1. The Y position will be the top-left corner of our view’s frame, remembering to add in the
midX of our container so the views are centered correctly.
2. Remove anchor: .leading from the code, so it aligns top-leading.
And that’s our layout complete! Honestly, once you see how well it works I think you’ll be
really pleased because it’s an extremely effective algorithm.
To actually give this a meaningful test, we can create a trivial placeholder view that displays a
random color and its size. What matters is that this view has a fixed aspect ratio, and the same
is true if you want to use your own custom pictures instead – make them resizable, but ensure
they stay at the correct aspect ratio so they can be placed into our columns well.
182 www.hackingwithswift.com
Implementing a masonry layout
Text("\(Int(size.width))x\(Int(size.height))")
.foregroundColor(.white)
.font(.headline)
}
.aspectRatio(size, contentMode: .fill)
}
}
Important: The size displayed in those views won’t match the actual size used to place them
in our grid, because they’ll get resized up or down as needed. However, the aspect ratio will be
the same between the view’s requested and actual size, which is what matters.
To try this out we can now create a ContentView that creates 20 random view sizes and places
them into a MasonryLayout inside a ScrollView. To make things more interesting, I’m going
to make the column count adjustable using a stepper:
www.hackingwithswift.com 183
Custom Layouts
That completes our third and final layout, and I think it really shows off just how flexible
SwiftUI is. Remember, all three layouts we’ve made work great in the AnyLayout example
from earlier – we can flip through grids, ZStack, masonry layout, relative width layout, radial
layout, etc, all without changing any other part of our code.
184 www.hackingwithswift.com
Implementing a masonry layout
Before we’re done, I’d like you to try one more thing. Modify your ContentView code to this:
MasonryLayout(columns: columns) {
ForEach(0..<20) { i in
if i.isMultiple(of: 2) {
PlaceholderView(size: views[i])
} else {
Divider()
}
}
}
.padding(.horizontal, 5)
We’re now inserting dividers into half our views, and when you run the code you’ll see they
appear correctly. The question is: how? How does SwiftUI know to make our divider
horizontally rather than vertical?
www.hackingwithswift.com 185
Custom Layouts
horizontally rather than vertical?
Internally, SwiftUI asks our layout whether it defines one specific axis for its views. If we
don’t specify something SwiftUI assumes we have a vertical layout, which works great for our
masonry layout where a left-to-right divider looks great, but you’ll see it causes problems for
horizontal layouts such as EqualWidthHStack and RelativeHStack – the dividers will still
run left to right even in a horizontal axis, which looks wrong.
If you need to customize the axis of your layout, add a computed property called
layoutProperties providing a specific value. For example, if we wanted to specifically tell
SwiftUI that our masonry layout was vertical, we would add this:
That specifies our masonry layout works vertically, but you won’t see a difference here – try
upgrading one of our horizontal stack implementations to have a horizontal axis, and you
should see them behave better!
186 www.hackingwithswift.com
Layout caching
When we first looked at custom layouts I mentioned that both sizeThatFits() and
placeSubviews() take a cache parameter that allows us to avoid repeating work by reusing
calculations. For our radial and equal width layouts this wasn’t an issue, but our masonry
layout is more complicated and is a place where we could consider caching.
Important: I said could consider there, not must use. Caching is something you should
implement once you have used Instruments to profile your app and verified there’s a
performance problem you need to address.
No, seriously: You shouldn’t add a cache to your layout unless your profiling has shown
conclusive proof that it’s needed, because bad caching is a very common source of bugs. I’m
adding a cache here only so you can see how it works, not because one is desperately needed.
To make a layout cache you first need to make the easiest choice: what data do you want to
cache? In the case of our masonry layout we only really have one piece of data, and it’s also
the most complex to calculate: all the frames for our views. So, at the very least we need to add
a struct like this one to our code:
struct Cache {
var frames: [CGRect]
}
Now, you can put that anywhere in your project, but I’m a big fan of nesting types where they
have limited applicability. In this case, that Cache type is designed specifically for use by our
MasonryLayout struct, so I’d place it inside like this:
www.hackingwithswift.com 187
Custom Layouts
Important: As soon as you add that nested Cache struct, SwiftUI will attempt to use it for the
layout. We aren’t ready for this quite yet, so you’ll see compiler errors for the time being.
That cache is enough to store all the data we care about for performance reasons, but before we
solve our compiler errors there’s another property I want to add and it’s in answer to a simple
question: how can we know when our cache is invalid?
Well, SwiftUI will automatically invalidate our cache when our layout or its subviews change,
but that only applies to the properties of our layout rather than the amount of space it’s
allocated on the screen. This means if we adjust the size of our layout at runtime, either
explicitly adjusting its frame or because the device rotated, our cache is likely to be wrong.
To fix this problem, we need to give our cache a width property that will store the amount of
space we were laying out for. When we’re asked to place our subviews, we can double check
we’re still referring to the width we used for our cache, and if not we’ll recreate the cache from
scratch.
That’s our Cache type complete, so now in order to make Swift happy we need to use it in
three places. It won’t work yet, but at least we’ll be back to our code compiling cleanly.
The first is a new method that Layout will call when it wants to create a new cache for our
layout. This is called simply makeCache(), and it should return a new cache object ready to
use. Add this to MasonryLayout now:
188 www.hackingwithswift.com
Layout caching
The second and third places we need to use Cache are in the signatures for sizeThatFits() and
placeSubviews(). Find this code in both the method signatures:
Note: If you used Xcode’s code completion for the signatures, you might have cache: inout (),
which is identical to cache: inout Void.
That will make our code compile cleanly again, although it won’t actually utilize the cache yet.
This part is surprisingly easy, though, because we just need to copy the data we’re creating into
our cache at the right times.
For sizeThatFits(), that means adding two lines of code directly before the return statement:
cache.frames = viewFrames
cache.width = width
Remember, SwiftUI already created a Cache object for us, so we just need to update it with
the latest values we computed.
sizeThatFits() will be called when our layout is created or recreated, or when its subviews
change. It will also be called when the size we allocate to a view changes, e.g. if we add
padding at runtime. However, it will only be called once per orientation otherwise, so the
following can happen:
• We launch our app in portrait. sizeThatFits() is called and sets up the cache for our portrait
size.
• We rotate to landscape. sizeThatFits() is called and sets up the cache for our landscape
size.
• We rotate back to portrait. sizeThatFits() won’t be called again because it was already
www.hackingwithswift.com 189
Custom Layouts
called for the portrait orientation, so our layout will accidentally use our cache that was
configured for landscape.
So, we need to be careful in placeSubviews(): if we find that our bounds doesn’t match our
cached size, we should update the cache by calling frames again and resetting the width.
Put this at the start of the method, in place of the let viewFrames line:
if cache.width != bounds.width {
cache.frames = frames(for: subviews, in: bounds.width)
cache.width = bounds.width
}
That ensures the cache is always in a good state before we try and use it.
Speaking of using it, the last step in this process is to replace viewFrames[index] a couple of
lines lower, because we need to use our cache instead – make that read cache.frames[index]
instead, and now our cache will be used.
You can see it in action if you put this at the very start of the frames() method:
print("Recreating cache")
When you run the app you’ll see “Recreating cache” is printed only once, whereas previously
the frames() method would have been called unconditionally in both sizeThatFits() and
placeSubviews(). So, at the very least we’ve halved the number of calculations we perform.
That doesn’t mean we can eliminate all the extra work – SwiftUI is free to rebuild our cache
whenever and as often as it wants, but that’s an implementation detail and nothing I’d worry
about.
190 www.hackingwithswift.com
Customizing layout animations
SwiftUI does a good job of animating some parts of our layouts automatically. For example,
you’ll find you can animate something like the spacing in both our custom HStack just by
changing the value, and as you saw our masonry layout animates its columns flawlessly.
However, sometimes it’s not perfect, and the problem occurs when our intermediate states
matter: if you’re animating the spacing of a HStack, for example, then SwiftUI can look at the
spacing before the animation, look at the spacing after, then interpolate between the two – it
will call sizeThatFits() and placeSubviews() once each, then handle the rest itself. However,
if you need to calculate your intermediate states for every step in the animation – if you’re
animating values used inside sizeThatFits(), for example – then we need to give SwiftUI a
little extra help.
To demonstrate this, I want to return to our radial layout. Rather than placing our circles
around a full circle, we could instead add a property to control how much of the circle we use.
Add this to your RadialLayout struct now:
We can then adjust our placeSubviews() method so that the angle value we calculate for each
view is multiplied by rollOut, like this:
So, if angle was originally 10% of a circle, when rollOut was only 0.5 then the angle would
be just 5% of a circle, and when it’s 0.0 then the angle would 0% for all the views – the circles
wouldn’t roll out at all.
We can then try that out by adjusting our ContentView code to track a Boolean property of
whether our circle should be rolled out or not, convert that into a Double to use with the
RadialLayout initializer, and finally add a button to toggle the Boolean. Adjust your
www.hackingwithswift.com 191
Custom Layouts
Button("Expand") {
withAnimation(.easeInOut(duration: 1)) {
isExpanded.toggle()
}
}
}
}
}
}
Go ahead and run that now and see what you think – you should find that as you toggle
isExpanded the circles move from one point directly out to their final location.
It’s important you understand what’s happening here: SwiftUI knows the initial position of the
192 www.hackingwithswift.com
Customizing layout animations
circles, and when isExpanded is toggled it will calculate the destination position of the circles,
so it simply animates from the original to the new positions in one action.
This happens because SwiftUI doesn’t understand rollOut should be animated. It knows the
result of changing rollOut should be animated because that’s baked right into the framework,
which is why our circles animate their position, but it doesn’t know that it should animate all
the rollOut values as it moves from 0 to 1.
We can do better.
You see, the Layout protocol inherits from Animatable, which means we can ask SwiftUI to
give us all the intermediate values by implementing animatableData just like animating any
other SwiftUI views.
With that tiny change, we’ve told SwiftUI we want to do something special when the
animating value changes – that rather jumping directly to the new rollOut property, it should
instead move from 0.0 to 0.05, 0.1, etc, and let us do something with each intermediate value.
We’ll look at the impact of this more in a moment, but first please run the result so you can see
the difference. All being well you should see our circles animate outwards along the circle’s
perimeter rather than just sliding directly to their destination – it’s much nicer, I think.
To understand what has changed internally – and if you haven’t figured it out by now, I really
think it’s important to think about these internals so you know what’s really happening when
SwiftUI works with our code – I want you to comment out the animatableData property we
just added, and instead add print statements to both sizeThatFits() and placeSubviews(), like
this:
www.hackingwithswift.com 193
Custom Layouts
this:
print("In sizeThatFits")
Or this:
print("In placeSubviews")
When you run the app you’ll see those messages printed out at various times, but it’s not a lot
– SwiftUI might call each one twice when changing rollOut, for example, so it can calculate
the positions of its subviews before and after the animation.
Now uncomment the animatableData property so that it’s live code again, while also leaving
our little print() calls in, and this time you’ll see something very different: our print() calls get
executed a lot. In fact, both sizeThatFits() and placeSubviews() get called twice each for
every tiny change of animatableData, so if it animates from 0.0, through 0.01, 0.02, 0.03, etc,
all the way up to 1.0, those methods are getting called many, many times.
This is what makes our improved animation work: rather than making circles from in a straight
line from their start to end position, SwiftUI calls this line of code again and again to calculate
a custom location for all the intermediate positions of our subviews:
That means the latest value of rollOut is being read every time it changes, causing the much
nicer animation.
Obviously you can go ahead and remove the print() calls now, but I do want you to keep in
mind that animating layouts like this will trigger an exponential rise in the number of times
your layout methods are called, so you should avoid doing anything too computationally
expensive in there unless you’re carefully profiling the animation on older devices.
Tip: If your animation really does manage to push the CPU hard – a surprisingly hard thing to
do! – this might be a good place to consider introducing a cache to reduce the number of
194 www.hackingwithswift.com
Customizing layout animations
www.hackingwithswift.com 195
Chapter 5
Drawing and Effects
196 www.hackingwithswift.com
Drawing with Canvas
It’s no secret that I am obsessed with drawing using SwiftUI, and honestly I’ve lost countless
hours noodling around, experimenting, and overall having fun creating beautiful effects with
surprisingly little code. Over the coming chapters I want to explore a handful of these
techniques with you, partly because it really is a lot of fun creating beautiful things, but partly
also because I hope it will inspire you to add a little extra surprise and delight to your own
apps.
We’re going to start with something nice and easy: a trivial particle system that lets us draw in
glowing lights by touching the screen. Particle systems work by creating dozens, hundreds, or
even thousands of very small images, which can be colored and animated to create a variety of
special effects such as fire, smoke, fog, and rain.
In this initial foray into drawing, our particle system will be trivial: we’ll constantly be creating
and deleting particles, and the user will be able to move their finger to reposition the place
where we generate particles from. This takes fewer than 50 lines of code, and that’s including
whitespace and lines that are just closing braces – it’s a great entry point into the world of
drawing with SwiftUI.
The first step is to create a Particle struct that will store the data one particle needs to work.
We’ll make more advanced particles later on, but for this example our particle just needs two
pieces of data: its position on the screen, and the date it should be destroyed. We’ll pass the X
and Y values in from the particle system that generates it all, but we can automatically set the
destruction date to be the current time plus 1 second so that each particle lasts that long before
being destroyed.
struct Particle {
let position: CGPoint
let deathDate = Date.now.timeIntervalSinceReferenceDate + 1
}
www.hackingwithswift.com 197
Drawing and Effects
Now that we have defined a single particle, the next step is to create the particle system
responsible for creating and managing all the particles. This has six interesting things:
1. It will be a class rather than a struct, so we can mutate its values freely without triggering
SwiftUI updates.
2. It needs to store an array of all the particles that are currently alive.
3. It also needs to store the current position of the particle system, which is used to create new
particles.
4. We’ll give it one method, called update(). This will be called every time we want to redraw
our canvas, and will be provided with the current time.
5. Inside there we’ll destroy any particles that are past their deathDate property.
6. Finally, it will also create a new particle at the current position of the particle system.
It takes much less code to implement all that than it does to explain it, so go ahead and add this
class now:
class ParticleSystem {
var particles = [Particle]()
var position = CGPoint.zero
That completes all our data model, so what remains is to create one of those particle systems
inside ContentView, then use its data to render particles inside TimelineView and Canvas.
This class doesn’t conform to ObservableObject because it doesn’t need to – it manages itself
without needing to publish any changes. So, we’ll create it using @State rather than
@StateObject, which is enough to keep the object alive through view recreations without also
198 www.hackingwithswift.com
Drawing with Canvas
requiring ObservableObject.
Now for the view’s body. I’m going to tackle this in three parts to make it easier to follow:
we’ll create the SwiftUI views first, then add some modifiers to make it look and work right,
then finish up by adding the actual drawing code.
First, the views. We need a TimelineView so that SwiftUI knows to redraw our layout on a
fixed schedule, which in our case will be .animation so it redraws as often as necessary to get
smooth animations. Inside that we’ll place a Canvas, which is where SwiftUI gives us free
rein to draw whatever we need inside a drawing context with a fixed size.
TimelineView(.animation) { timeline in
Canvas { ctx, size in
// drawing code here
}
}
We’re going to add three modifiers to that TimelineView to get exactly the right effect:
1. A custom drag gesture so that we can update the particle system’s position as the user
moves their finger. If we give this a minimum distance of 0 it means the gesture will start
being triggered as soon as the user moves their finger even the smallest amount.
2. We’ll the TimelineView to ignore the safe area, so the user can draw to the very edges of
the screen.
3. Finally, we’ll give it a black background color so that our finger drawings stand out nice
and bright.
www.hackingwithswift.com 199
Drawing and Effects
Add these three modifiers to the TimelineView now:
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { drag in
particleSystem.position = drag.location
}
)
.ignoresSafeArea()
.background(.black)
The last part of the puzzle is to fill in the drawing code. This needs to call the particle system’s
update() method with the current time from the TimelineView, then loop over all the particles
and render a circle at their location. I chose a 32x32 size for my circles, but you can make the
circle whatever size you want – just make sure you subtract half the size from the X and Y
position so the circle is centered on the user’s finger.
Go ahead and give it a try. All being well you should find you can drag your finger on the
screen to see blue circles follow you around, each of which should disappear a second after
they are created.
200 www.hackingwithswift.com
Drawing with Canvas
That’s a start, but it’s not what I’d call beautiful. We can do better! First, rather than making
circles simply disappear we can make them fade away slowly by subtracting the current time
from their deathDate property and using that for the canvas opacity. So, if deathDate was 3.5
and the timeline date was 3.0, we’d set opacity to 0.5.
That’s an improvement, but to get something much nicer I’d like you to add these two lines of
code directly after the call to particleSystem.update():
ctx.blendMode = .plusLighter
ctx.addFilter(.blur(radius: 10))
The first of those tells SwiftUI to blend the circles together so the colors get lighter when they
www.hackingwithswift.com 201
Drawing and Effects
overlap, and the second will apply a Gaussian blur to all the circles so they look more like a
smooth glow than individual circles.
And with that we’re done with our first drawing example! Try it out and see what you think –
given how little code we’ve written I think the end result is quite beautiful!
202 www.hackingwithswift.com
Falling snow
Now that you’ve got the hang of a basic particle system, let’s take it up a notch by creating
particles that move independently rather than always staying where they were created. This
means being able to constantly adjust our particles after they have been created, which in turn
means using a class rather than a struct for the Particle type.
class Particle {
var x: Double
var y: Double
let xSpeed: Double
let ySpeed: Double
let deathDate = Date.now.timeIntervalSinceReferenceDate + 2
1. I’ve split x and y into two separate properties because it makes them easier to work with.
2. The ySpeed will determine how fast this particle moves down the screen, and the xSpeed is
there to let us add a very small amount of horizontal movement to make the particles look a
bit more natural.
3. There’s a 2-second lifetime for each particle so they can fall most if not all of the way down
the screen before being destroyed.
4. It’s a class rather than a struct, so we need a custom initializer.
www.hackingwithswift.com 203
Drawing and Effects
Next we need to write the ParticleSystem class. This is similarly enhanced from our previous,
simpler particle system, because now it needs some extra features:
That second point is the most interesting from a code perspective, because we need to make
sure particles move at the same speed no matter whether update() is called 60 times a second
(the ideal for many devices), 120 times a second (the ideal for devices that support
ProMotion), or even just 30 frames a second if your app is busy doing a lot of other work.
This can be done using a technique called frame-independent movement, which means we
calculate how much time has passed since update() was last called, then multiply our
movement speed by that time difference. So, if we want to move 60 points per second and a
tenth of a second has elapsed since the last update, we move by 6 points, but if only 1/60th of a
second elapsed then we move only 1 point.
Doing this means giving our ParticleSystem class an extra property that stores when update()
was last called. This can be the current date to begin with, but it will be changed every time
update() is called.
class ParticleSystem {
var particles = [Particle]()
var lastUpdate = Date.now.timeIntervalSinceReferenceDate
}
204 www.hackingwithswift.com
Falling snow
The update() method will accept the same time interval we used in the previous particle
system, but like I said we’re also going to make it accept the size of the screen so we know
where particles can be created. Each new particle needs various values provided as part of its
initializer:
• The X coordinate will be any value between -32 and the screen width. Our particles will be
32 points, just like in the previous particle system, so using -32 means we’ll position some
particles partly off the left edge to ensure a full spread of positions.
• The Y coordinate will always be exactly -32, which places new particles just off the top
edge of the view.
• The X speed will be a random number between -50 and 50, so particles move very gently
left or right.
• The Y speed will be a random number between 100 and 500, so particles move swiftly
down the screen. Having a lot of variation in speed will create a pleasing sense of depth to
the particles.
Note: It’s important we replace the value of lastUpdate with whatever is the new timeline
date, so the next time the method is called we move the correct amount.
www.hackingwithswift.com 205
Drawing and Effects
Obviously I left the most important part out, which is where we update each particle to take
into account its movement across the screen. We need to remove particles from our array
whenever they pass their death date, just like we did with the previous particle system, so we
might as well use the same loop to perform our frame-independent movement.
Add this loop in place of the // update all particles here comment:
That completes our data model, so now we can turn to the SwiftUI view to render it all using
TimelineView and Canvas. We’re going to write a few versions of this, but our initial pass is
identical to the ContentView struct from our previous particle system apart from three
changes:
1. We need to pass the canvas size into our particle system’s update() method, so it knows the
full range of space that can be used to create particles.
2. We aren’t going to add a blend mode. You can add one if you want, but it’s not the effect
I’m looking for here.
3. We don’t need to offset the particle’s X and Y position by half the circle width, because we
don’t need to draw exactly under the user’s finger any more.
Otherwise it’s exactly the same, so replace your existing ContentView struct with this:
206 www.hackingwithswift.com
Falling snow
www.hackingwithswift.com 207
Drawing and Effects
That already creates a neat snow effect, but it takes only a little extra work to push this into
much more interesting territory. For example, we could make our snow blobs look like
metaballs with only a little extra code.
Important: Before you decide to email me saying I wrote “metaballs” when I meant to write
“meatballs”, you should know that metaballs is the correct spelling and is the term used for
organic-looking shapes that appear to meld together when they are in close proximity to each
other.
If you haven’t seen metaballs in action before, you’re in for a real treat – not least because it’s
remarkable how little code it takes in SwiftUI.
The first step is to tell SwiftUI to render all our particles into a distinct layer. This means all
the particles will be rendering into a new transparent context, which is then drawn onto our
original context in one pass. So, rather than seeing each particle as individual, the original
context will just be given all the particles as one finished drawing – perfect for blending into
metaballs.
To make this happen, add ctx.drawLayer { ctx in before the for loop in our canvas, and add a
208 www.hackingwithswift.com
Falling snow
ctx.drawLayer { ctx in
for particle in particleSystem.particles {
ctx.opacity = particle.deathDate - timelineDate
ctx.fill(Circle().path(in: CGRect(x: particle.x, y:
particle.y, width: 32, height: 32)), with: .color(.white))
}
}
Important: That creates a new context inside drawLayer(). I’ve given it the same ctx name
as the outer layer because a) we can’t draw to the outer context from inside the layer, and b) it
means we don’t need to change the opacity and fill() lines, but you’re welcome to name it
inner or similar.
If you run the code now you’ll see nothing has changed, but behind the scenes SwiftUI is now
rendering all the particles into a new layer, then drawing that layer into our main context.
Although it looks the same to the user, the behind the scenes part matters to us because if we
add more filters they get applied to the whole sublayer in one pass – overlapping circles are
treated like one contiguous shape rather than two separate ones.
In this case we’re going to add the alpha threshold filter, which tells SwiftUI to replace all the
pixels with a specific color if they fall within alpha values of our choosing, or make them
transparent otherwise. The maximum alpha value is 1 by default, so if we specify 0.5 for the
minimum it means that all pixels between alpha 0.5 and 1.0 will be replaced with a specific
color, but pixels outside that alpha range will be invisible.
Think about it: we’re using a blur filter already, so already quite a lot of each circle will fall
outside that alpha threshold because only the central parts will have enough opacity to pass the
threshold. But when two circles overlap, even partly, the parts where they overlap will have a
higher alpha value, and because we’re rendering all our circles into a sublayer it means this
filter will see the combined circle areas as a single pixel colors to evaluate.
www.hackingwithswift.com 209
Drawing and Effects
To see the result of all this, and the following code directly before the blur filter:
If you run the code now you should see the metaball effect in full swing: as the particles
overlap each other they will appear to merge together as if they were water drops rolling down
a window. Hopefully you can see what I meant when I said they looked organic!
To create an even more interesting effect, try using your TimelineView as the mask for
something else, such as a linear gradient, like this:
Now the metaballs will appear to change color the lower they get on the screen, creating an
210 www.hackingwithswift.com
Falling snow
effect reminiscent of lava lamps. Can we make an actual lava lamp effect? Certainly, but that
takes quite a bit more thinking…
www.hackingwithswift.com 211
Creating a lava lamp
For a really advanced effect, we can use SwiftUI to create a lava lamp by combining Canvas,
metaballs, and a chunk of mathematics. To make things easier to follow I’m going to adopt a
simplified approach first that does without the math, but if you have the patience I encourage
you stick around for the extended version – it’s worth it!
As with the previous two particle systems, the first step is to define what one particle needs to
know in order to work. The particles will move, which means we need a class with X and Y
coordinates, but this time there are a few small changes:
• We will need to be able to loop over particles in SwiftUI code, which means making this
class conform to Identifiable with a random UUID.
• Each particle will have a unique size, making our lava lamp more varied.
• The lava bubbles won’t move horizontally, so we need only a single speed property to
handle Y movement.
• We need to track whether this bubble is currently moving up or down, because we will
never destroy the lava particles – we just keep reusing the same initial batch, flipping their
direction when they reach one end.
There is also one important change: we need to fill our lava lamp with lots of particles when
the app is launched, which means creating all our particles up front rather than on a rolling
basis. That in turn means we don’t actually know the canvas’s dimensions when creating our
particles, so rather than storing absolute X and Y positions – e.g. X:50 Y:300 – we will instead
store relative positions between 0 and 1, where 0 is the top or left edge and 1 is the bottom or
right edge.
212 www.hackingwithswift.com
Creating a lava lamp
Note: That creates particles in positions that go slightly beyond the screen’s bounds to make
sure we get a full spread. The spread is wider for the Y axis because we’ll be moving particles
vertically, so they can safely go further off the screen before coming back on.
Next we need to create a ParticleSystem class responsible for creating the lava bubbles and
moving them around. This starts similar to the previous particle system, so add this now:
class ParticleSystem {
let particles: [Particle]
var lastUpdate = Date.now.timeIntervalSinceReferenceDate
Apart from the // move the particles comment, there is one important difference here: rather
than creating particles dynamically as the app runs, we’re instead going to create them all up
front. This is why particles can be a constant array now, and also why it doesn’t have a default
value – we need to add an initializer to create all our particles up front.
We could make the number of particles fixed, but it’s hardly any extra work to make that
amount customizable. So, add this initializer to ParticleSystem now:
www.hackingwithswift.com 213
Drawing and Effects
amount customizable. So, add this initializer to ParticleSystem now:
init(count: Int) {
particles = (0..<count).map { _ in Particle() }
}
Now for the movement: if the particle is moving down, we’ll add to its Y position using frame-
independent movement, otherwise we’ll subtract from it. Critically, we’ll add two extra checks
to make sure particles flip their movement when they are fully off the screen.
if particle.isMovingDown {
particle.y += particle.speed * delta
Now for the main event: rendering all this in SwiftUI. We’re going to take a different approach
here, partly because it demonstrates an important Canvas technique, but honestly mostly
because it makes it much easier to switch over to the more advanced version if you decide to
pursue it!
The different approach is this: rather than just filling circles in our Canvas code, we are
instead going to create our shapes as SwiftUI views and pass them into the canvas as symbols.
This is a real powerhouse Canvas technique because it lets us place any kind of SwiftUI view
directly into our drawings. This will be really important in the more advanced lava lamp effect,
214 www.hackingwithswift.com
Creating a lava lamp
Apart from that drawing change, there are two other thing we’re going to do:
1. Rather than use fixed values for the blur and threshold filters, we’ll make them local state
you can adjust using sliders. This will give you a much better idea of how the finished
effect really works, because you’l be able to noodle around with both sliders to get exactly
the result you want.
2. We’ll be using the same LinearGradient mask as before, but this time we’ll give it a
background color indigo. Why? Well, if lava lamps can’t be disco, what can?
ctx.drawLayer { ctx in
// draw particles here
}
} symbols: {
www.hackingwithswift.com 215
Drawing and Effects
LabeledContent("Threshold") {
Slider(value: $threshold, in: 0.01...0.99)
}
.padding(.horizontal)
LabeledContent("Blur") {
Slider(value: $blur, in: 0...40)
}
.padding(.horizontal)
}
}
}
216 www.hackingwithswift.com
Creating a lava lamp
I’ve removed two key parts from that code, both relating to the way canvas symbols work. You
see, the way this works is that we get to pass in as many SwiftUI views as we want, either
statically written out in code or dynamically using ForEach, but the main thing is that we give
each view a unique identifier. Our Particle class conforms to Identifiable, which means
SwiftUI will take care of that part for us.
So, for the case of our lava particles, I’d like you to add the following code in place of the //
create symbols here comment:
ForEach(particleSystem.particles) { particle in
Circle()
.frame(width: particle.size, height: particle.size)
}
That creates a whole bunch of SwiftUI Circle shapes, each the correct size for their bubble.
Again, SwiftUI will silently tag each view for us because of the Identifiable conformance, and
that matters because when it comes to the rendering code – where the // draw particles here
comment is – we need to look up each circle using the same identifier.
www.hackingwithswift.com 217
Drawing and Effects
This lookup is done using the resolveSymbol(id:) method, which takes an identifier to look up
in the list of symbols. If it’s found we’ll be sent back the resolved symbol to use, which will be
a SwiftUI view we can draw just like any other shape.
Remember, this time we’re using relative positions for our particles, so we need to multiply the
particle’s X and Y positions by our canvas’s width and height respectively.
Go ahead and replace the // draw particles here comment with this:
That’s us done, at least with the simplified version – try it out and see what you think! The
code is broadly similar to our previous particle system, but I think it looks remarkably good.
218 www.hackingwithswift.com
Creating a lava lamp
Wouldn’t it be lava-ly?
Can we do better? Yes! If we create irregular polygons rather than circles, we can adjust the
length of each of their sides using animation, making the shape change constantly. Thanks to
our blur filter the shapes will still look nice and smooth even though they are pretty rough
polygons
I’ll be honest, this does take a bit of math. However, I have tried to remove as much code as I
possibly can, so hopefully it’s understandable.
First, the good news: Particle and ParticleSystem don’t need to change at all, and only one
small line of ContentView will change – most of what we’re writing is new.
Now for the first piece of mathematics: we’re going to add an extension on Array so that it
conforms to both VectorArithmetic and AdditiveArithmetic when it contains Double as its
element type. These protocols are used to provide animating values to SwiftUI, which in our
case means the polygon points we generate will animate to be longer or shorter over time.
Yes, I know that extending a type we don’t own to support protocols we don’t own is frowned
upon, but it avoids having to create a wholly separate type – this code really is as simple as I
can make it!
There are quite a few parts here, so start by adding this empty extension so we can fill it in bit
by bit:
First, we need to tell SwiftUI what zero looks like, which for us means an empty array with 0
in. Add this to the extension now:
www.hackingwithswift.com 219
Drawing and Effects
Next we need to add operator overloads for += and -= so that SwiftUI can add one array to
another. I’m going to assume (again, this is simplified!) that our lava polygons don’t change
their side counts over time, so we can safely enumerate over both arrays and add or subtract
each item.
Next, the protocols require that add a scale(by:) method that multiplies each item in the array
by another number, so add this:
Finally, the protocols require we add a - operator overload and a magnitudeSquared property.
Although the protocols require that these exist, they won’t actually be used by our lava lamp
effect so we can just write dummies like these two:
220 www.hackingwithswift.com
Creating a lava lamp
{ [] }
public var magnitudeSquared: Double { 0 }
Okay, that completes our extension, so now we’re going to create two structs to represent each
blob: one that creates the shape using whatever points are provided, and one that wraps the
shape in a view that animates over time.
The view is straightforward, but the shape is where more mathematics comes in, so let’s start
there.
First, we can create a new struct that conforms to Shape, and has a property to store an array
of numbers that represent how much we should shrink or extend each side of the polygon. In
order for this to be animated we need to either store these values in an animatableData
property containing these values, or store them in another property and provide a getter/setter
combo for animatableData.
We’re aiming for the simplest solution, so that means using a single property. Add this now:
init(points: [Double]) {
animatableData = points
}
}
Now for the mathematics: we need to create a path(in:) method that creates a path from our
polygon’s points, taking into account the animatable data array that describes how much to
shrink or extend each side.
www.hackingwithswift.com 221
Drawing and Effects
If we want to draw completely regular polygons – i.e., polygons where every side has the same
length, we can do so by following a simple procedure:
1. Calculating the center of our drawing rectangle, by halving the width and height.
2. Calculate the radius of the largest circle that can fit into our space. This is as simple as
choosing the smallest of the two numbers from our center.
3. Count from 0 to the number of sides we want to create, and calculate how far we are
through the number of sides as a fraction between 0 and 1. So, if we’re placing five sides,
the first side will be 0.0, the third side will be 0.5, and the last will be 1.0.
4. Multiply that fraction by pi times 2 so we get an angle between 0 and 2π radians, or 0
through 360 degrees, meaning that we know the angle we need to use to create each point.
5. Get the X coordinate for this point by calculating the cosine of the angle we just made,
multiplying it by our radius, then adding the result to our center X value.
6. Get the Y coordinate by doing the same thing, except using sine rather than cosine.
Again, that creates regular polygons. We want irregular polygons using the animatable
Double array, which will contain numbers between 0.8 and 1.2 – one for each point in our
polygon. So, once we’ve figured out the correct X/Y coordinates for our regular polygon, we
can multiply those values by the matching element in animatableData so that each side can be
made any length from 80% to 120% its original size.
That might seem awfully complicated, but I think the code is surprisingly straightforward
given how good the finished effect looks!
So, go ahead and replace the // more code to come with this:
222 www.hackingwithswift.com
Creating a lava lamp
path.addLines(lines)
Again, if we removed the * value part of that code each of our blobs would be regular n-sided
polygons, and if it weren’t for the fact that we only know the shape’s size when path(in:) gets
called we could calculate the polygon’s points once in the initializer.
That defines a single irregular polygon shape, but in order to make it move on the screen we
need to wrap it in a SwiftUI view that has an animation in place. This is much simpler, but still
has a few tricks up its sleeve:
1. When the view is created we will immediately fill a points array with 8 random numbers
between 0.8 and 1.2. These will be used for the polygon.
2. The view will have a timer firing once a second, and each time it fires we’ll create the
points for our polygon.
3. Because we’ll have multiple lava bubbles at the same time, we’ll add a 1-second tolerance
to our timer so iOS can definitely coalesce them – rather than firing one timer, then waiting
a split second and firing another, iOS will be able group them all together so they fire at the
same time, which is more efficient.
4. We’ll attach an ease-in-out animation to our polygon shape, but it will have a 3-second
duration.
Yes, the timer fires three times as fast as the animation takes to complete, which is intentional
– it would look strange if one animation completed fully before the next one started, so this
way SwiftUI will always be interpolating our animations smoothly. It’s a great little trick, but
really effective as you’ll see!
www.hackingwithswift.com 223
Drawing and Effects
And that’s it! That completes all the major code changes to make the more advanced lava lamp
effect work.
To see it in action, we need to change one tiny part of ContentView: in the symbols for the
Canvas view, change Circle() to AnimatingPolygon(), like this:
ForEach(particleSystem.particles) { particle in
AnimatingPolygon()
.frame(width: particle.size, height: particle.size)
}
Now give it a try and see what you think – you should see our lava blobs now gently change
shape all by themselves, even without colliding with other blobs.
224 www.hackingwithswift.com
shape all by themselves, even without colliding with other blobs. Creating a lava lamp
If you want to see the effect more clearly, try reducing the particle system count down to 5 or
so, or dragging the blur slider down to 0 so you see our irregular polygons rather than the
smoothed out blobs.
Hopefully I managed to find the right balance between mathematical accuracy and
explanations that are easy to understand, but more importantly I hope you appreciate the final
effect!
www.hackingwithswift.com 225
Drawing and Effects
226 www.hackingwithswift.com
Blurred backgrounds
We’ve looked at lots of custom drawing using Canvas, but you can create some really
effective results just with plain old shapes and animations. To demonstrate this, I want to show
you how to we can create a soothing background animation without even coming close to
needing Canvas.
To make this work we’re going to create a new SwiftUI view called BackgroundBlob, which
will have a random alignment on the screen and a random color. Inside there we need several
modifiers:
1. The frame will be random between 200 and 500 wide, and 200 and 500 high.
2. That frame will be wrapped in a second frame using the random alignment, so each blob
will be placed at a random screen corner.
3. We’ll then use offset() to push the blob randomly between -400 and +400 points both
horizontally and vertically.
4. We can then rotate the blob by some amount, using a looping animation to make it happen
smoothly and continuously.
5. As soon as the blob is shown, we’ll adjust its rotation amount so the animation is triggered.
The key to this effect is rotating after the offset, which causes the view to be rotated around its
original location so that it moves in interesting ways.
Create a new SwiftUI view called BackgroundBlob, then give it this code:
www.hackingwithswift.com 227
Drawing and Effects
Tip: I’ve given the animations a very fast duration between 2 and 4 seconds, and also thrown
in a bright purple color that really stands out. That’s intentional because I want to really drive
home how the effect works, but in practice you’ll want to use 20 and 40 to get something much
more sedate, and you might also want to tweak the color palette too.
To get the full effect, we need to layer many of those background blobs on top of each other in
a ZStack with a solid background color, so replace your current ContentView code with this:
228 www.hackingwithswift.com
Blurred backgrounds
.background(.blue)
}
}
Now try running it and see what you think! You should see a whole bunch of ellipses swirling
around on the screen, and it won’t look anything at all like the soothing background animation
I promised.
Fortunately, you’re just one line of code away from the final result, because we just need to
add a blur to each of the circles to make the blend together nicely.
.blur(radius: 75)
Now you should find all the colors mix together in more interesting ways, and in particular
using blobs that are the same blue color as the background causes our shapes to get cut out in
interesting ways.
www.hackingwithswift.com 229
Drawing and Effects
Again, I’ve specifically chosen a fast animation and a bright purple in order to make the effect
more obvious – at the very least you’ll probably want to adjust the animation to a range
between 20 and 40 seconds to get something gentler. You might also want to adjust the color
palette to be more harmonious, or on the other hand you might want something outrageously
bright like this:
Regardless of which approach you take, I think it’s a delightfully simple effect and shows just
how much work SwiftUI can do for us!
230 www.hackingwithswift.com
Blurred backgrounds
www.hackingwithswift.com 231
Magic with SpriteKit
Some people will see SpriteKit in this chapter title and skip right by, which is sad because
we’re about to make something absolutely magical.
You see, SpriteKit and SceneKit are Apple frameworks that are backed by Metal, which is
Apple’s high-performance 3D rendering framework. This means SpriteKit is able to use Metal
to create some extraordinarily advanced effects, and of course SwiftUI is able to embed
SpriteKit scenes right into our view hierarchies.
To demonstrate this, we’re going to build a water rippling effect: we’re going to write code
that makes any SwiftUI views have animated ripples, like they are being viewed through
water. This takes a bit of thinking because it involves multiple parts:
1. We’ll have a fragment shader, which is the named used for a tiny program able to
manipulate what is effectively a single pixel in a texture. Modern CPUs have hundreds or
even thousands of tiny GPU cores dedicated to running these shaders, which means a
shader can potentially run hundreds of millions of times every second.
2. We’ll run that program inside an SKScene subclass, which is the SpriteKit equivalent to
View.
3. That SKScene subclass will be rendered from inside a SwiftUI view, which will make sure
it’s configured correctly.
4. We’ll then render that view inside another, adding some controls so you can experiment
with the water effect.
That sounds like a lot of work because it is a lot of work, but trust me: this effect is incredible,
and most definitely worth it.
Tip: If you’re a coffee drinker, make a brew now – there’s quite a lot to digest here.
To get started, add import SpriteKit to the top of your file. Now add the following the
following class:
232 www.hackingwithswift.com
Magic with SpriteKit
This scene needs to have three properties: one for the sprite it will display, which will be our
SwiftUI view with the water effect applied, and a second the UIImage we want to place inside
there. We’ll be making that image dynamically, but we still need to store it somewhere.
Important: UIImage is available only iOS, watchOS, and macOS Catalyst. If you intend to
target macOS, use NSImage instead.
I mentioned a third property, and this is the tricky one: we need to create our shader, which
means writing some fragment shader code. This is not Swift code – it’s much, much lower-
level than that, because of the need to run literally millions of times every second.
SpriteKit lets us write shaders either in GLSL (the OpenGL Shading Language) or MSL (the
Metal shading language). Both these two get compiled on the device when the app runs, so it
can be fully optimized for whatever hardware it’s running on.
Of the two options, GLSL is significantly simpler than MSL, and helpfully the system will
automatically convert GLSL to MSL when compiling our shader. So, we’ll be using GLSL
here – it’s just as fast, but way less code for us.
www.hackingwithswift.com 233
Drawing and Effects
That creates a main() function in our shader, with a single comment inside. We’re going to
write that line by line, so I can explain what it all does. Before I dive into the code, though, I
want to summarize how the effect works:
All set?
Okay, the first step is to figure out how fast we want our effect to happen. This can be done by
reading two values that will be passed in externally: u_time will tell us how much time has
passed since the shader was created, and u_speed will tell us how fast the user has requested
for the water to move. If we multiply those two together we are effectively making time move
faster, so the water will ripple more quickly.
Next we need to decide which pixel we’re going to read. Here we need to use several more
external values: v_tex_coord will tell us which pixel we were asked to read, u_frequency will
contain how fast the user wants ripples to be created, and u_strength will tell us how strong
the user wants the effect to be.
Figuring out which pixel to use involves a small amount of mathematics, so let me break it
down:
• We already set speed to the current time multiplied by how fast the user wants the ripples
to happen.
• If we take multiply that speed by the ripple frequency it means the pixel we choose will
234 www.hackingwithswift.com
Magic with SpriteKit
Here’s a worked example, assuming our X coordinate is 22, our speed is 8, our frequency is 4
and our strength is 2:
So, we read 1.62 pixels to the left – yes it’s not a whole number, but Metal will figure it out.
Alternatively, if we were reading X:23 rather than X:22, we would get cos((23 + 8) * 4) * 2,
which is approximately -0.19, so the pixel offset we read is slightly different, which creates the
ripple effect.
Tip: Remember, v_tex_coord tells us which X/Y coordinates we were asked to read.
To get the Y offset, we do the same thing as X except now using sin() rather than cos():
www.hackingwithswift.com 235
Drawing and Effects
At this point we now know exactly which pixel we want to read, so we need to read that value
and send it back. GLSL provides three helpers here:
Tip: If we try and read pixels from outside the bounds of the texture, Metal will automatically
wrap them around to the other side for us. As a result, it usually works better if your SwiftUI
views have a little padding around them to avoid this wrapping.
I know it took a lot of explaining, but the entire function is really just this:
void main() {
float speed = u_time * u_speed;
Fragment shaders are easy to write once you get the hang of them – this one is taken from a
huge library of them I wrote for my ShaderKit repository, which you can find here: https://
236 www.hackingwithswift.com
Magic with SpriteKit
github.com/twostraws/ShaderKit/.
We’re done with the shader now, but we still have some SpriteKit work to do. First, when our
scene loads we need to do some quick set up work:
This can all be done through the sceneDidLoad() method, which is the SpriteKit equivalent to
viewDidLoad() from UIKit. Add this method to the class now:
spriteNode.shader = waterShader
addChild(spriteNode)
}
Next we need to write an updateTexture() method, that will convert our UIImage into a
texture, place it into the sprite, and make sure it’s all sized and positioned correctly.
func updateTexture() {
guard view != nil else { return }
guard let image else { return }
www.hackingwithswift.com 237
Drawing and Effects
spriteNode.position.x = frame.midX
spriteNode.position.y = frame.midY
}
The very first line of updateTexture() checks whether our scene is currently being shown in a
view, because if it isn’t we silently exit to avoid wasting time. When our scene actually is
being moved into a real view, SpriteKit will call a separate method named didMove(to:), and
that’s our chance to call updateTexture() so everything gets positioned correctly.
That finishes all our WaterScene code, so we’re half way there!
The next step is to create a SwiftUI view that’s responsible for creating and managing our
water scene. This is the bridge between SpriteKit and the rest of our SwiftUI app – we want
this thing to be neatly encapsulated so that we don’t have to worry about SpriteKit every time
we use it.
Start by creating a new SwiftUI view called WaterEffect, and give it this code:
238 www.hackingwithswift.com
Magic with SpriteKit
That doesn’t have a real body property yet, but before we add that I want to explain what we
have so far:
1. We have a WaterScene instance stored as state, meaning that SwiftUI will keep it alive
without watching it for changes.
2. We have properties to store the three user-configurable effect inputs: speed, strength, and
frequency.
3. Most importantly, we also have a view builder that will generate some kind of SwiftUI
content. This is what we’ll be using in our game scene.
Tip: We need to make the struct generic so that it’s able to render any kind of SwiftUI views.
Inside the body property comes the real work. You see, our content property will contain a
whole bunch of SwiftUI views, and we need to pass those to the water scene to render. That
thing expects a UIImage, not SwiftUI views, so the first thing we need to do is render those
SwiftUI views as an image.
This can be done using the ImageRenderer struct, which accepts any views as its input and
has a uiImage property that contains the resulting image.
Tip: If you’re using macOS, access the nsImage property rather than uiImage.
Now that we have our view image ready to go, we can read its size or replace it with .zero if
there was a problem:
www.hackingwithswift.com 239
Drawing and Effects
Next we need to send all those user values into the shader. We have speed, strength, and
frequency as properties of this view, and our shader expects u_speed, u_strength, and
u_frequency to be provided in order to work. All those “u” names are there for a reason: these
are called uniforms, which are the name for fixed values we assign to a shader to customize it.
These are specified by name and value, with the names matching the names used inside the
shader code.
There is one small complication here, but it’s best explained after you see the code. Add this to
your body property next:
scene.waterShader.uniforms = [
SKUniform(name: "u_speed", float: Float(speed)),
SKUniform(name: "u_strength", float: Float(strength) / 20.0),
SKUniform(name: "u_frequency", float: Float(frequency))
]
Do you see the complication? Rather than sending our strength value in directly, we divide it
by 20 to make it a lot smaller – we only need very small offsets to make this work, but it’s hard
to work with very small values.
Moving on, we can now assign our new image to the scene, and call its updateTexture()
method:
scene.image = image
scene.updateTexture()
Finally, we can send back a new SpriteView with that scene in place, making sure to enable
transparency on it and also give it a frame matching our rendered view’s size:
You’ll be pleased to know we’re now effectively done with our water code – almost
240 www.hackingwithswift.com
Magic with SpriteKit
Our WaterEffect view is capable of rendering any kind of SwiftUI view, so we’re going to
create a little sandbox that renders adds some user customization points.
LabeledContent("Speed") {
Slider(value: $speed)
}
LabeledContent("Strength") {
Slider(value: $strength)
}
LabeledContent("Frequency") {
Slider(value: $frequency, in: 5...25)
www.hackingwithswift.com 241
Drawing and Effects
}
}
.padding()
}
}
What should go in place of that // Views to render here comment? It’s down to you!
Something like this is a good test of the effect:
Circle()
.fill(.red)
.frame(width: 150, height: 150)
.padding()
.overlay(Circle().stroke(.red, lineWidth: 4))
.overlay(Text(text).font(.title).foregroundColor(.white))
.padding()
Reminder: Having some padding around your view is a good idea, because it avoids Metal
wrapping pixel coordinates around at the edges.
242 www.hackingwithswift.com
Magic with SpriteKit
Now try running the effect and see what you think! You’ll see you can type into the text box to
have the water effect update immediately, and of course dragging the sliders around is also
instant. You might also notice how low the CPU usage is – Metal is fast!
There’s one last thing I want to do before we’re done, and it’s to fix a small problem you might
not have even noticed. You see, when using ImageRenderer to convert views into images,
SwiftUI will use an image scale of 1.0, which means a 400x400 view will be rendered at
400x400 pixels. That might sound good, but keep in mind that all modern devices have screen
resolutions that are either 2x or 3x resolution, which means on an iPhone 14 a 400x400 view
should render into a 1200x1200 image.
To fix this, we need to add a property to the WaterEffect view that will read the correct scale
for the display:
www.hackingwithswift.com 243
Drawing and Effects
It’s a small difference, but it does mean the rendered SwiftUI views will look pin sharp now,
just as nature intended.
That’s our code all finished now, so go and experiment! And if you’re feeling really brave, try
adapting some of the other effects in https://github.com/twostraws/ShaderKit – you’ll see a
whole bunch of them in the Shaders directory.
244 www.hackingwithswift.com
Chapter 6
Performance
www.hackingwithswift.com 245
Delaying work…
There are all sorts of tips to help you make your SwiftUI code as fast as possible, but the best
place to start is with this rule: the fastest code is the code that never executes. In SwiftUI terms
this means taking steps to skip work where possible, or at least delaying work as much as
possible.
For example, if you’re updating your user interface as the user interacts with it, does it need to
update for every single small change the user makes – for every letter they types, or even pixel
they drag a slider? If you’re generating a QR code from their data, or perhaps even using the
SpriteKit water effect we made in an earlier chapter, re-rendering for every pixel of a slider
drag is clearly overkill.
For many operations, you’ll often find that introducing a small debounce dramatically
improves performance while sacrificing little to none of your user experience. Debouncing is
the practice of waiting for a certain amount of time before taking an action. For example, if
we’re debouncing a text field for 1 second, the user needs to stop typing for a full second
before we update our app state – they can type as much as they want and no work will happen
until they finally stop.
Even a debounce as low as 0.1 – a tenth of a second! – can lead to huge performance
improvements, because the alternative might be updating views as 120fps, which equates to
just 0.008 of a second.
We can implement a simple generic debounce system using Combine, which will:
• Be generic over some kind of T, because we don’t care what kind of data we’re trying to
debounce.
• Have two published properties: one for the input value, and one for the output. The input
property will always be the latest value, and the output property will be the most recently
debounced version.
• Have a private AnyCancellable property that stores our actual debouncing work. This
comes from the Combine framework, and allows us to cancel the work if needed.
246 www.hackingwithswift.com
Delaying work…
import Combine
We need to write an initializer that accepts two values from the user: the initial value to use for
both the input and output (because they will be the same to start with), and also how long we
want our debounce to wait for.
The way this works is simple: elsewhere in SwiftUI we’ll adjust the input property directly,
but we’ll attach to that Combine debounce() and sink() operators so that after a period of time
has passed we copy the value from input into output. Because output uses @Published,
changing it will automatically cause any observing SwiftUI views to reinvoke their body
property.
debounce = $input
.debounce(for: .seconds(delay), scheduler:
DispatchQueue.main)
.sink { [weak self] in
self?.output = $0
www.hackingwithswift.com 247
Performance
}
}
And that’s it – that’s our entire Debouncer class done already. To try it out, create instances of
it using @StateObject, then bind your controls to input and your readouts to output. For
example, this debounces a text field and a slider:
Spacer().frame(height: 50)
I encourage you to try that out, because you’ll see how our view waits until the user pauses for
a moment before reloading.
For work that takes place outside of bindings, use Swift’s Task to spin off the work, sleep for
some amount of time, then execute it after the delay finishes. This works well because if you
need to schedule the same work while the sleep is happening, you can cancel the task and
248 www.hackingwithswift.com
Delaying work…
As an example, this view model can do some work immediately, or schedule some work to
happen after a 3-second delay:
func doWorkNow() {
workCounter += 1
print("Work done: \(workCounter)")
}
func scheduleWork() {
refreshTask?.cancel()
refreshTask = Task {
try await Task.sleep(until: .now + .seconds(3),
clock: .continuous)
doWorkNow()
}
}
}
Note: If you need to support older versions of Apple’s operating systems, use
Task.sleep(nanoseconds: 3_000_000_000) to get the same result.
That view model exposes both scheduleWork() and doWorkNow(), so you can choose which
you use depending on circumstances:
www.hackingwithswift.com 249
Performance
250 www.hackingwithswift.com
…or skipping it entirely
Delaying work is a good start, not least because it helps your apps load faster and keeps your
views responsive more of the time. Even better is avoiding work entirely – looking for ways to
skip work that isn’t necessary, beyond things I’ve covered elsewhere such as preferring ternary
conditionals to if in a view builder.
For example, it’s common to write SwiftUI code that interacts with classes that either don’t
conform to ObservableObject, or do conform but we don’t happen to care in this
circumstance. In these places you should use @State to store a reference to the class, as
opposed to either let (which would destroy and reallocate the class instance every time the
view was recreated) or @StateObject (which would monitor the object for changes).
You can see this in action in the following code, which uses @State to store a Core Image
context – something that is resource-intensive to create, and so is best kept alive:
import CoreImage
import CoreImage.CIFilterBuiltins
import SwiftUI
www.hackingwithswift.com 251
Performance
Using @State like this is effectively using it as a cache – we’re storing something persistently,
but not trying to do any change tracking.
Another common place where wasted work happens is in onAppear(). First I should say that
using onAppear() is broadly a good thing: it’s much, much better to take work out of view
initializers and put it into onAppear() where possible, because initializers are called far more
frequently than onAppear().
That being said, onAppear() is still called whenever a view is presented, so if you’re putting
work in there you might find it’s being repeated pointlessly. You can see the problem in this
example code:
252 www.hackingwithswift.com
…or skipping it entirely
TabView {
ForEach(1..<6) { i in
ExampleView(number: i)
.tabItem { Label(String(i), systemImage: "\
(i).circle") }
}
}
}
}
When that runs, you’ll see that moving between the tabs repeats the onAppear() code, even
when you’ve already visited a tab. Is that what you want? If so, you have nothing to worry
about, but if you were using onAppear() as a way to delay initialization of values for this
view, you might find you only really want that work to happen once.
The good news is that we can do better: we can create an onFirstAppear() modifier that runs
its closure only when a view appears for the very first time:
www.hackingwithswift.com 253
Performance
extension View {
func onFirstAppear(perform: @escaping () -> Void) -> some
View {
modifier(OnFirstAppearModifier(perform: perform))
}
}
And now you have the option of using onAppear() when you want some code to always run
on appearance, or onFirstAppear() when you want to do the work only once:
Text("View \(number)")
.onFirstAppear {
print("View \(number) appearing")
}
Finally, if you’re targeting multiple platforms, don’t be afraid to write a handful of platform-
specific modifiers that lock off certain code on a case-by-case basis. For example, if you want
some text to have zero padding on watchOS, you can add a method like this to make such a
change apply only to watchOS:
254 www.hackingwithswift.com
…or skipping it entirely
modifier(self)
#else
self
#endif
}
}
That does nothing at all for all operating systems apart from watchOS, so the Swift compiler
will optimize it out at build time. Use it like this:
Text("Hello, world!")
.watchOS {
$0.padding(0)
}
www.hackingwithswift.com 255
Watching for changes
One of the most common SwiftUI performance sinks is recomputing views as a result of
change notifications.
When these happen as a result of @State changes it’s usually obvious: the user typed into your
text field or dragged your slider, and so your view’s body property needs to be reinvoked.
That’s unavoidable for the most part. Where things get more complex is when some external
object changes and your view is recomputed – what changed and why?
There are a handful of techniques I use here, any or all of which might prove useful to you
depending on the circumstances.
First, here’s a simple set up that triggers external notifications on a regular basis:
init() {
timer = Timer.scheduledTimer(withTimeInterval: 0.1,
repeats: true) { _ in
self.objectWillChange.send()
}
}
}
256 www.hackingwithswift.com
Watching for changes
If you run that you’ll see it looks and works just like any other SwiftUI view – there’s no
indication it’s doing vast amounts of work thanks to the change notifications coming in.
To address this, I want to introduce you to a neat trick I learned from Peter Steinberger, which
is to create a random color extension such as this one:
With that in place it becomes obvious that are view is being recomputed, because the text will
literally flash through colors on the screen:
That doesn’t explain why something changed, though, and for that you need the
View._printChanges() method. This can be called from inside any view’s body property, and
will automatically print what triggered a change.
Because of the way Swift’s result builders work, to use _printChanges() you either need to
assign it to a value such as _ (underscore), or add an explicit return to your view body. In the
first case you’d write this:
www.hackingwithswift.com 257
Performance
let _ = Self._printChanges()
Text("Example View Here")
}
Either way, you’ll see output such as ContentView: _viewModel changed, which is telling us
that the body property was reinvoked because that particular object announced a change.
This kind of performance problem is particularly common when apps grow over time. When
you’re just starting out with a small app, it’s common to create your view model right in your
App struct, and post it into the SwiftUI environment for all your views to share. But as your
app grows and more views depend on that data, you might come to find that a small change in
one view causes a cascade of refreshes to happen elsewhere.
Like I said when we looked at environment keys, every time you make a view use
@ObservableObject, you are effectively creating a dependency on that data – you’re telling
SwiftUI to refresh your view when that data changes, even if your view doesn’t actually care
about the precise thing that changed.
This isn’t a theoretical problem, and in fact it’s something I hit with my own Control Room
app for macOS – it’s on GitHub here: https://github.com/twostraws/ControlRoom. This
project started out small but grew extensively over time, and suddenly @AppStorage values
that were fine before were causing significant performance problems – typing into a text field
took a whole second for every character to appear!
Do you need to make your view dependent on an external object? If so, does it need to be
every property on that object, or just part of it? We looked at how to solve this in the
258 www.hackingwithswift.com
Watching for changes
environment keys chapter, so if you find your views are refreshing more often than you’d like
that’s the best place to start.
If you’re stuck – if you have a view that’s changing and you’re not sure why even with
_printChanges() – then I recommend adding a handful of View extensions that let us inject
pieces of code directly into SwiftUI’s views.
extension View {
func debugPrint(_ value: @autoclosure () -> Any) -> some View
{
#if DEBUG
print(value())
#endif
return self
}
return self
}
return self
www.hackingwithswift.com 259
Performance
}
}
The first of those prints a message when the modifier is executed, the second runs arbitrary
code, and the third runs arbitrary code while also giving access to the current view. All three
use #if DEBUG to ensure the diagnostic code never leaves Xcode – as soon as you ship these
apps to the App Store that code will be compiled out completely.
If you’ve tried background colors, checked _printChanges(), and even tried using
debugExecute() to check what’s happening, and you’re still not sure where the problem is,
there’s one last option: adding an assert() modifier to View, so that you can check exactly
what’s happening and hopefully catch the problem.
extension View {
public func assert(
_ condition: @autoclosure () -> Bool,
_ message: @autoclosure () -> String = String(),
file: StaticString = #file, line: UInt = #line
) -> some View {
Swift.assert(condition(), message(), file: file, line:
line)
return self
}
}
That uses @autoclosure to avoid doing any work that isn’t needed, and also calls down to
Swift’s internal assert() function that gets compiled out of App Store releases.
260 www.hackingwithswift.com
Watching for changes
That adds an explicit, code-level check that counter must be less than 50 at all times, and as
soon as the view body is reinvoked when that isn’t true Xcode will stop execution and print the
message “Timer exceeded”.
www.hackingwithswift.com 261
The SwiftUI cycle of events
If there is one thing – just one thing – I would recommend everyone do in order to better
understand the sequence of events SwiftUI goes through when working with all our views and
modifiers, and in doing so get a much better idea of what code is being run when our apps
execute, it is this: create a bunch of test structs that represent your app, some views, modifiers,
properties, initializers, etc, and make them all print out messages explaining what’s going on.
It takes only a few minutes to do, and yet most people will be surprised what they see when it
runs.
If you’re not sure where to start, something like this is a great beginning:
@main
struct MyApp: App {
@State private var property = ExampleProperty(location:
"App")
return WindowGroup {
NavigationStack {
ContentView()
}
}
}
init() {
print("In App.init")
}
}
struct ExampleProperty {
262 www.hackingwithswift.com
The SwiftUI cycle of events
init(location: String) {
print("Creating ExampleProperty from \(location)")
}
}
www.hackingwithswift.com 263
Performance
init() {
print("In ContentView.init")
}
}
init() {
print("In DetailView.init")
}
}
When that runs you’ll see a whole lot of output, and while you use it some more will come out.
In particular, watch out for:
264 www.hackingwithswift.com
The SwiftUI cycle of events
two task() modifiers being executed in the order they appear in the code, but onAppear()
running before task().
5. Both the onAppear() and task() modifiers in DetailView only being run when the view is
shown.
The point of this exercise is to remind you that SwiftUI can create our views and reinvoke their
body properties as often as it wants, whenever it wants – even just launching our app run
DetailView.init() twice, and it’s not even visible. This is why it’s so absolutely critical to keep
your initializers as simple as possible: do no slow work in there, and under no circumstances
attempt to access network data from there!
Instead, push work back into onAppear() or task() where possible, so you allow SwiftUI to
call init() and body frequently without hiccups. These are called when your views are added to
the active view hierarchy – when they are being placed onto the screen – and so you can be
sure the work you’re doing is actually useful.
www.hackingwithswift.com 265