0% found this document useful (0 votes)
1K views265 pages

Pro Swiftui

The document is a guide to mastering SwiftUI, focusing on its core fundamentals to help users understand its underlying processes. It covers various topics including layout, animations, environment preferences, custom layouts, and performance optimizations. The author, Paul Hudson, emphasizes practical coding examples and encourages readers to explore SwiftUI's public interface for deeper insights.

Uploaded by

nasser
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
1K views265 pages

Pro Swiftui

The document is a guide to mastering SwiftUI, focusing on its core fundamentals to help users understand its underlying processes. It covers various topics including layout, animations, environment preferences, custom layouts, and performance optimizations. The author, Paul Hudson, emphasizes practical coding examples and encourages readers to explore SwiftUI's public interface for deeper insights.

Uploaded by

nasser
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
You are on page 1/ 265

HACKING WITH SWIFT

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

Layout and Identity 9


Parents and children
Fixing view sizes
Layout neutrality
Multiple frames
Inside TupleView
Understanding identity
Intentionally discarding identity
Optional views, gestures, and more

Animations and Transitions 60


Animating the unanimatable
Avoiding pain in iOS 15.6 and below
Creating animated views
Custom timing curves
Overriding animations
Advanced transitions

Environment and Preferences 106


The environment
@Environment vs @EnvironmentObject
Overriding the environment
Preferences
Anchor preferences

Custom Layouts 148


Adaptive layouts
Implementing a radial layout
Implementing an equal width layout
Implementing a relative width layout
Implementing a masonry layout
Layout caching

www.hackingwithswift.com 3
Customizing layout animations

Drawing and Effects 196


Drawing with Canvas
Falling snow
Creating a lava lamp
Blurred backgrounds
Magic with SpriteKit

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!

Working with SwiftUI’s public interface


At various points in this book I will ask you to run a specific command. It’s quite long, so I’ve
made a GitHub gist from it at this link: https://bit.ly/swiftinterface

6 www.hackingwithswift.com
Welcome

In case that link doesn’t work, here’s the command in full:

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

Frequent Flyer Club


You can buy Swift tutorials from anywhere, but I'm pleased, proud, and very grateful that you
chose mine. I want to say thank you, and the best way I have of doing that is by giving you
bonus content above and beyond what you paid for – you deserve it!

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.

This edition has the version 2022-10-25.

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:

1. A parent view proposes a size for its child.


2. Based on that information, the child then chooses its own size and the parent view must
respect that choice.
3. The parent view then positions the child in its coordinate space.

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!")

Whereas this is two views:

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:

@frozen public struct ModifiedContent<Content, Modifier>

A little further down you’ll also see its View conformance:

extension ModifiedContent : View where Content : View,


Modifier : ViewModifier {

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:

struct CustomFont: ViewModifier {


func body(content: Content) -> some View {
content.font(.largeTitle)
}
}

We could apply that to some text using ModifiedContent, like so:

struct ContentView: View {


var body: some View {
ModifiedContent(content: Text("Hello"), modifier:
CustomFont())
}
}

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.

So, when we write code like this:

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.

The six are:

• 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?

Okay, what we end up with is this:

• 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.

In their documentation, Apple describes fixedSize() as “the creation of a counter proposal to


the view size proposed to a view by its parent,” which is quite apt once you understand what’s
happening internally.

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.

In its purest form, layout neutrality it looks like this:

struct ContentView: View {


var body: some View {
Color.red
}
}

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

Now take a look at this code:

struct ContentView: View {


var body: some View {
Text("Hello, World!")
.frame(idealWidth: 300, idealHeight: 200)
.background(.red)
}
}

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.

Take a moment to pause and think about it before continuing.

If you break it down, the flow works like this:

• 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:

struct ContentView: View {


@State private var usesFixedSize = false

var body: some View {


VStack {
Text("Hello, World!")
.frame(width: usesFixedSize ? 300 : nil)
.background(.red)

Toggle("Fixed sizes", isOn: $usesFixedSize.animation())


}
}
}

What that code does at runtime depends on the value of usesFixedSize:

• 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.

To see an example of what I mean, try code like this:

ScrollView {
Color.red
}

What size can the scroll view propose to 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.

Still here? Okay:

• 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

each frame you’ll see exactly what’s happening:

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))
}

When that runs, you’ll see the type is ModifiedContent<VStack<TupleView<(Text,


Text)>>, AddGestureModifier<_EndedGesture<TapGesture>>>, but really the important
part in all that is TupleView<(Text, Text)> because that’s how SwiftUI encodes multiple
views: a special view type that accepts other views inside it.

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
}

static func buildPartialBlock<C0, C1>(accumulated: C0, next:


C1) -> TupleView<(C0, C1)> where C0: View, C1: View {
TupleView((accumulated, next))
}
}

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.

In SwiftUI these identities come in two forms:

• 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.

Try this, for example:

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

find this in the SwiftUI generated interface:

public static func buildEither<TrueContent,


FalseContent>(first: TrueContent) ->
_ConditionalContent<TrueContent, FalseContent> where
TrueContent : View, FalseContent : View

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
}

struct ContentView: View {


@State var loadState = ViewState.a

@ViewBuilder var state: some View {


switch loadState {
case .a:
Text("a")
case .b:
Image(systemName: "plus")
case .c:
Circle()
case .d:
Rectangle()
case .e:

40 www.hackingwithswift.com
Understanding identity

Capsule()
case .f:
RoundedRectangle(cornerRadius: 25)
}
}

var body: some View {


Button("Press") {
print(type(of: state))
}
}
}

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:

@ViewBuilder var body: Self.Body { get }

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.

You can see this in action with the following code:

struct ExampleView: View {


@State private var counter = 0

var body: some View {


Button("Tap Count: \(counter)") {
counter += 1
}
}
}

struct ContentView: View {


@State private var scaleUp = false

var body: some View {


VStack {
if scaleUp {
ExampleView()
.scaleEffect(2)
} else {
ExampleView()
}

Toggle("Scale Up", isOn: $scaleUp.animation())


}

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.

When you run that code you’ll notice two things:

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:

struct ExampleView: View {


@State private var counter = 0
let scale: Double

var body: some View {


Button("Tap Count: \(counter)") {
counter += 1

44 www.hackingwithswift.com
Understanding identity

}
.scaleEffect(scale)
}
}

struct ContentView: View {


@State private var scaleUp = false

var body: some View {


VStack {
if scaleUp {
ExampleView(scale: 2)
} else {
ExampleView(scale: 1)
}

Toggle("Scale Up", isOn: $scaleUp.animation())


}
.padding()
}
}

Where things get interesting is what happens if we add some explicit identity, like this:

var body: some View {


VStack {
if scaleUp {
ExampleView(scale: 2)
.id("Example")
} else {
ExampleView(scale: 1)
.id("Example")
}

www.hackingwithswift.com 45
Layout and Identity

Toggle("Scale Up", isOn: $scaleUp.animation())


}
.padding()
}

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:

struct ContentView: View {


@State private var scaleUp = false

var exampleView: some View {


if scaleUp {
return ExampleView(scale: 2)
.id("Example")
} else {

46 www.hackingwithswift.com
Understanding identity

return ExampleView(scale: 1)
.id("Example")
}
}

var body: some View {


VStack {
exampleView

Toggle("Scale Up", isOn: $scaleUp.animation())


}
.padding()
}
}

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?

The answer is that removing @ViewBuilder removes the _ConditionalContent struct


entirely, which means flipping between the two view states no longer means flipping between
two different “True” view and a “False” view.

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

them the same view.

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:

struct ContentView: View {


@State private var scaleUp = false

var body: some View {


VStack {
ExampleView(scale: scaleUp ? 2 : 1)

Toggle("Scale Up", isOn: $scaleUp.animation())


}
.padding()

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:

struct ContentView: View {


@State private var isNewMessage = false

var body: some View {


if isNewMessage {
Text("Message title here").bold()
} else {
Text("Message title here")
}
}
}

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”:

Text("Message title here").fontWeight(isNewMessage ? .bold :


nil)

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:

struct ContentView: View {


@State private var shouldHide = false

var body: some View {


VStack {
if shouldHide {
ExampleView(scale: 1)
.hidden()
} else {
ExampleView(scale: 1)
}

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:

struct ContentView: View {


@State private var items = Array(1...20)

var body: some View {


VStack(spacing: 0) {
List(items, id: \.self) {
Text("Item \($0)")
}

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:

List(items, id: \.self) {


Text("Item \($0)")
}
.id(UUID())

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()
}
}

Or we could add a custom transition rather than fading, like this:

List(items, id: \.self) {


Text("Item \($0)")
}
.id(UUID())
.transition(.asymmetric(insertion: .move(edge: .trailing),
removal: .move(edge: .leading)))

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:

struct ContentView: View {


let colors: [Color] =
[.blue, .cyan, .gray, .green, .indigo, .mint, .orange, .pink, .
purple, .red]
let symbols = ["run", "archery", "basketball", "bowling",
"dance", "golf", "hiking", "jumprope", "rugby", "tennis",
"volleyball", "yoga"]
@State private var id = UUID()

www.hackingwithswift.com 55
Layout and Identity

var body: some View {


VStack {
ZStack {
Circle()
.fill(colors.randomElement()!)
.padding()

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 it has the type _BackgroundStyleModifier<Color>.

In comparison, we could make the background optional, like this:

.background(Bool.random() ? Color.blue : nil)

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

wraps types that conform to those protocols, like this:

extension Optional : Commands where Wrapped : Commands


extension Optional : Gesture where Wrapped : Gesture
extension Optional : View where Wrapped : View

So, Optional conforms to Commands where the thing inside the optional also conforms to
Commands, etc.

This is what makes it possible to conditionally apply backgrounds or overlays, or to


conditionally enable a gesture based on some program state – use the gesture when your state
is true, or use nil otherwise to remove it.

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():

struct ContentView: View {


@State private var scale = 1.0

var body: some View {


Text("Hello, World!")
.scaleEffect(scale)
.onTapGesture {
withAnimation {
scale += 1
}
}
}
}

And we can use implicit animations instead:

struct ContentView: View {


@State private var scale = 1.0

var body: some View {


Text("Hello, World!")
.scaleEffect(scale)
.onTapGesture {
scale += 1
}
.animation(.default, value: scale)

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:

struct ContentView: View {


@State private var redAtFront = false
let colors: [Color] =
[.blue, .green, .orange, .purple, .mint]

var body: some View {


VStack {
Button("Toggle zIndex") {
withAnimation(.linear(duration: 1)) {
redAtFront.toggle()
}
}

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

struct AnimatableZIndexModifier: ViewModifier, Animatable {


var index: Double

func body(content: Content) -> some View {


content
.zIndex(index)
}
}

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)

However, that still won’t work – no animation will take place.

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:

var animatableData: Double {


get { index }
set { index = newValue }
}

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:

var animatableData: Double {


get { index }
set { print(newValue); index = newValue }
}

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:

struct AnimatableFontModifier: ViewModifier, Animatable {


var size: Double

var animatableData: Double {


get { size }
set { size = newValue }
}

func body(content: Content) -> some View {


content
.font(.system(size: size))
}
}

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))
}
}

And now you can create a view to use it:

struct ContentView: View {


@State private var scaleUp = false

var body: some View {


Text("Hello, World!")
.animatableFont(size: scaleUp ? 56 : 24)
.onTapGesture {
withAnimation(.spring(response: 0.5, dampingFraction:

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:

struct ContentView: View {


@State private var isRed = false

var body: some View {


Text("Hello, World!")
.foregroundColor(isRed ? .red : .blue)
.font(.largeTitle.bold())
.onTapGesture {
withAnimation {
isRed.toggle()
}
}
}
}

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:

struct CountingText: View, Animatable {


var value: Double
var fractionLength = 8

var body: some View {

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:

var animatableData: Double {


get { value }
set { value = newValue }
}

Now we can go ahead and use it just like any other view:

struct ContentView: View {

70 www.hackingwithswift.com
Creating animated views

@State private var value = 0.0

var body: some View {


CountingText(value: value)
.onTapGesture {
withAnimation(.linear) {
value = Double.random(in: 1...1000)
}
}
}
}

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.

The simplest solution looks like this:

struct TypewriterText: View, Animatable {


var string: String
var count = 0

var animatableData: Double {


get { Double(count) }
set { count = Int(max(0, newValue)) }
}

var body: some View {

www.hackingwithswift.com 71
Animations and Transitions

let stringToShow = String(string.prefix(count))


Text(stringToShow)
}
}

We could then use it something like this:

struct ContentView: View {


@State private var value = 0
let message = "This is a very long piece of text that appears
letter by letter."

var body: some View {


VStack {
TypewriterText(string: message, count: value)
.frame(width: 300, alignment: .leading)

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.

First, add two properties to the view:

@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)
}

static func edgeBounce(duration: TimeInterval = 0.2) ->


Animation {
Animation.timingCurve(0, 1, 1, 0, duration: duration)
}
}

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:

struct ContentView: View {


@State private var offset = -200.0

var body: some View {

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.

We can implement this ourselves:

extension Animation {
static var easeInOutBack: Animation {
Animation.timingCurve(0.5, -0.5, 0.5, 1.5)
}

static func easeInOutBack(duration: TimeInterval = 0.2) ->


Animation {
Animation.timingCurve(0.5, -0.5, 0.5, 1.5, duration:
duration)
}
}

Or create a stronger effect by increasing the steepness of the curve:

static var easeInOutBackSteep: Animation {

76 www.hackingwithswift.com
Custom timing curves

Animation.timingCurve(0.7, -0.5, 0.3, 1.5)


}

static func easeInOutBackSteep(duration: TimeInterval = 0.2) ->


Animation {
Animation.timingCurve(0.7, -0.5, 0.3, 1.5, duration:
duration)
}

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:

func withMotionAnimation<Result>(_ animation: Animation?


= .default, _ body: () throws -> Result) rethrows -> Result {
if UIAccessibility.isReduceMotionEnabled {
return try body()
} else {
return try withAnimation(animation, body)
}
}

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.

Use it like this:

struct ContentView: View {


@State var scale = 1.0

www.hackingwithswift.com 79
Animations and Transitions

var body: some View {


Button("Tap Me") {
withMotionAnimation {
scale += 1
}
}
.scaleEffect(scale)
}
}

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:

struct ContentView: View {


@State var scale = 1.0

var body: some View {


Button("Tap Me") {
withMotionAnimation {
scale += 1
}
}
.scaleEffect(scale)
.animation(.default, value: scale)
}
}

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

struct MotionAnimationModifier<V: Equatable>: ViewModifier {


@Environment(\.accessibilityReduceMotion) var
accessibilityReduceMotion

let animation: Animation?


let value: V

func body(content: Content) -> some View {


if accessibilityReduceMotion {
content
} else {
content.animation(animation, value: value)
}
}
}

As always, adding a View extension makes this much easier to use:

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

.motionAnimation(.default, value: scale)

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.

In particular, what we care about is the disablesAnimations property of transactions, which


lets us disable implicit animations that would otherwise be part of this update.

So, we could disable our implicit animation like this:

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

it all up in one place:

func withoutAnimation<Result>(_ body: () throws -> Result)


rethrows -> Result {
var transaction = Transaction()
transaction.disablesAnimations = true
return try withTransaction(transaction, body)
}

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:

func withHighPriorityAnimation<Result>(_ animation: Animation?


= .default, _ body: () throws -> Result) rethrows -> Result {
var transaction = Transaction(animation: animation)
transaction.disablesAnimations = true
return try withTransaction(transaction, body)
}

We can now write code like this:

struct ContentView: View {


@State var scale = 1.0

www.hackingwithswift.com 83
Animations and Transitions

var body: some View {


Button("Tap Me") {
withHighPriorityAnimation(.linear(duration: 3)) {
scale += 1
}
}
.scaleEffect(scale)
.animation(.default, value: scale)
}
}

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.

So far we have looked at:

• Disabling explicit animations based on Reduce Motion


• Disabling implicit animations based on Reduce Motion
• Disabling implicit animations on a case-by-case basis
• Replacing implicit animations with an explicit animation on a case-by-case basis

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:

struct CircleGrid: View {


var useRedFill = false

var body: some View {


LazyVGrid(columns: [.init(.adaptive(minimum: 64))]) {
ForEach(0..<30) { i in
Circle()
.fill(useRedFill ? .red : .blue)
.frame(height: 64)
}
}
}
}

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:

struct ContentView: View {


@State var useRedFill = false

var body: some View {


VStack {
CircleGrid(useRedFill: useRedFill)

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:

1. A circle grows out from the center.


2. That circle then shrinks from the inside out.
3. Some colorful confetti pieces fly out from the edges of the circle.
4. The filled heart icon springs out from the center, and bounces to its final position.

If we take screen captures of it along the way it looks like this:

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.

Add this new view modifier now:

struct ConfettiModifier: ViewModifier {


private let speed = 0.3

var color: Color


var size: Double

func body(content: Content) -> some View {


content
}
}

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.

So, add this extension:

extension AnyTransition {
static var confetti: AnyTransition {
.modifier(
active: ConfettiModifier(color: .blue, size: 3),
identity: ConfettiModifier(color: .blue, size: 3)
)
}

static func confetti(color: Color = .blue, size: Double =

90 www.hackingwithswift.com
Advanced transitions

3.0) -> AnyTransition {


AnyTransition.modifier(
active: ConfettiModifier(color: color, size: size),
identity: ConfettiModifier(color: color, size: size)
)
}
}

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:

struct ContentView: View {


@State private var isFavorite = false

var body: some View {


VStack(spacing: 60) {
ForEach([Font.body, Font.largeTitle, Font.system(size:
72)], id: \.self) { font in
Button {
isFavorite.toggle()
} label: {
if isFavorite {
Image(systemName: "heart.fill")
.foregroundStyle(.red)
.transition(.confetti(color: .red, size: 3))
} else {
Image(systemName: "heart")
.foregroundStyle(.gray)
}
}
.font(font)
}
}

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:

@State private var circleSize = 0.00001

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.

Go ahead and modify your body() method to this:

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:

@State private var strokeMultiplier = 1.0

Second, modify your strokeBorder() modifier to multiply the line width by that multiplier:

.strokeBorder(color, lineWidth: proxy.size.width / 2 *


strokeMultiplier)

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”.

Add these three to ConfettiModifier now:

@State private var confettiIsHidden = true


@State private var confettiMovement = 0.7
@State private var confettiScale = 1.0

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.

Add these two:

withAnimation(.easeOut(duration: speed).delay(speed * 1.25)) {


confettiIsHidden = false
confettiMovement = 1.2
}

withAnimation(.easeOut(duration: speed).delay(speed * 2)) {


confettiScale = 0.00001
}

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.

Add this modifier now:

www.hackingwithswift.com 97
Animations and Transitions

.frame(width: size + sin(Double(i)), height: size +


sin(Double(i)))

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.

Now, we could do this using the following modifier:

.offset(x: proxy.size.width / 2 * confettiMovement)

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:

.offset(x: proxy.size.width / 2 * confettiMovement +


(i.isMultiple(of: 2) ? size : 0))

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.

Add this modifier now:

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.

Add this modifier now:

.offset(x: (proxy.size.width - size) / 2, y: (proxy.size.height


- size) / 2)

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:

@State private var contentsScale = 0.00001

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.

Add this final code now:

withAnimation(.interpolatingSpring(stiffness: 50, damping:


5).delay(speed)) {
contentsScale = 1
}

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.

Honestly, this takes very little work to do, so give it a try!

First, we need to make the modifier generic over some kind of ShapeStyle:

struct ConfettiModifier<T: ShapeStyle>: ViewModifier {

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:

static func confetti<T: ShapeStyle>(color: T = .blue, size:


Double = 3.0) -> AnyTransition {

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))

Or we could provide a wholly custom gradient for something really bright:

.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:

@inlinable public func font(_ font: SwiftUI.Font?) -> some


SwiftUI.View {
return environment(\.font, font)
}

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:

struct FormElementIsRequiredKey: EnvironmentKey {


static var defaultValue = false
}

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:

struct RequirableTextField: View {


@Environment(\.required) var required

110 www.hackingwithswift.com
The environment

let title: String


@Binding var text: String

var body: some View {


HStack {
TextField(title, text: $text)

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:

struct ContentView: View {


@State private var firstName = ""

var body: some View {


Form {
RequirableTextField(title: "First name", text:
$firstName)
.environment(\.required, true)
}
}
}

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:

RequirableTextField(title: "First name", text: $firstName)


.required()

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?

Well, consider code like this:

112 www.hackingwithswift.com
The environment

struct ContentView: View {


@State private var firstName = ""
@State private var lastName = ""

@State private var makeRequired = false

var body: some View {


Form {
RequirableTextField(title: "First name", text:
$firstName)
RequirableTextField(title: "Last name", text: $lastName)
Toggle("Make required", isOn: $makeRequired.animation())
}
.required(makeRequired)
}
}

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.

First, we need to define the custom environment key and an extension on


EnvironmentValues:

struct StrokeWidthKey: EnvironmentKey {


static var defaultValue = 1.0
}

extension EnvironmentValues {
var strokeWidth: Double {
get { self[StrokeWidthKey.self] }
set { self[StrokeWidthKey.self] = newValue }
}
}

114 www.hackingwithswift.com
The environment

We could even add a View extension if you wanted:

extension View {
func strokeWidth(_ width: Double) -> some View {
environment(\.strokeWidth, width)
}
}

Now we can go ahead and use it with some drawing:

struct CirclesView: View {


@Environment(\.strokeWidth) var strokeWidth

var body: some View {


ForEach(0..<3) { _ in
Circle()
.stroke(.red, lineWidth: strokeWidth)
}
}
}

And then set that value at some higher point in the environment, like this:

struct ContentView: View {


@State private var sliderValue = 1.0

var body: some View {


VStack {
CirclesView()
Slider(value: $sliderValue, in: 1...10)
}
.strokeWidth(sliderValue)

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:

struct TitleFontKey: EnvironmentKey {


static var defaultValue = Font.custom("Georgia", size: 34)
}

www.hackingwithswift.com 117
Environment and Preferences

extension EnvironmentValues {
var titleFont: Font {
get { self[TitleFontKey.self] }
set { self[TitleFontKey.self] = newValue }
}
}

Again, we could make a View extension to make it easier to access:

extension View {
func titleFont(_ font: Font) -> some View {
environment(\.titleFont, font)
}
}

Now we can send both values into the environment, like this:

struct ContentView: View {


@State private var sliderValue = 1.0
@State private var titleFont = Font.largeTitle

var body: some View {


VStack {
CirclesView()
Text("Hello, world!")
.font(titleFont)

Slider(value: $sliderValue, in: 1...10)

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:

var body: some View {


print("In CirclesView.body")

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:

class ThemeManager: ObservableObject {


@Published var strokeWidth = 1.0
@Published var titleFont = TitleFontKey.defaultValue
}

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:

struct CirclesView: View {


@EnvironmentObject var theme: ThemeManager

var body: some View {


print("In CirclesView.body")

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:

struct ContentView: View {


@StateObject private var theme = ThemeManager()

var body: some View {


VStack {
CirclesView()
Text("Hello, world!")
.font(theme.titleFont)

Slider(value: $theme.strokeWidth, in: 1...10)

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:

struct DefaultTheme: Theme {


var strokeWidth = 1.0
var titleFont = TitleFontKey.defaultValue
}

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:

class ThemeManager: ObservableObject {


@Published var activeTheme: any Theme = DefaultTheme()

static var shared = ThemeManager()


private init() { }
}

Now we can expose all that to the environment, focusing only on the internal Theme struct:

struct ThemeKey: EnvironmentKey {


static var defaultValue: any Theme =
ThemeManager.shared.activeTheme
}

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:

struct ThemeModifier: ViewModifier {


@ObservedObject var themeManager = ThemeManager.shared

www.hackingwithswift.com 123
Environment and Preferences

func body(content: Content) -> some View {


content.environment(\.theme, themeManager.activeTheme)
}
}

And now we can wrap it up in a View extension for easier use:

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:

@ObservedObject var theme = ThemeManager.shared

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:

struct CirclesView: View {


@Environment(\.theme) var theme

var body: some View {


print("In CirclesView.body")

return ForEach(0..<3) { _ in
Circle()

124 www.hackingwithswift.com
@Environment vs @EnvironmentObject

.stroke(.red, lineWidth: theme.strokeWidth)


}
}
}

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:

struct CirclesView: View {


@Environment(\.theme.strokeWidth) var strokeWidth

var body: some View {


print("In CirclesView.body")

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

safety of using environment keys rather than environment objects.

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:

struct WelcomeView: View {


var body: some View {
VStack {
Image(systemName: "sun.max")
Text("Welcome!")
}
}
}

We could then use it somewhere else, adjusting its font as needed:

struct ContentView: View {


var body: some View {
WelcomeView()
.font(.largeTitle)
}
}

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

We could try adding a font() modifier to it, like this:

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.

So, a much better solution is to write this:

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

Our own preferences work in a similar way to navigationTitle():

• Any view can add them.


• They flow upwards through our views.
• We need to choose one value to use.

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:

struct WidthPreferenceKey: PreferenceKey {


static let defaultValue = 0.0

static func reduce(value: inout Double, nextValue: () ->


Double) {
value = nextValue()
}
}

Now we can make a view that sets a value for that preference, like this:

struct SizingView: View {


@State private var width = 50.0

132 www.hackingwithswift.com
Preferences

var body: some View {


Color.red
.frame(width: width, height: 100)
.onTapGesture {
width = Double.random(in: 50...160)
}
.preference(key: WidthPreferenceKey.self, value: width)
}
}

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:

struct ContentView: View {


@State private var width = 0.0

var body: some View {


NavigationStack {
VStack {
SizingView()
}
.onPreferenceChange(WidthPreferenceKey.self) { width =
$0 }
.navigationTitle("Width: \(width)")
}
}
}

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

and be reflected in the navigation view.

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:

struct ContentView: View {


@State private var width = 0.0

var body: some View {


NavigationStack {
VStack {
SizingView()

Text("100%")
.frame(width: width)
.background(.red)

Text("150%")

134 www.hackingwithswift.com
Preferences

.frame(width: width * 1.5)


.background(.green)

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:

static func reduce(value: inout Double, nextValue: () ->


Double) {
value += nextValue()
}

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:

static func reduce(value: inout Double, nextValue: () ->


Double) {

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:

struct Category: Identifiable, Equatable {


let id: String
let symbol: String
}

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:

struct CategoryPreference: Equatable {


let category: Category
let anchor: Anchor<CGRect>
}

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

struct CategoryPreferenceKey: PreferenceKey {


static let defaultValue = [CategoryPreference]()

www.hackingwithswift.com 139
Environment and Preferences

static func reduce(value: inout [CategoryPreference],


nextValue: () -> [CategoryPreference]) {
value.append(contentsOf: nextValue())
}
}

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.

It looks like this:

struct CategoryButton: View {


var category: Category
@Binding var selection: Category?

var body: some View {


Button {
withAnimation {
selection = category
}
} label: {
VStack {
Image(systemName: category.symbol)
Text(category.id)
}
}
.buttonStyle(.plain)

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.

Add this ContentView code now:

struct ContentView: View {


@State private var selectedCategory: Category?

let categories = [
Category(id: "Arctic", symbol: "snowflake"),
Category(id: "Beach", symbol: "beach.umbrella"),
Category(id: "Shared Homes", symbol: "house")
]

var body: some View {


VStack {
HStack(spacing: 20) {
ForEach(categories) { category in
CategoryButton(category: category, selection:
$selectedCategory)
}
}
}
}
}

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.

All this is done by adding this single modifier to CategoryButton, below


accessibilityLabel():

.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

.frame(width: frame.width, height: 2)


.position(x: frame.midX, y: frame.maxY)
}
}
}

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!

Anyway, let’s break down the code:

1. We use overlayPreferenceValue() to specify we want to read in a particular preference key


and convert it into an overlay. As a reminder, that means we want SwiftUI to place some
kind of view over our VStack, and we’ll use the preference keys to figure out what that
view should be.
2. Inside our overlay we use a GeometryReader so we can evaluate our geometry somehow.
This will automatically expand to fill all the available space, which is fine as an overlay
because it will naturally fit the same space as the view it overlays.
3. We look through all the category preferences for whichever one matches the selected
category.
4. If we find a match, we pass the selected preference’s anchor into our geometry proxy,
which performs the conversion into a finished frame representing where that anchor is in
the coordinates of our current GeometryReader.
5. Now we have a real frame, we draw a black rectangle using the width of the frame so it
matches the width of the thing we want to underline, giving it a 2-point height so it looks
like a thick line.
6. We want to position that rectangle so that its center lies at the middle bottom of our frame.
If you prefer offset() rather than position() you should use frame.minX because we’re
providing a relative movement rather than trying to center the view in some exact location.

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:

List(categories, id: \.id) { category in


HStack {
Button(category.id) {
withAnimation {
selectedCategory = category
}
}

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:

struct ExampleView: View {


@State private var counter = 0
let color: Color

var body: some View {


Button {

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.

Start by adding this as a property to ContentView:

let layouts = [AnyLayout(VStackLayout()),


AnyLayout(HStackLayout()), AnyLayout(ZStackLayout())]

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

@State private var currentLayout = 0

And our final property will be responsible for returning one layer from the array, based on the
value of currentLayout:

var layout: AnyLayout {


layouts[currentLayout]
}

For the body of our view, we’re going to create a VStack with four things inside:

1. A button that goes to the next layout when pressed.


2. Whatever layout is currently active, containing four instances of ExampleView inside.
3. A spacer above the layout…
4. …and another one below the layout, so it’s centered neatly.

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:

let layouts = [AnyLayout(VStackLayout()),


AnyLayout(HStackLayout()), AnyLayout(ZStackLayout()),
AnyLayout(GridLayout())]

Second, modify your layout code 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.

Let’s look at that next…

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:

struct RadialLayout: Layout {


func sizeThatFits(proposal: ProposedViewSize, subviews:
Subviews, cache: inout Void) -> CGSize {

func placeSubviews(in bounds: CGRect, proposal:


ProposedViewSize, subviews: Subviews, cache: inout Void) {

}
}

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.

What replacingUnspecifiedDimensions() does is return a fully-formed CGSize with no


optional width or height – both values will have something meaningful in there, with nil values
being replaced by a default of 10. So, our sizeThatFits() method effectively means “I’ll take

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.

Start by adding these two lines to placeSubviews():

let radius = min(bounds.size.width, bounds.size.height) / 2


let angle = Angle.degrees(360 / Double(subviews.count)).radians

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.

Add this loop underneath the previous lines:

for (index, subview) in subviews.enumerated() {


// more code to come

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:

let viewSize = subview.sizeThatFits(.unspecified)

Now for the trigonometry:

• 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.

There are two bonus complications here:

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().

Add these two lines to placeSubviews() below the previous code:

let xPos = cos(angle * Double(index) - .pi / 2) * (radius -


viewSize.width / 2)
let yPos = sin(angle * Double(index) - .pi / 2) * (radius -
viewSize.height / 2)

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.

Add this line to the method next:

let point = CGPoint(x: bounds.midX + xPos, y: bounds.midY +


yPos)

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.

Add this final line of code to end the loop:

subview.place(at: point, anchor: .center,


proposal: .unspecified)

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:

struct ContentView: View {


@State private var count = 16

var body: some View {


RadialLayout {
ForEach(0..<count, id: \.self) { _ in
Circle()
.frame(width: 32, height: 32)
}
}
.safeAreaInset(edge: .bottom) {
Stepper("Count: \(count)", value: $count.animation(), in:
0...36)
.padding()
}

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:

struct EqualWidthHStack: Layout {


}

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:

1. Assuming a CGSize.zero maximum size to begin with.


2. Looping over every view and asking its preferred size.
3. If that view’s width is greater than our current maximum width, make that our new
maximum width.
4. Repeat that, just for height.

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.

Add this method to the struct now:

private func maximumSize(across subviews: Subviews) -> CGSize {


var maximumSize = CGSize.zero

for view in subviews {


let size = view.sizeThatFits(.unspecified)

if size.width > maximumSize.width {


maximumSize.width = size.width
}

if size.height > maximumSize.height {


maximumSize.height = size.height
}
}

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.

Add this second helper now:

private func spacing(for subviews: Subviews) -> [Double] {


var spacing = [Double]()

for index in subviews.indices {


if index == subviews.count - 1 {
spacing.append(0)
} else {
let distance = subviews[index].spacing.distance(to:
subviews[index + 1].spacing, along: .horizontal)
spacing.append(distance)
}
}

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.

Thanks to our helper methods, implementing sizeThatFits() is straightforward. We need to:

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.

Go ahead and add this sizeThatFits() method now:

func sizeThatFits(proposal: ProposedViewSize, subviews:


Subviews, cache: inout Void) -> CGSize {
let maxSize = maximumSize(across: subviews)
let spacing = spacing(for: subviews)
let totalSpacing = spacing.reduce(0, +)

return CGSize(width: maxSize.width * Double(subviews.count) +


totalSpacing, height: maxSize.height)
}

The placeSubviews() method is a little trickier, but it still leans heavily on those two helper
methods we wrote. So, start with this:

func placeSubviews(in bounds: CGRect, proposal:


ProposedViewSize, subviews: Subviews, cache: inout Void) {
let maxSize = maximumSize(across: subviews)
let spacing = spacing(for: subviews)

// more code to come


}

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:

let proposal = ProposedViewSize(width: maxSize.width, height:


maxSize.height)

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.

Please add this line now:

var x = bounds.minX + maxSize.width / 2

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.

We can finish the method by adding this loop:

for index in subviews.indices {


subviews[index].place(at: CGPoint(x: x, y: bounds.midY),

www.hackingwithswift.com 167
Custom Layouts

anchor: .center, proposal: proposal)


x += maxSize.width + spacing[index]
}

That completes our layout, so now all that remains is to try it out in a SwiftUI view:

struct ContentView: View {


var body: some View {
EqualWidthHStack {
Text("Short")
.background(.red)

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:

private func maximumSize(across subviews: Subviews) -> CGSize {


let sizes = subviews.map { $0.sizeThatFits(.unspecified) }
return sizes.reduce(.zero) { largest, next in
CGSize(width: max(largest.width, next.width), height:
max(largest.height, next.height))
}
}

private func spacing(for subviews: Subviews) -> [Double] {


subviews.indices.map { index in
guard index < subviews.count - 1 else { return 0 }
return subviews[index].spacing.distance(to: subviews[index
+ 1].spacing, along: .horizontal)
}

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.

Add this now:

struct RelativeHStack: Layout {


var spacing = 0.0
}

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:

func frames(for subviews: Subviews, in totalWidth: Double) ->


[CGRect] {

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.

So, start by adding this to the frames() method:

let totalSpacing = spacing * Double(subviews.count - 1)

Now we know how much space we have to allocate to each view, which is our total width
minus our total spacing:

let availableWidth = totalWidth - totalSpacing

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.

So, add this constant now:

let totalPriorities = subviews.reduce(0) { $0 + $1.priority }

Now it’s time to start calculating frames, which means creating two variables:

• An array of frames, storing where we’ll place every subview we have.


• An X value, starting at 0, that represents the position we’ll place the next subview. Every
time we place a view we’ll add its width plus spacing to our X value.

Add these lines now:

var viewFrames = [CGRect]()


var x = 0.0

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:

for subview in subviews {


// more code to come
}

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

let subviewWidth = availableWidth * subview.priority /


totalPriorities

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:

let proposal = ProposedViewSize(width: subviewWidth, height:


nil)
let size = subview.sizeThatFits(proposal)

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:

let frame = CGRect(x: x, y: 0, width: size.width, height:


size.height)
viewFrames.append(frame)

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.

First, the sizeThatFits() method. This will:

1. Use replacingUnspecifiedDimensions() on the proposed container size, to make sure we


always have sensible values to work with.
2. Send the proposed width value into our frames() method to calculate all the view frames.
3. Send back a CGSize containing our proposed width alongside the maximum Y value we

174 www.hackingwithswift.com
Implementing a relative width layout

got back from the frames() method – the bottom edge of the lowest view.

Add this method to the RelativeHStack struct now:

func sizeThatFits(proposal: ProposedViewSize, subviews:


Subviews, cache: inout Void) -> CGSize {
let width = proposal.replacingUnspecifiedDimensions().width
let viewFrames = frames(for: subviews, in: width)
let height = viewFrames.max { $0.maxY < $1.maxY } ?? .zero
return CGSize(width: width, height: height.maxY)
}

To finish up we need to implement placeSubviews(). Because the frames() method already


does all the hard work of calculating every frame for every view, this just needs to loop over
all the subviews and assign the position and size we calculated for each one. This time, though,
we’re assigning the leading edge of each view, so that all our views are positioned in the
vertical center of our container.

Add this last method to RelativeHStack:

func placeSubviews(in bounds: CGRect, proposal:


ProposedViewSize, subviews: Subviews, cache: inout Void) {
let viewFrames = frames(for: subviews, in: bounds.width)

for index in subviews.indices {


let frame = viewFrames[index]
let position = CGPoint(x: bounds.minX + frame.minX, y:
bounds.midY)
subviews[index].place(at: position, anchor: .leading,
proposal: ProposedViewSize(frame.size))
}
}

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.

For example, we could create views with priorities 1, 2, and 3:

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.

Add this now:

struct MasonryLayout: Layout {


var columns: Int
var spacing: Double

init(columns: Int = 3, spacing: Double = 5) {


self.columns = max(1, columns)
self.spacing = spacing
}
}

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:

func frames(for subviews: Subviews, in totalWidth: Double) ->


[CGRect] {

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:

let totalSpacing = spacing * Double(columns - 1)

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:

let columnWidth = (totalWidth - totalSpacing) / Double(columns)

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:

let columnWidthWithSpacing = columnWidth + spacing

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.”

Add this line next:

let proposedSize = ProposedViewSize(width: columnWidth, height:


nil)

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:

var viewFrames = [CGRect]()


var columnHeights = Array(repeating: 0.0, count: columns)

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.

Please add this code now:

for subview in subviews {


// more code to come
}

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.

Replace the // more code to come comment with this:

var selectedColumn = 0
var selectedHeight = Double.greatestFiniteMagnitude

for (columnIndex, height) in columnHeights.enumerated() {


if height < selectedHeight {
selectedColumn = columnIndex

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:

let x = Double(selectedColumn) * columnWidthWithSpacing


let y = columnHeights[selectedColumn]

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:

let size = subview.sizeThatFits(proposedSize)

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:

let frame = CGRect(x: x, y: y, width: size.width, height:


size.height)

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.

Add this now:

180 www.hackingwithswift.com
Implementing a masonry layout

columnHeights[selectedColumn] += size.height + spacing

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:

func sizeThatFits(proposal: ProposedViewSize, subviews:


Subviews, cache: inout Void) -> CGSize {
let width = proposal.replacingUnspecifiedDimensions().width
let viewFrames = frames(for: subviews, in: width)
let height = viewFrames.max { $0.maxY < $1.maxY } ?? .zero
return CGSize(width: width, height: height.maxY)
}

func placeSubviews(in bounds: CGRect, proposal:


ProposedViewSize, subviews: Subviews, cache: inout Void) {
let viewFrames = frames(for: subviews, in: bounds.width)

for index in subviews.indices {


let frame = viewFrames[index]
let position = CGPoint(x: bounds.minX + frame.minX, y:
bounds.midY)
subviews[index].place(at: position, anchor: .leading,

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.

So, change the loop in your placeSubviews() method to this:

for index in subviews.indices {


let frame = viewFrames[index]
let position = CGPoint(x: bounds.minX + frame.minX, y:
bounds.minY + frame.minY)
subviews[index].place(at: position, proposal:
ProposedViewSize(frame.size))
}

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.

Here’s my placeholder view:

struct PlaceholderView: View {

182 www.hackingwithswift.com
Implementing a masonry layout

let color: Color =


[.blue, .cyan, .green, .indigo, .mint, .orange, .pink, .purple,
.red].randomElement()!
let size: CGSize

var body: some View {


ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(color)

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:

struct ContentView: View {


@State private var columns = 3

@State private var views = (0..<20).map { _ in


CGSize(width: .random(in: 100...500), height: .random(in:
100...500))
}

www.hackingwithswift.com 183
Custom Layouts

var body: some View {


ScrollView {
MasonryLayout(columns: columns) {
ForEach(0..<20) { i in
PlaceholderView(size: views[i])
}
}
.padding(.horizontal, 5)
}
.safeAreaInset(edge: .bottom) {
Stepper("Columns: \(columns)", value:
$columns.animation(), in: 1...5)
.padding()
.background(.regularMaterial)
}
}
}

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:

static var layoutProperties: LayoutProperties {


var properties = LayoutProperties()
properties.stackOrientation = .vertical
return properties
}

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:

struct MasonryLayout: Layout {


struct Cache {
var frames: [CGRect]
}

// rest of the masonry code

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.

Add this property to the Cache struct now:

var width = 0.0

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:

func makeCache(subviews: Subviews) -> Cache {


Cache(frames: [])
}

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:

cache: inout Void

And replace it with this:

cache: inout Cache

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:

var rollOut = 0.0

We can then adjust our placeSubviews() method so that the angle value we calculate for each
view is multiplied by rollOut, like this:

let angle = Angle.degrees(360 / Double(subviews.count)).radians


* rollOut

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

ContentView struct to this:

struct ContentView: SelfCreatingView {


@State private var count = 16
@State private var isExpanded = false

var body: some View {


RadialLayout(rollOut: isExpanded ? 1 : 0) {
ForEach(0..<count, id: \.self) { _ in
Circle()
.frame(width: 32, height: 32)
}
}
.safeAreaInset(edge: .bottom) {
VStack {
Stepper("Count: \(count)", value: $count.animation(),
in: 0...36)
.padding()

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.

Add this property to RadialLayout now:

var animatableData: Double {


get { rollOut }
set { rollOut = newValue }
}

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:

let angle = Angle.degrees(360 / Double(subviews.count)).radians


* rollOut

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

calculations you’re performing.

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.

Add this struct now:

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

func update(date: TimeInterval) {


particles = particles.filter { $0.deathDate > date }
particles.append(Particle(position: position))
}
}

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.

Add this property to ContentView now:

@State private var particleSystem = ParticleSystem()

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.

Replace your current body property with this:

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.

Add these three modifiers to the TimelineView now:

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.

Replace the // drawing code here comment with this:

let timelineDate = timeline.date.timeIntervalSinceReferenceDate


particleSystem.update(date: timelineDate)

for particle in particleSystem.particles {


ctx.fill(Circle().path(in: CGRect(x: particle.position.x -
16, y: particle.position.y - 16, width: 32, height: 32)),
with: .color(.cyan))
}

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.

Add this line of code directly before the ctx.fill() line:

ctx.opacity = particle.deathDate - timelineDate

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.

Add this class now:

class Particle {
var x: Double
var y: Double
let xSpeed: Double
let ySpeed: Double
let deathDate = Date.now.timeIntervalSinceReferenceDate + 2

init(x: Double, y: Double, xSpeed: Double, ySpeed: Double) {


self.x = x
self.y = y
self.xSpeed = xSpeed
self.ySpeed = ySpeed
}
}

There are a few things that deserve extra explanation:

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:

1. We need to move all particles that are still alive.


2. That movement needs to happen at a fixed speed regardless of how fast the app is
rendering.
3. We need to create new particles at random locations at the top of the screen, so they fall
down evenly from the left screen edge to the right.
4. Knowing where the right edge of the screen lies means sending in the canvas size.

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.

Start with this new class:

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.

Add this method to ParticleSystem now:

func update(date: TimeInterval, size: CGSize) {


let delta = date - lastUpdate
lastUpdate = date

// update all particles here

let newParticle = Particle(x: .random(in: -32...size.width),


y: -32, xSpeed: .random(in: -50...50), ySpeed: .random(in:
100...500))
particles.append(newParticle)
}

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:

for (index, particle) in particles.enumerated() {


if particle.deathDate < date {
particles.remove(at: index)
} else {
particle.x += particle.xSpeed * delta
particle.y += particle.ySpeed * delta
}
}

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:

struct ContentView: View {


@State private var particleSystem = ParticleSystem()

206 www.hackingwithswift.com
Falling snow

var body: some View {


TimelineView(.animation) { timeline in
Canvas { ctx, size in
let timelineDate =
timeline.date.timeIntervalSinceReferenceDate
particleSystem.update(date: timelineDate, size: size)
ctx.addFilter(.blur(radius: 10))

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))
}
}
}
.ignoresSafeArea()
.background(.black)
}
}

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

closing brace after the loop ends, like this:

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:

ctx.addFilter(.alphaThreshold(min: 0.5, color: .white))

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:

LinearGradient(colors: [.red, .indigo], startPoint: .top,


endPoint: .bottom).mask {
// current TimelineView code
}
.ignoresSafeArea()
.background(.black)

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.

So, start by creating this new Particle class:

class Particle: Identifiable {


let id = UUID()
var size = Double.random(in: 100...250)
var x = Double.random(in: -0.1...1.1)

212 www.hackingwithswift.com
Creating a lava lamp

var y = Double.random(in: -0.25...1.25)


var isMovingDown = Bool.random()
var speed = Double.random(in: 0.01...0.1)
}

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

func update(date: TimeInterval) {


let delta = date - lastUpdate
lastUpdate = date

for particle in particles {


// move the particles
}
}
}

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.

Replace the // move the particles comment with this:

if particle.isMovingDown {
particle.y += particle.speed * delta

if particle.y > 1.25 {


particle.isMovingDown = false
}
} else {
particle.y -= particle.speed * delta

if particle.y < -0.25 {


particle.isMovingDown = true
}
}

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

but for now we’ll just create SwiftUI circles in there.

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?

Go ahead and add this ContentView now:

struct ContentView: View {


@State private var particleSystem = ParticleSystem(count: 15)
@State private var threshold = 0.5
@State private var blur = 30.0

var body: some View {


VStack {
LinearGradient(colors: [.red, .orange], startPoint: .top,
endPoint: .bottom).mask {
TimelineView(.animation) { timeline in
Canvas { ctx, size in
particleSystem.update(date:
timeline.date.timeIntervalSinceReferenceDate)
ctx.addFilter(.alphaThreshold(min: threshold))
ctx.addFilter(.blur(radius: blur))

ctx.drawLayer { ctx in
// draw particles here
}
} symbols: {

www.hackingwithswift.com 215
Drawing and Effects

// create symbols here


}
}
}
.ignoresSafeArea()
.background(.indigo)

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:

for particle in particleSystem.particles {


guard let shape = ctx.resolveSymbol(id: particle.id) else
{ continue }
ctx.draw(shape, at: CGPoint(x: particle.x * size.width, y:
particle.y * size.height))
}

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:

extension Array: VectorArithmetic, AdditiveArithmetic where


Element == Double {
}

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:

public static var zero = [0.0]

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.

So, add these two new methods to the extension:

public static func +=(lhs: inout [Double], rhs: [Double]) {


for (index, item) in rhs.enumerated() {
lhs[index] += item
}
}

public static func -=(lhs: inout [Double], rhs: [Double]) {


for (index, item) in rhs.enumerated() {
lhs[index] -= item
}
}

Next, the protocols require that add a scale(by:) method that multiplies each item in the array
by another number, so add this:

public mutating func scale(by rhs: Double) {


for (index, item) in self.enumerated() {
self[index] = item * rhs
}
}

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:

public static func -(lhs: [Double], rhs: [Double]) -> [Double]

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:

struct AnimatablePolygonShape: Shape {


var animatableData: [Double]

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.

We’ll fill this in piece by piece, starting with this:

func path(in rect: CGRect) -> Path {


Path { path in

www.hackingwithswift.com 221
Drawing and Effects

// more code to come


}
}

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:

let center = CGPoint(x: rect.width / 2, y: rect.height / 2)


let radius = min(center.x, center.y)

222 www.hackingwithswift.com
Creating a lava lamp

let lines = animatableData.enumerated().map { index, value in


let fraction = Double(index) / Double(animatableData.count)
let xPos = center.x + radius * cos(fraction * .pi * 2)
let yPos = center.y + radius * sin(fraction * .pi * 2)
return CGPoint(x: xPos * value, y: yPos * value)
}

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

Add this new view now:

struct AnimatingPolygon: View {


@State private var points = Self.makePoints()
@State private var timer = Timer.publish(every: 1, tolerance:
1, on: .main, in: .common).autoconnect()

var body: some View {


AnimatablePolygonShape(points: points)
.animation(.easeInOut(duration: 3), value: points)
.onReceive(timer) { date in
points = Self.makePoints()
}
}

static func makePoints() -> [Double] {


(0..<8).map { _ in .random(in: 0.8...1.2) }
}
}

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:

struct BackgroundBlob: View {


@State private var rotationAmount = 0.0
let alignment: Alignment =
[.topLeading, .topTrailing, .bottomLeading, .bottomTrailing].ra
ndomElement()!
let color: Color =
[.blue, .cyan, .indigo, .mint, .purple, .teal].randomElement()!

www.hackingwithswift.com 227
Drawing and Effects

var body: some View {


Ellipse()
.fill(color)
.frame(width: .random(in: 200...500), height: .random(in:
200...500))
.frame(maxWidth: .infinity, maxHeight: .infinity,
alignment: alignment)
.offset(x: .random(in: -400...400), y: .random(in:
-400...400))
.rotationEffect(.degrees(rotationAmount))
.animation(.linear(duration: .random(in:
2...4)).repeatForever(), value: rotationAmount)
.onAppear {
rotationAmount = .random(in: -360...360)
}
}
}

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:

struct ContentView: View {


var body: some View {
ZStack {
ForEach(0..<15) { _ in
BackgroundBlob()
}
}

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.

So, add this below the onAppear() modifier in BackgroundBlob:

.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:

let color: Color =


[.blue, .blue, .blue, .cyan, .indigo, .mint, .orange, .pink, .p
urple, .red, .teal, .yellow].randomElement()!

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:

class WaterScene: SKScene {

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.

Add these two properties to the water scene now:

private let spriteNode = SKSpriteNode()


var image: UIImage?

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.

Go ahead and add this property to the water scene:

let waterShader = SKShader(source: """


void main() {
// more code to come
}
""")

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:

1. Imagine a grid of pixels.


2. Our shader will effectively be run on every pixel in that grid.
3. It will be given the full grid, along with the X/Y coordinates it’s supposed to use.
4. Rather than returning pixel at the X/Y coordinate that was requested, we will instead return
a different pixel from a nearby coordinate – we’re simulating the water refracting light.
5. Which nearby coordinate we choose depends on the control settings the user provides.

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.

Put this in your main() function now:

float speed = u_time * u_speed;

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

change faster, creating more or fewer ripples.


• We don’t want all the pixels to change uniformly: if they all moved up by one or down by
three it would look like the whole image was moving up a bit rather than ripples.
• So, we’ll add the pixel’s original X coordinate to speed first, then multiply by frequency.
• We’ll put the result of that operation through cos() to get a value between -1 and 1 telling
us which pixel direction we want to move in.
• Finally, we’ll multiply that by the strength the user asked for.

Here’s a worked example, assuming our X coordinate is 22, our speed is 8, our frequency is 4
and our strength is 2:

• We add the X coordinate to our speed, making 30.


• We multiply that by frequency, making 120.
• We calculate the cosine of that to produce some long number that is approximately 0.81.
• We multiply that by strength to get 1.62.

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.

Add this line of code next:

v_tex_coord.x += cos((v_tex_coord.x + speed) * u_frequency) *


u_strength;

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():

v_tex_coord.y += sin((v_tex_coord.y + speed) * u_frequency) *


u_strength;

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:

• The texture we’re working with is provided as u_texture.


• We can read a pixel from there by calling the built-in texture2D() function, passing a
texture and a pixel coordinate.
• The return value from texture2D() will be the new color to use, and we can assign that to
the special value gl_FragColor – that’s what SpriteKit will use for the finished pixel color
for our shader.

Add this final line to the function now:

gl_FragColor = texture2D(u_texture, v_tex_coord);

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;

v_tex_coord.x += cos((v_tex_coord.x + speed) * u_frequency) *


u_strength;
v_tex_coord.y += sin((v_tex_coord.y + speed) * u_frequency) *
u_strength;

gl_FragColor = texture2D(u_texture, v_tex_coord);


}

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:

• Give our scene a transparent background color


• Make it resizable to whatever size SwiftUI wants,
• Make sure our GLSL shader is assigned to the sprite node that will contain our rendered
SwiftUI view.
• Add our sprite node to the scene, so it gets drawn.

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:

override func sceneDidLoad() {


backgroundColor = .clear
scaleMode = .resizeFill

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.

Add this method now:

func updateTexture() {
guard view != nil else { return }
guard let image else { return }

let texture = SKTexture(image: image)


spriteNode.texture = texture
spriteNode.size = texture.size()

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.

Add this now:

override func didMove(to view: SKView) {


updateTexture()
}

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:

struct WaterEffect<Content: View>: View {


@State private var scene = WaterScene()

var speed: Double


var strength: Double
var frequency: Double
@ViewBuilder var content: () -> Content

var body: some View {


}

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.

Add these two to your body property now:

let renderer = ImageRenderer(content: content())


let image = renderer.uiImage

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:

let size = image?.size ?? .zero

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:

return SpriteView(scene: scene, options: .allowsTransparency)


.frame(width: size.width, height: size.height)

You’ll be pleased to know we’re now effectively done with our water code – almost

240 www.hackingwithswift.com
Magic with SpriteKit

everything from now on is just using it somehow.

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.

Replace your current ContentView with this:

struct ContentView: View {


@State private var text = "Hello"
@State private var speed = 0.5
@State private var strength = 0.5
@State private var frequency = 5.0

var body: some View {


VStack {
WaterEffect(speed: speed, strength: strength, frequency:
frequency) {
// Views to render here
}

TextField("Enter a message", text: $text)


.textFieldStyle(.roundedBorder)

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:

@Environment(\.displayScale) var displayScale

www.hackingwithswift.com 243
Drawing and Effects

Now we can assign that to the image renderer like this:

let renderer = ImageRenderer(content: content())


renderer.scale = displayScale

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…

Start with this:

import Combine

class Debouncer<T>: ObservableObject {


@Published var input: T
@Published var output: T

private var debounce: AnyCancellable?


}

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.

Go ahead and add this initializer now:

init(initialValue: T, delay: Double = 1) {


self.input = initialValue
self.output = initialValue

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:

struct ContentView: View {


@StateObject private var text = Debouncer(initialValue: "",
delay: 0.5)
@StateObject private var slider = Debouncer(initialValue:
0.0, delay: 0.1)

var body: some View {


VStack {
TextField("Search for something", text: $text.input)
.textFieldStyle(.roundedBorder)
Text(text.output)

Spacer().frame(height: 50)

Slider(value: $slider.input, in: 0...100)


Text(slider.output.formatted())
}
}
}

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…

schedule it again – it’s debouncing, just done without Combine.

As an example, this view model can do some work immediately, or schedule some work to
happen after a 3-second delay:

class ViewModel: ObservableObject {


private var refreshTask: Task<Void, Error>?
var workCounter = 0

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:

struct ContentView: View {


@StateObject private var viewModel = ViewModel()

www.hackingwithswift.com 249
Performance

var body: some View {


VStack {
Button("Do Work Soon", action: viewModel.scheduleWork)
Button("Do Work Now", action: viewModel.doWorkNow)
}
}
}

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

struct ContentView: View {


@State private var context = CIContext()
@State private var name = "Paul"

var body: some View {


VStack {
TextField("Enter your name", text: $name)
.textFieldStyle(.roundedBorder)
.padding()

Image(uiImage: generateQRCode(from: "\(name)"))


.resizable()
.interpolation(.none)

www.hackingwithswift.com 251
Performance

.frame(width: 200, height: 200)


}
}

func generateQRCode(from string: String) -> UIImage {


let filter = CIFilter.qrCodeGenerator()
filter.message = Data(string.utf8)

if let output = filter.outputImage {


if let cgImage = context.createCGImage(output, from:
output.extent) {
return UIImage(cgImage: cgImage)
}
}

return UIImage(systemName: "xmark.circle") ?? UIImage()


}
}

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:

struct ContentView: View {


var body: some View {

252 www.hackingwithswift.com
…or skipping it entirely

TabView {
ForEach(1..<6) { i in
ExampleView(number: i)
.tabItem { Label(String(i), systemImage: "\
(i).circle") }
}
}
}
}

struct ExampleView: View {


let number: Int

var body: some View {


Text("View \(number)")
.onAppear {
print("View \(number) appearing")
}
}
}

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:

struct OnFirstAppearModifier: ViewModifier {


@State private var hasLoaded = false
var perform: () -> Void

www.hackingwithswift.com 253
Performance

func body(content: Content) -> some View {


content.onAppear {
guard hasLoaded == false else { return }
hasLoaded = true
perform()
}
}
}

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:

public extension View {


func watchOS<Content: View>(_ modifier: @escaping (Self) ->
Content) -> some View {
#if os(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:

class AutorefreshingObject: ObservableObject {


var timer: Timer?

init() {
timer = Timer.scheduledTimer(withTimeInterval: 0.1,
repeats: true) { _ in
self.objectWillChange.send()
}
}
}

struct ContentView: View {


@StateObject private var viewModel = AutorefreshingObject()

var body: some View {


Text("Example View Here")
}
}

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:

extension ShapeStyle where Self == Color {


static var random: Color {
Color(
red: .random(in: 0...1),
green: .random(in: 0...1),
blue: .random(in: 0...1)
)
}
}

Use it like this:

Text("Example View Here")


.background(.random)

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:

var body: some View {

www.hackingwithswift.com 257
Performance

let _ = Self._printChanges()
Text("Example View Here")
}

And in the second this:

var body: some View {


Self._printChanges()
return 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.

Here are the three I rely on the most:

extension View {
func debugPrint(_ value: @autoclosure () -> Any) -> some View
{
#if DEBUG
print(value())
#endif

return self
}

func debugExecute(_ function: () -> Void) -> some View {


#if DEBUG
function()
#endif

return self
}

func debugExecute(_ function: (Self) -> Void) -> some View {


#if DEBUG
function(self)
#endif

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.

It looks like this:

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.

Use it like this:

struct ContentView: View {


@State private var counter = 0

260 www.hackingwithswift.com
Watching for changes

let timer = Timer.publish(every: 0.1, on: .main,


in: .common).autoconnect()

var body: some View {


Text("Example View Here")
.onReceive(timer) { _ in
counter += 1
}
.assert(counter < 50, "Timer exceeded")
}
}

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")

var body: some Scene {


print("In App.body")

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)")
}
}

struct ExampleModifier: ViewModifier {


init(location: String) {
print("Creating ExampleModifier from \(location)")
}

func body(content: Content) -> some View {


print("In ExampleModifier.body()")
return content
}
}

struct ContentView: View {


@State private var property = ExampleProperty(location:
"ContentView")

var body: some View {


print("In ContentView.body")

return NavigationLink("Hello, world!") {


DetailView()
}
.modifier(ExampleModifier(location: "ContentView"))
.task { print("In first task") }
.task { print("In second task") }
.onAppear { print("In first onAppear") }
.onAppear { print("In second onAppear") }
}

www.hackingwithswift.com 263
Performance

init() {
print("In ContentView.init")
}
}

struct DetailView: View {


@State private var property = ExampleProperty(location:
"DetailView")

var body: some View {


print("In DetailView.body")

return Text("Hello, world!")


.modifier(ExampleModifier(location: "DetailView"))
.task { print("In detail task") }
.onAppear { print("In detail onAppear") }
}

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:

1. The App property being created before App.init() is called.


2. Swift running DetailView.init() immediately, long before we even press the button to show
it. Heck, it’s run twice!
3. The ContentView.body property being executed more than once.
4. The two onAppear() modifiers being executed in the order they appear in the code, and the

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

You might also like