Pro SwiftUI - Paul Hudson
Pro SwiftUI - Paul Hudson
Preface
Custom Layouts
Performance
OceanofPDF.com
Preface
OceanofPDF.com
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.
xed
/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulat
or.platform/Developer/SDKs/iPhoneSimulator.sdk/System/Library/Fra
meworks/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.
Every book contains a word that unlocks bonus content for Frequent Flyer
Club members. The word for this book is HARMONY. Enter that word,
along with words from any other Hacking with Swift books, here:
https://www.hackingwithswift.com/frequent-flyer
Updates
When you buy your books from Hacking with Swift, you get Swift updates
for free. You can read the full version of my update policy at
https://www.hackingwithswift.com/update-policy, but the abridged version
is this: whenever I release to the public an updated book or video to reflect
these changes, all existing buyers will get that update free.
Dedication
This book is dedicated to my father, who died earlier this year. After he
passed I looked through photos I had taken of him, and realized although I
had a great many of him with my children I had very few of him with me.
So, if you’re reading this and are lucky enough to have your parents or even
grandparents around, go and make some memories with them!
OceanofPDF.com
Chapter 1
Layout and Identity
OceanofPDF.com
Parents and children
At the core of SwiftUI is its three-step layout process:
It sounds so trivial, and you’re probably wondering why I’m starting out by
mentioning something that is so straightforward, but the simple truth is that
this simple process unlocks a huge amount of power and the more you
really understand it the more you’ll be able to get SwiftUI doing exactly
what you want.
The key to the power is answering a simple question: what is the “parent
view” in that process? When you’re learning SwiftUI, the answer seems
obvious. For example:
VStack {
Text("Hello, world!")
.frame(width: 300, height: 100)
Image(systemName: "person")
}
If I asked a learner what the parent of the text view was, they would
probably answer “the VStack.” And honestly that’s a perfectly fine answer,
and I wouldn’t correct a beginner who said it – it feels natural, and it fits the
hierarchy we can see when the code runs. However, it’s also wrong, and
once you have sufficient experience with SwiftUI it’s important you
understand why the answer is wrong, and more importantly what is right.
Let’s start simple: when we use any modifier in SwiftUI, we are most of the
time creating a new view that wraps the original view to add some extra
behavior or styling. For example, this is just one view:
Text("Hello, world!")
Text("Hello, world!")
.frame(width: 300, height: 100)
This makes sense if you break it down and keep the three-step layout
process in mind: the text view can’t position itself, because that’s the job of
the parent. So, the only way for “Hello, world!” to be aligned center in a
300x100 container is for the parent – the frame – to be that 300x100
container. This is also why we can stack many modifiers to create more
complex effects: we aren’t modifying the original view again and again, but
instead modifying a new view that modifies the original.
Once you understand this process of creating new views using modifiers, so
much of the rest of SwiftUI makes sense. This is why I repeatedly
encourage folks to print out the underlying types of their views, like this:
Text("Hello, world!")
.frame(width: 300, height: 100)
.onTapGesture {
print(type(of: self.body))
}
When you do that, you’ll see the ModifiedContent type appear a lot – not
exactly once for every modifier, because again not all modifiers create new
views. ModifiedContent is itself a struct that conforms to the View
protocol, and I’d like you to look it up using Open Quickly.
If you aren’t familiar with it, Open Quickly is an Xcode feature that lets you
type to search through your code and also Apple’s own APIs; activate it
using Shift+Cmd+O, then type ModifiedContent and press return. This will
open Xcode’s generated interface file for SwiftUI, and you should be able
to find this in there:
These things aren’t magic, and they aren’t secret – you can use
ModifiedContent directly if you want, because it’s public API. For
example, back in ContentView.swift we could create a custom a modifier
like this one:
That’s obviously a lot more wordy than the normal SwiftUI code we’d
write, but what I want you to understand is that the end result is identical to
what we’d get by using modifier() like this:
Text("Hello")
.modifier(CustomFont())
.onTapGesture {
print(type(of: self.body))
}
This is what SwiftUI’s result builder does for us: it repeatedly transforms
our modifiers into ModifiedContent views, nesting them again and again
to get exactly the right result. This is all done at compile time rather than
run time: the actual underlying type of these two views are identical, rather
than just the finished, rendering layouts.
Text("Hello, world!")
.frame(width: 300, height: 100)
That creates the original text view, plus a new ModifiedContent around it
that happens to contain the fixed frame instructions. The text still has its
original frame – the original size it wants to work with – but now we’ve
added a second frame around it.
You can see the original frame is alive and kicking by passing in a custom
alignment for the outer frame:
Text("Hello, world!")
.frame(width: 300, height: 100, alignment:
.bottomTrailing)
If you think about it, the only way the text can be aligned to the bottom
trailing edge is if it knows its original frame. So, our text view has its own
default frame that exactly matches the natural size for its text, and no
amount of futzing from us can ever force that text to extend its bounds
beyond the natural width and height of its lines.
OceanofPDF.com
Fixing view sizes
Let’s look at this simple code again:
Text("Hello, world!")
.frame(width: 300, height: 100)
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.
On the surface this sounds like it breaks the second rule of SwiftUI’s layout
system: “the child chooses its own size and the parent view must respect
that choice.” In this case the child is the text view and the “frame” view is
its parent, so how come the text is being forced to accept the size of its
parent, the frame?
What’s really happening here is that all views use six values to decide how
much space they want to use for layouts, and understanding how they
interact is key to getting the most out of SwiftUI’s layout system.
Minimum width and minimum height, which store the least space this
view accepts. Anything lower than these values will be ignored,
causing the view to “leak” out of the space that was proposed to it.
Maximum width and maximum height, which store the most space this
view accepts. Anything greater than these values will be ignored,
meaning that the parent must position the view somehow inside the
remaining space.
Ideal width and ideal height, which store the preferred space that this
view wants. It’s okay 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?
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.
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()
.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?
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!
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?
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.
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.
OceanofPDF.com
Layout neutrality
Not all views have a meaningful ideal size, and in fact some views have
very little sizing preference at all – they will happily adapt their own size
based on the way we use them alongside other views. This is called being
layout neutral, and a view can be layout neutral for any combination of its
six dimensions.
That will fill the whole space with red, because the color will occupy
whatever space is available. On the other hand, we could use the color as a
background:
Text("Hello, World!")
.background(.red)
Because the color doesn’t actually care how much space it takes up, it will
simply read the ideal and maximum sizes from its child, the text, and use
that for itself. It doesn’t read the minimum size because like I said earlier
the text view is itself layout neutral for its minimum width and height – the
text doesn’t mind being squeezed smaller, so the background color doesn’t
mind either.
What do you think that might do, and why? Remember to work backwards
– the layout starts with background(.red) as the parent, and works inwards
from there.
The background has the whole screen to work with, and Color.red is
completely layout neutral so it’s happy to occupy whatever is
available. If this were the entirety of our layout, the color would
expand to fill the screen.
The background passes on the size proposal it received (the whole
screen) to its child, frame(), which is layout neutral for minimum
width, minimum height, maximum width, and maximum height.
The frame proposes the whole screen to its child, the text, which is
layout neutral for minimum width and minimum height, but cares very
much about its ideal width, ideal height, maximum width, and
maximum height.
The text sends back to the frame the four values it cares about, but
because the frame has provided its own ideal width and height those
two are effectively ignored – the frame will use its own ideal width
and height to override whatever the text asked for. However, because
the frame is layout neutral for its maximum width and height, it will
inherit those from the text.
The frame then sends its final size up to its parent view, the
background: it has the 300x200 ideal size we set, but a maximum size
of whatever the text says it needs, e.g. 95x20.
That 95x20 space then gets filled with the red background.
So, it really is important to think about all six sizing values when working
with layout – they combine together in really interesting ways that may not
necessarily make sense at first, but if you break it all down into a sort of
blow-by-blow conversation then hopefully the exact behavior will become
clear.
Helpfully, all six of these sizing values are optional, which means you can
switch between layout neutrality and a fixed value by using either a number
or nil. This is most commonly done using a ternary conditional operator,
like this:
When it’s true, the frame will propose 300 points of width to the text
When it’s false, the frame will propose to the text whatever size it
receives from the VStack – it effectively does nothing at all.
So, if nil forces a view to be layout neutral for one particular dimension,
what happens if we 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
}
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)
}
}
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.
OceanofPDF.com
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)
Text("Hello, World!")
.frame(width: 250)
.frame(minHeight: 400)
I don’t want to sound like a broken record, but remember the golden rule
here: no amount of futzing from us can ever force that text to extend its
bounds beyond the natural width and height of its lines. We aren’t making
the text flexible at all, because it isn’t possible. Instead, we’re wrapping it in
a new view that has a fixed width of 250 points, then wrapping that in
another new view that has a flexible height of at least 400 points.
The best way to check your understanding is correct is to look at code like
this:
Text("Hello, World!")
.frame(width: 250)
.frame(minWidth: 400)
What do you think that will do when run? It sounds like we’re giving
SwiftUI completely contradictory instructions, but we really aren’t, and
hopefully the answer becomes clear if you try to think like SwiftUI does.
Again, pause for a moment and think it through to decide for yourself
before I walk you through what actually happens.
Our text has an ideal width and height matching its contents.
We place that inside a frame that is 250 points wide.
We place that frame inside another frame that is at least 400 points.
So, there is no contradiction at all, and if you try adding background colors
after the text and each frame you’ll see exactly what’s happening:
Text("Hello, World!")
.background(.blue)
.frame(width: 250)
.background(.red)
.frame(minWidth: 400)
.background(.yellow)
OceanofPDF.com
Inside TupleView
You’ve seen how using modifiers with a single view gets transformed by
@ViewBuilder into nested ModifiedContent views, but the other side of
this coin is how Swift handles multiple views. Try this:
VStack {
Text("Hello")
Text("World")
}
.onTapGesture {
print(type(of: self.body))
}
TupleView isn’t underscored, which means it’s public API, and I encourage
folks to look it up using Open Quickly because it explains one of the key
restrictions in SwiftUI. If you look for where TupleView is used, sooner or
later you’ll find this:
public static func buildBlock<C0, C1, C2, C3, C4, C5, C6, C7,
C8, C9>(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _
c5: C5, _ c6: C6, _ c7: C7, _ c8: C8, _ c9: C9) ->
TupleView<(C0, C1, C2, C3, C4, C5, C6, C7, C8, C9)> where C0
: View, C1 : View, C2 : View, C3 : View, C4 : View, C5 :
View, C6 : View, C7 : View, C8 : View, C9 : View
That’s a generic result builder method that accepts 10 views, and you’ll also
find alternatives that accept 9 views, 8 views, and so on – but, critically,
you won’t find one that accepts 11 views, which is why SwiftUI isn’t able
to statically represent more than 10 views in its type 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!)
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:
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/iPhoneSimulat
or.platform/Developer/SDKs/iPhoneSimulator.sdk/System/Library/Fra
meworks/SwiftUI.framework/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.
extension ViewBuilder {
static func buildPartialBlock<Content>(first content:
Content) -> Content where Content: View {
content
}
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.
OceanofPDF.com
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.
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.
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 directly into the
type of your views. We saw this earlier when using type(of: self.body) to
examine what the actual type of our view body was, but it’s really important
you understand this extends to logic as well.
VStack {
if Bool.random() {
Text("Hello")
} else {
Text("Goodbye")
}
}
.onTapGesture {
print(type(of: self.body))
}
When that runs and you tap the text you’ll see it prints the following into
Xcode’s console: ModifiedContent<VStack<_ConditionalContent<Text,
Text>>, AddGestureModifier<_EndedGesture<TapGesture>>>. If we
break that down, you can see:
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
}
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<Tex
t, 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:
This is all made possible because the View protocol contains this line of
code:
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.
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:
That renders the same view in two slightly different ways: one scaled up to
200%, and one at its default size. The scaling happens using an animated
Boolean, and ExampleView stores some state for how many times its
button was tapped.
1. The scaling effect happens as a fade – the smaller button fades out,
while the larger one fades in.
2. The tap count for your view gets reset to 0 every time you toggle the
switch.
Both of these happen because of that _ConditionalContent flip: SwiftUI
destroys the original ExampleView along with its platform rendering, and
creates a new ExampleView in its place – the fade effect happens because
we’re seeing a transition, rather than an animation. As for the @State being
lost, again this is because SwiftUI considers the original view to be
destroyed, so it removes all its data at the same time.
We’re losing data, we’re losing smooth animations, and we’re making
SwiftUI do a lot of extra work, because as far as SwiftUI is concerned these
are two separate views.
This problem remains even if we removed the view modifier so all that was
changing was the way ExampleView was created:
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.
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
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?
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:
Using this approach we’re back to having structural identity do all the hard
work for us: regardless of the value of scaleUp we have an ExampleView
as the first child of our VStack, so SwiftUI will keep it alive as the Boolean
changes.
I think the problem is pretty clear in our current code, but it might take a
little more thought when dealing with modifiers depending on which iOS
version you’re targeting. For example, we might have some code to show a
message in bold if the user hasn’t read it yet:
How could we write that as a single view? In iOS 16 and later the bold()
modifier takes a Boolean saying whether it’s active or not, but if you’re
targeting 15 or earlier – you either add the modifier or you don’t.
If you aren’t able to target 16 or later, the fix here is to remove bold()
entirely and replace it with fontWeight(), which does accept other options:
Button("Toggle") {
withAnimation {
shouldHide.toggle()
}
}
}
}
}
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.
OceanofPDF.com
Intentionally discarding identity
There are a handful of places where you want to intentionally discard the
identity of your views – to tell SwiftUI that two instances of a view are
different no matter what it might otherwise think.
For example, consider the following list that shuffles its contents when a
button is tapped:
Button("Shuffle") {
withAnimation {
items.shuffle()
}
}
.buttonStyle(.borderedProminent)
.padding(5)
}
}
}
Using withAnimation() here will trigger the default iOS list animation,
where each row will slide from its old position to its new position. That
might be what you want, but in many places this effect is rather hard on the
eye – when a single row moves this animation looks great, but when
everything moves it just causes a jumble.
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:
Button("Shuffle") {
withAnimation(.easeInOut(duration: 1)) {
items.shuffle()
}
}
So, now we have complete control over the animation, which means you
can create something less intense as the default row slide, or perhaps
something just plain different like our edge transition. If you tap Shuffle
quickly, you’ll even see multiple overlapping lists arrive and depart in swift
succession.
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:
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.
OceanofPDF.com
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.
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))
}
So, Optional conforms to Commands where the thing inside the optional
also conforms to Commands, etc.
OceanofPDF.com
Chapter 2
Animations and Transitions
OceanofPDF.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?
Tip: Do not use implicit animations without providing the value parameter
– that’s deprecated from iOS 15 and later because it would animate every
change, including device rotation.
But not everything works this way. For example, if we had several
overlapping views, we might want to animate the Z index of one view:
ZStack {
RoundedRectangle(cornerRadius: 25)
.fill(.red)
.zIndex(redAtFront ? 6 : 0)
ForEach(0..<5) { i in
RoundedRectangle(cornerRadius: 25)
.fill(colors[i])
.offset(x: Double(i + 1) * 20, y:
Double(i + 1) * 20)
.zIndex(Double(i))
}
}
.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.
Tip: The name of the view modifier is “animatable” not “animated” – we’re
providing the ability for this change to be animated, but whether or not it
actually is animated depends on how it’s used.
While it’s possible to apply modifier structs directly to a view, it’s usually a
better idea to wrap them in a View extension to make our code easier:
extension View {
func animatableZIndex(_ index: Double) -> some View {
self.modifier(AnimatableZIndexModifier(index: index))
}
}
And now rather than using zIndex() on the view we want to change, we use
animatedZIndex() instead:
RoundedRectangle(cornerRadius: 25)
.fill(.red)
.animatableZIndex(redAtFront ? 6 : 0)
You see, all the Animatable protocol really does is give us the ability to
read and write some kind of interpolated value over time. As we’re
animating between the values 0 and 6, 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:
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.
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))
}
}
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!
OceanofPDF.com
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:
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)
}
}
OceanofPDF.com
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.
Text(value.formatted(.number.precision(.fractionLength(fracti
onLength))))
}
}
That’s barely doing anything, but thanks to SwiftUI the next step is trivial –
how do you think we upgrade that so it supports animation?
Simple: we just add an animatableData property to get and set value, like
this:
Now we can go ahead and use it just like any other view:
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.
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:
ZStack {
Text(string)
.hidden()
.overlay(
Text(stringToShow),
alignment: .topLeading
)
}
But we can do better! Having this typewriting effect is nice for lots of folks,
but what about folks who rely on VoiceOver, or folks who have specifically
asked apps to reduce the amount of animation they use? If we factor in both
those we can make this view even better.
@Environment(\.accessibilityVoiceOverEnabled) var
accessibilityVoiceOverEnabled
@Environment(\.accessibilityReduceMotion) var
accessibilityReduceMotion
And now we can modify the body property to return two different things
depending on whether we want the animation or not:
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.
OceanofPDF.com
Custom timing curves
SwiftUI gives us fine-grained control over how our animation movements
take place: rather than relying on linear movements or ease-in-out, for
example, we can instead create completely custom cubic Bézier paths that
match whatever acceleration and deceleration we want.
For example, we could create a timing curve that very slowly around the
center of an animation, but bounces hard on the edges:
extension Animation {
static var edgeBounce: Animation {
Animation.timingCurve(0, 1, 1, 0)
}
Notice how I’ve added two variations of the same curve: one as a property,
and one as a method that accepts a duration. This matches the same way
Apple’s own timing curves are created – e.g. .easeIn and
.easeIn(duration:) – so it makes it more natural to use our custom curves.
With that extension in place, we can now create animations using our
custom timing curve just like we would use one of the built-in curves:
extension Animation {
static var easeInOutBack: Animation {
Animation.timingCurve(0.5, -0.5, 0.5, 1.5)
}
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.
OceanofPDF.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.
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:
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:
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)
.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?
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.
That has a default implicit animation, but we’re explicitly overriding it with
a 3-second linear animation – we get the implicit animation most of the
time, but an explicit override for the times we need it.
But there’s one more situation you’re likely to encounter: what happens if
part of your view hierarchy wants to override an animation?
This is another place where transactions solve the problem for us, but this
time they are applied differently: we don’t want to create a new transaction
to replace our global transaction, but instead we want each view to
selectively override just their part of the transaction.
Spacer()
Button("Toggle Color") {
withAnimation(.easeInOut) {
useRedFill.toggle()
}
}
}
}
}
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.
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.
OceanofPDF.com
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.
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.
First, we’ll create a simple view modifier that stores three properties and
renders its content unchanged. The properties are:
The speed the animation will take place. This will be used in various
places as both animation duration and delay, so having it one central
place makes our code easier to follow.
The color to render the effect. This will be used to render the circle and
confetti.
How big to draw the confetti. This is helpful if the user is bringing in a
larger view, where chunkier confetti look much better.
Again, that body() method does nothing right now – it just renders
whatever it’s given, which is fine given that we’re just setting up a skeleton.
To make that easier to use, we’re going to extend AnyTransition with both
a property and a method, just like SwiftUI’s own built-in transitions. The
property will force default values of .blue and 3 for color and size, whereas
the method will allow the user to customize them as needed.
extension AnyTransition {
static var confetti: AnyTransition {
.modifier(
active: ConfettiModifier(color: .blue, size: 3),
identity: ConfettiModifier(color: .blue, size: 3)
)
}
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:
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.
Now I’d like you to add several modifiers to the content line in body():
Now, you might wonder why padding() is in there twice, and the answer is
simple: we need to 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.
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 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:
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!
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.
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.
.scaleEffect(confettiScale)
Moving on, we need to move our confetti outwards as the effect animates.
This means pushing our circles outwards by our radius (half the proxy
width), multiplied by whatever is in confettiMovement. When the view is
first created that will put them at 70% of the radius because
confettiMovement has an initial value of 0.7, but our animation moves
them out to 120% of the radius so they fly outwards a good distance.
That works, but’s a bit dull because every confetti piece would move
exactly the same distance. To make things a bit more varied, we’re going to
add some extra movement to every other piece, like this:
It’s a small difference, but when you see the final effect I think you’ll
appreciate it!
At this point we’ve moved all our confetti pieces out to the side of our
circle, but they are all on the same side. So, our next step is to rotate the
circles by 24 times i so they spread out across the entire circle, and we’re
using 24 because we have 15 circles being created – 24 x 15 is 360, which
covers all the angles.
.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:
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.
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)
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.
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:
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)
And now to finish off the whole effect we need to add one final
withAnimation() call next to the others. I said earlier that it’s a good idea
to structure your animation code in the order it executes, but it’s a bit
trickier here because we want a spring animation rather than a specific
duration. So, if I were you I’d place this in the middle of the four existing
animations – after the strokeMultiplier animation but before the
confettiIsHidden animation – because it has a delay that makes it fit into
that spot well.
Now run the project again and see what you think! It’s not identical to
Twitter’s animation, but it’s close enough that you’d be hard pressed to tell
the difference unless you zoomed in close and compared the two side by
side.
Yes, it did take quite a bit of code, but that’s only because there are
numerous overlapping animations taking place and I’ve tried to make it
fairly accurate to Twitter’s original. Hopefully it’s given you a good idea of
just how powerful SwiftUI’s transitions can be – with the ability to insert
completely custom views and animations, there’s really no limit to what
they can do.
Want to go further?
At this point you might well have had enough of transitions, but if you’re
keen to take this to the next level there is one small but important change
we can make: rather than forcing our transition to use a color, we can in fact
let it use any kind of shape style including gradients.
First, we need to make the modifier generic over some kind of ShapeStyle:
var color: T
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))
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!)
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.
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.
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 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.
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:
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:
if required {
Image(systemName: "asterisk")
.imageScale(.small)
.foregroundColor(.red)
}
}
}
}
Earlier I showed you how the View.font() modifier is nothing more than a
wrapper around .environment(\.font), and honestly this is good practice
because it makes our code easier to read. In this case it would mean adding
a new View extension like this:
extension View {
func required(_ makeRequired: Bool = true) -> some View {
environment(\.required, makeRequired)
}
}
With that in place, we can now just called required(), like this:
Tip: As you can see, making the Boolean parameter have a default value of
true makes for much more natural use at the call site – saying required() is
the same as required(true).
Now, as we’re applying required() directly to our custom text field, the
environment approach might seem overkill – why not just pass it directly
into the RequirableTextField initializer?
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.
I’d like you to try making a custom environment key now: can you create
one that stores a stroke width for all the shapes you want to draw?
Have a go and see how you get on! I’ll add my solution below.
extension EnvironmentValues {
var strokeWidth: Double {
get { self[StrokeWidthKey.self] }
set { self[StrokeWidthKey.self] = newValue }
}
}
extension View {
func strokeWidth(_ width: Double) -> some View {
environment(\.strokeWidth, width)
}
}
And then set that value at some higher point in the environment, like this:
OceanofPDF.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.
There are two compelling reasons why, where possible, you should aim to
use simple environment keys rather than passing in environment objects.
extension EnvironmentValues {
var titleFont: Font {
get { self[TitleFontKey.self] }
set { self[TitleFontKey.self] = newValue }
}
}
extension View {
func titleFont(_ font: Font) -> some View {
environment(\.titleFont, font)
}
}
Now we can send both values into the environment, like this:
Button("Default Font") {
titleFont = .largeTitle
}
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:
return ForEach(0..<3) { _ in
Circle()
.stroke(.red, lineWidth: strokeWidth)
}
}
That prints out a message every time body is called, which is helpful here
because it lets us run the project back and see the message being printed
again and again as we drag around the slider. But, importantly, it won’t be
printed when pressing the buttons: those also change the environment, but
SwiftUI knows CirclesView doesn’t actually use the titleFont environment
key so it doesn’t need to reinvoke body.
So, that’s how environment keys work, but what happens if we had used an
environment object here instead? To find out, we would start by making
some kind of class to store our two values together as theme data:
In CirclesView we aren’t going to watch for precise keys, but instead we’ll
expect to receive a whole environment object of theme data:
return ForEach(0..<3) { _ in
Circle()
.stroke(.red, lineWidth: theme.strokeWidth)
}
}
}
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 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.
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:
Next, we’re going to wrap that in a class that is able to publish changes as
the theme updates. Apps only ever have one theme active at a time, so we
could implement this as a singleton:
Now we can expose all that to the environment, focusing only on the
internal Theme struct:
extension EnvironmentValues {
var theme: any Theme {
get { self[ThemeKey.self] }
set { self[ThemeKey.self] = newValue }
}
}
We need to expose this to our views in a natural way, but we can’t just make
a simple View extension this time because we need to actually watch the
ThemeManager for changes. We don’t own this theme manager object
because we’re using a singleton, so a simple @ObservedObject is the right
choice:
extension View {
func themed() -> some View {
modifier(ThemeModifier())
}
}
You’ll see that still triggers when the stroke width changes or when the font
changes, but doesn’t change when the font is changed to its existing value –
SwiftUI is smart enough to discard that.
But we can get even better: because @Environment uses a key path rather
than always accepting a whole observable object like
@EnvironmentObject does, we can actually tell SwiftUI we want access
to only part of the theme, which means only that part of it will be a
dependency for this view:
return ForEach(0..<3) { _ in
Circle()
.stroke(.red, lineWidth: strokeWidth)
}
}
}
And now SwiftUI will only reinvoke the body property when that one
specific value changes – we’re back to where we were originally in terms of
performance, while also benefiting from having the environment object
available elsewhere if needed.
This approach obviously takes more work, but it gives us the ability to
synchronize changes everywhere with some kind of theme control panel,
but also means we get the guaranteed safety of using environment keys
rather than environment objects.
OceanofPDF.com
Overriding the environment
As we’ve seen, SwiftUI’s environment flows down through our views,
which allows parents to set some data for their children that can then be
read out as needed.
For example, we might create a simple welcome view for our app:
That works well enough, but what if we wanted to customize the font for
the SF Symbols image we’re using? Maybe our designer wants that to be
much bolder, so the image stands out more clearly.
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.
Image(systemName: "sun.max")
.transformEnvironment(\.font) { font in
font = font?.weight(.black)
}
OceanofPDF.com
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 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.
It’s not identical, because we get to decide how the single value is selected
– maybe we choose the first one like navigationTitle(), or maybe we
combine values together somehow.
Enough talk: let’s try and implement a preference key ourselves, which will
let a child view report its size upwards to containers.
The first step is to create a new struct that conforms to the PreferenceKey
protocol. This requires us to provide a default value for the preference, just
like EnvironmentKey, but now we also need to provide a reducer function
– code that chooses which value to use, when several come in.
In our case, we want to track the width of some view, but if we get multiple
widths coming in we’ll just track the last one:
Now we can make a view that sets a value for that preference, like this:
That changes its width whenever it’s tapped, which is helpful so you can
see what’s happening.
Finally, we need to place that view somehow, and watch for changes to its
preferences. This watching is done using the onPreferenceChange()
modifier, which runs code of our choosing whenever some specific
preference data changes, like this:
That code works great: you can run the project, then tap the red square to
see its width adjust 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:
Text("100%")
.frame(width: width)
.background(.red)
Text("150%")
.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:
VStack {
SizingView()
SizingView()
SizingView()
}
.onPreferenceChange(WidthPreferenceKey.self) { width = $0 }
.navigationTitle("Width: \(width)")
We made the reduce() method always use the final value it’s given, so
when that code runs only the third SizingView will have its width reflected
in the navigation title. Of course, it doesn’t have to be that way: we could
make our reducer sum the preferences instead, like this:
Now the final value for our preference will be the total of all three widths,
and will automatically adapt when any of the three changes.
Even better, if you wanted to mimic the “first preference only” approach of
navigationTitle(), it takes literally zero code:
Because that never calls nextValue(), it means “you give me the first value
and the next one, but I don’t care – do nothing with them.”
OceanofPDF.com
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.
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.
First, we can define what one category in our app looks like. We’ll provide
two properties here: one for the identifier, which will be things like
“Beach”, “Golfing”, or “Tropical”, and one for the SF Symbol that will be
used to add an icon. We’ll make this struct be Identifiable so we can loop
over arrays of them in SwiftUI, and also Equatable so we can compare one
category to another. Start with this code:
Next will be the preference data we want to share. Previously this was a
simple number, but this time I want to share a custom struct containing two
pieces of information: the category that it refers to, and an anchor. Anchors
are opaque geometry stores, which means they can store a reference to
some position and size on the screen but we can’t read it out directly
because it wouldn’t be useful. Fortunately, SwiftUI knows how to read
them for us, and in doing so automatically resolves the anchor’s geometry
into coordinates that are useful for us.
If that sounds fuzzy, relax; it will make sense in a moment. For now, add
this second struct to store the category and an anchor storing its geometry
data:
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.
let categories = [
Category(id: "Arctic", symbol: "snowflake"),
Category(id: "Beach", symbol: "beach.umbrella"),
Category(id: "Shared Homes", symbol: "house")
]
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.
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.
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.
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)
.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:
For example, we could add a List below the buttons HStack, showing all
the categories and letting the user select them that way:
if selectedCategory == category {
Spacer()
Image(systemName: "checkmark")
}
}
}
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.
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.
OceanofPDF.com
Chapter 4
Custom Layouts
OceanofPDF.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.
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:
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.
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:
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:
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") {
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.
That’s neat, right? But SwiftUI goes two steps further. First, alongside
VStackLayout and others we can also use GridLayout to lay out views in
a grid. To try it out, first modify your layouts array to this:
layout {
GridRow {
ExampleView(color: .red)
ExampleView(color: .green)
}
GridRow {
ExampleView(color: .blue)
ExampleView(color: .orange)
}
}
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?
OceanofPDF.com
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:
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:
}
}
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:
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 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.
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.
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.
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:
1. Like I said, we need to subtract half the width and height of the view
from the final X and Y positions.
2. SwiftUI considers 0 radians to be directly to the right, whereas users
will expect 0 to be directly up. We can fix this by subtracting half of pi
from our angle before putting it through sin() and cos().
At this point we know where to place this view inside our container, but
there are still two more small complications.
First, we’ve calculated where the view should be by multiplying its angle
by our container’s radius, with a little extra logic in there for handling 0
degrees being up and ensuring views always lie inside the circle. What we
haven’t done is offset this position so that it’s relative to the center of our
container, which means right now those xPos and yPos values are offsets
from the top-left corner of our container.
To fix this, we’re going to convert our two position values into a CGPoint,
and while doing so add in the midX and midY of our bounds, so that our
circle is centered on the center of our container as you’d expect.
Attached to this is a chance to tell the subview exactly how much space we
have allocated to it. Remember, children choose their final sizes and parents
must respect that, so this space allocation is just another proposal – the
child can do what it likes. We don’t care how much size the view takes up,
so we’ll use .unspecified here.
So, that asks each view for its ideal size, then uses it to place it inside our
circle’s perimeter. The whole thing isn’t a lot of actual code, but it does take
quite a bit of explaining because there’s a lot of power here.
We’ll look into these layouts more in coming chapters, but first I want you
to try it out in a SwiftUI view – try replacing your ContentView struct with
this one:
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!
OceanofPDF.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:
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:
So, we’re not saying the maximum width and height for any one view, but
instead the maximum height across all subviews and maximum width
across subviews.
return maximumSize
}
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.
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.
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 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 final step is to lay out the views. To make this happen, we’ll create an x
variable that represents the center X position of the next view we’re laying
out. When we’re just starting out this will have the position of our left edge,
plus half our maximum size – we’re centering the views, remember.
And now all that remains is to loop over the subviews, placing each one at
the x position and in the vertical center of our container. Remember, it’s
important we tell SwiftUI that we’re specifying the center of our views
rather than the top-leading edge or something else, and this time we are
going to use the proposal parameter because we’re going to ask each view
to fit itself into the shared ProposedViewSize we made a moment ago.
The key thing here is that every time we place a view we need to modify x
upwards by adding our maximum width value, but also by adding the
spacing for the view we just added so that this view has the correct amount
of space between it and the next view.
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.
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:
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!
OceanofPDF.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 layout priority can be read through the Layout protocol, so we’re
going to hijack it here as a way of specifying how much relative space
should be allocated to a view. Rather than forcing developers to add
numbers up to 1 or similar, we will instead sum up all the priorities for the
views we’re trying to lay out, then calculate the relative size for a view
based on its priority compared to the total. So, the total might be 1.0, 100,
12, or any other number depending on what works at the call site.
We can start by creating a struct for our layout, giving it the one property
we care about: how much space we want to have between our views. This
will be a single fixed value provided by the user rather than querying each
subview for how much spacing it wants, because the rest of the space will
be allocated proportionally.
All the hard work for this layout is contained in one helper method, which
has the job of calculating all the frames for all the views in one pass. This
will be called by sizeThatFits() so we know how much space we need, and
also called again by placeSubviews() so we can actually assign each view
its frame.
Because there’s a lot of code here, I’m going to break it down step by step
so I can explain as I go. Start by adding this method stub:
Already you can see we’re passing in all the subviews, along with whatever
is the total width allocated to our container.
Our first job will be to figure out how much total spacing we’ll be using
across our layout. We already added a spacing property to control the
amount of space between individual views, so to find the total spacing we
just need to multiply spacing by 1 less than our column count. Why one
less? Well, if we have two columns, we need only one space – the one that’s
between the two columns. We don’t need to add space on either side of the
layout, because that’s something that will be decided by whomever is
placing the container.
Now we know how much space we have to allocate to each view, which is
our total width minus our total spacing:
The final constant I want to set up front is an important one: what is the
total of all the layout priorities of the views we’re working with? This might
add up to 1.0, but really it’s arbitrary – this is a relative layout, after all.
So, add this constant now:
Now it’s time to start calculating frames, which means creating two
variables:
And now we have the main chunk of this method, which needs to go over
all the subviews we have, figuring out how much space each one should be
allocated. Start with this:
return viewFrames
Inside that loop comes the real work. Remember, this is calculated using the
layout priority assigned to the view rather than trying to take into account
how one view should be spaced from others.
Now that we know how much space this particular subview can have, we
can put it forward as a proposal and see what comes back:
Again, that size is decided solely by the child, but it will take into account
our request: we’re asking for a specific width, but saying it can grow as
high as it likes.
We can then combine our x value with the size we received into a CGRect,
and add it to our array:
And finally we need to add to x the width of the view we placed, plus the
spacing for the whole container, so the next view we lay out will go into the
correct location:
x += size.width + spacing
That completes our helper method – as you can see, it does all the hard
work of calculating frames for our views, which makes the rest of this
layout straightforward.
That’s our layout complete! To give it a try, create some flexible views then
assign layout priorities however you want.
RelativeHStack(spacing: 50) {
Text("First")
.frame(maxWidth: .infinity)
.background(.red)
.layoutPriority(1)
Text("Second")
.frame(maxWidth: .infinity)
.background(.green)
.layoutPriority(2)
Text("Third")
.frame(maxWidth: .infinity)
.background(.blue)
.layoutPriority(3)
}
Or we could use 4, 4, 8 to get two views the same size then one that’s twice
as large, or use 30, 50, 20 if you prefer to think in percentages, or really
whatever numbers work best for your layout.
OceanofPDF.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.
Start with a struct for the layout, giving it two properties: how many
columns we have, and how much spacing we want between each item in
our layout. The number of columns must be at least 1 otherwise the layout
makes no sense, so we’ll add a custom initializer that ensures the column
count is always at least 1.
Just like with RelativeHStack, all the real work for this layout is contained
in one helper method that calculates and returns frames for all the views.
Start by adding this method stub:
func frames(for subviews: Subviews, in totalWidth: Double) ->
[CGRect] {
Just like before, we can figure out much total spacing we have by
multiplying spacing by 1 less than our column count, like this:
Now we know how much spacing we have in total, we can calculate how
much space is left for the columns by subtracting totalSpacing from
totalWidth. If we then divide that number by our column count, we’ll have
how much space should be allocated to each column. Add this next:
There’s one last number I want to set up front, which is how much space we
need to allocate for one column including its spacing, because it makes our
code a little easier to read:
At this point we know all the values we need to calculate our frames, so our
first job is to create a ProposedViewSize that we can present to each view.
This will be the same for all views: “you can use as much height as you
want, but I’d like your width to be the same as our column width.”
This means we need two arrays: one storing all our view frames across all
columns, and one storing the heights of each column we have. Add these
lines now:
We can now loop over all our subviews, figure out which column is the
shortest, and place our view there. As we go through, we’ll also stash the
latest view frame away in our viewFrames array, which is the value that
will be returned from this method.
return viewFrames
Like I said, the first step in that loop is to figure out which column is
shortest. If we assume that the shortest is the first one and set a gigantic
height for it, we can loop over all the other columns and check whether they
are shorter or not. If we find one that is shorter, we’ll make that our new
selected column and use its height for our shortest height.
var selectedColumn = 0
var selectedHeight = Double.greatestFiniteMagnitude
for (columnIndex, height) in columnHeights.enumerated() {
if height < selectedHeight {
selectedColumn = columnIndex
selectedHeight = height
}
}
At this point we know exactly which column should be used to place our
subview, so we can figure out the X and Y coordinates for it. The X value is
simply the index of the column multiplied by the
columnWidthWithSpacing, and the Y value is the current height of
whichever column was shortest. Add this just after the previous loop:
To calculate the view’s size, we just need to ask it: given the proposed size
we made earlier, how much space does it actually want? Add this next:
Again, the view is free to ignore that proposal entirely, which would make
our layout a mess. That’s how SwiftUI works, though: the parent proposes a
size, but the child gets to make the final choice and the parent must respect
that.
At this point we know the full set of data for the frame of this view, so we
can create a CGRect from it:
To end this loop we need two more things. First, we just added a subview to
a column, so we need to adjust the height of that column to include the
subview’s height plus our spacing property. That doesn’t mean we’re
always adding spacing below the finished, placed views; these column
heights are just used for calculating where views ought to go.
Add this now:
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:
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.
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:
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.
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?
Internally, SwiftUI asks our layout whether it defines one specific axis for
its views. If we don’t specify something SwiftUI assumes we have a
vertical layout, which works great for our masonry layout where a left-to-
right divider looks great, but you’ll see it causes problems for horizontal
layouts such as EqualWidthHStack and RelativeHStack – the dividers
will still run left to right even in a horizontal axis, which looks wrong.
If you need to customize the axis of your layout, add a computed property
called layoutProperties providing a specific value. For example, if we
wanted to specifically tell SwiftUI that our masonry layout was vertical, we
would add this:
That specifies our masonry layout works vertically, but you won’t see a
difference here – try upgrading one of our horizontal stack implementations
to have a horizontal axis, and you should see them behave better!
OceanofPDF.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:
Important: As soon as you add that nested Cache struct, SwiftUI will
attempt to use it for the layout. We aren’t ready for this quite yet, so you’ll
see compiler errors for the time being.
That cache is enough to store all the data we care about for performance
reasons, but before we solve our compiler errors there’s another property I
want to add and it’s in answer to a simple question: how can we know when
our cache is invalid?
Well, SwiftUI will automatically invalidate our cache when our layout or its
subviews change, but that only applies to the properties of our layout rather
than the amount of space it’s allocated on the screen. This means if we
adjust the size of our layout at runtime, either explicitly adjusting its frame
or because the device rotated, our cache is likely to be wrong.
To fix this problem, we need to give our cache a width property that will
store the amount of space we were laying out for. When we’re asked to
place our subviews, we can double check we’re still referring to the width
we used for our cache, and if not we’ll recreate the cache from scratch.
That’s our Cache type complete, so now in order to make Swift happy we
need to use it in three places. It won’t work yet, but at least we’ll be back to
our code compiling cleanly.
The first is a new method that Layout will call when it wants to create a
new cache for our layout. This is called simply makeCache(), and it should
return a new cache object ready to use. Add this to MasonryLayout now:
The second and third places we need to use Cache are in the signatures for
sizeThatFits() and placeSubviews(). Find this code in both the method
signatures:
Note: If you used Xcode’s code completion for the signatures, you might
have cache: inout (), which is identical to cache: inout Void.
That will make our code compile cleanly again, although it won’t actually
utilize the cache yet. This part is surprisingly easy, though, because we just
need to copy the data we’re creating into our cache at the right times.
For sizeThatFits(), that means adding two lines of code directly before the
return statement:
cache.frames = viewFrames
cache.width = width
Remember, SwiftUI already created a Cache object for us, so we just need
to update it with the latest values we computed.
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.
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.
OceanofPDF.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.
We can then adjust our placeSubviews() method so that the angle value we
calculate for each view is multiplied by rollOut, like this:
So, if angle was originally 10% of a circle, when rollOut was only 0.5 then
the angle would be just 5% of a circle, and when it’s 0.0 then the angle
would 0% for all the views – the circles wouldn’t roll out at all.
We can then try that out by adjusting our ContentView code to track a
Boolean property of whether our circle should be rolled out or not, convert
that into a Double to use with the RadialLayout initializer, and finally add
a button to toggle the Boolean. Adjust your ContentView struct to this:
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 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.
We can do better.
You see, the Layout protocol inherits from Animatable, which means we
can ask SwiftUI to give us all the intermediate values by implementing
animatableData just like animating any other SwiftUI views.
With that tiny change, we’ve told SwiftUI we want to do something special
when the animating value changes – that rather jumping directly to the new
rollOut property, it should instead move from 0.0 to 0.05, 0.1, etc, and let
us do something with each intermediate value.
We’ll look at the impact of this more in a moment, but first please run the
result so you can see the difference. All being well you should see our
circles animate outwards along the circle’s perimeter rather than just sliding
directly to their destination – it’s much nicer, I think.
print("In sizeThatFits")
Or this:
print("In placeSubviews")
When you run the app you’ll see those messages printed out at various
times, but it’s not a lot – SwiftUI might call each one twice when changing
rollOut, for example, so it can calculate the positions of its subviews before
and after the animation.
Now uncomment the animatableData property so that it’s live code again,
while also leaving our little print() calls in, and this time you’ll see
something very different: our print() calls get executed a lot. In fact, both
sizeThatFits() and placeSubviews() get called twice each for every tiny
change of animatableData, so if it animates from 0.0, through 0.01, 0.02,
0.03, etc, all the way up to 1.0, those methods are getting called many,
many times.
This is what makes our improved animation work: rather than making
circles from in a straight line from their start to end position, SwiftUI calls
this line of code again and again to calculate a custom location for all the
intermediate positions of our subviews:
That means the latest value of rollOut is being read every time it changes,
causing the much nicer animation.
Obviously you can go ahead and remove the print() calls now, but I do
want you to keep in mind that animating layouts like this will trigger an
exponential rise in the number of times your layout methods are called, so
you should avoid doing anything too computationally expensive in there
unless you’re carefully profiling the animation on older devices.
Tip: If your animation really does manage to push the CPU hard – a
surprisingly hard thing to do! – this might be a good place to consider
introducing a cache to reduce the number of calculations you’re performing.
OceanofPDF.com
Chapter 5
Drawing and Effects
OceanofPDF.com
Drawing with Canvas
It’s no secret that I am obsessed with drawing using SwiftUI, and honestly
I’ve lost countless hours noodling around, experimenting, and overall
having fun creating beautiful effects with surprisingly little code. Over the
coming chapters I want to explore a handful of these techniques with you,
partly because it really is a lot of fun creating beautiful things, but partly
also because I hope it will inspire you to add a little extra surprise and
delight to your own apps.
We’re going to start with something nice and easy: a trivial particle system
that lets us draw in glowing lights by touching the screen. Particle systems
work by creating dozens, hundreds, or even thousands of very small
images, which can be colored and animated to create a variety of special
effects such as fire, smoke, fog, and rain.
In this initial foray into drawing, our particle system will be trivial: we’ll
constantly be creating and deleting particles, and the user will be able to
move their finger to reposition the place where we generate particles from.
This takes fewer than 50 lines of code, and that’s including whitespace and
lines that are just closing braces – it’s a great entry point into the world of
drawing with SwiftUI.
The first step is to create a Particle struct that will store the data one
particle needs to work. We’ll make more advanced particles later on, but for
this example our particle just needs two pieces of data: its position on the
screen, and the date it should be destroyed. We’ll pass the X and Y values in
from the particle system that generates it all, but we can automatically set
the destruction date to be the current time plus 1 second so that each
particle lasts that long before being destroyed.
struct Particle {
let position: CGPoint
let deathDate = Date.now.timeIntervalSinceReferenceDate +
1
}
Now that we have defined a single particle, the next step is to create the
particle system responsible for creating and managing all the particles. This
has six interesting things:
1. It will be a class rather than a struct, so we can mutate its values freely
without triggering SwiftUI updates.
2. It needs to store an array of all the particles that are currently alive.
3. It also needs to store the current position of the particle system, which
is used to create new particles.
4. We’ll give it one method, called update(). This will be called every
time we want to redraw our canvas, and will be provided with the
current time.
5. Inside there we’ll destroy any particles that are past their deathDate
property.
6. Finally, it will also create a new particle at the current position of the
particle system.
It takes much less code to implement all that than it does to explain it, so go
ahead and add this class now:
class ParticleSystem {
var particles = [Particle]()
var position = CGPoint.zero
That completes all our data model, so what remains is to create one of those
particle systems inside ContentView, then use its data to render particles
inside TimelineView and Canvas.
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.
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:
.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.
let timelineDate =
timeline.date.timeIntervalSinceReferenceDate
particleSystem.update(date: timelineDate)
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.
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.
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 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!
OceanofPDF.com
Falling snow
Now that you’ve got the hang of a basic particle system, let’s take it up a
notch by creating particles that move independently rather than always
staying where they were created. This means being able to constantly adjust
our particles after they have been created, which in turn means using a class
rather than a struct for the Particle type.
class Particle {
var x: Double
var y: Double
let xSpeed: Double
let ySpeed: Double
let deathDate = Date.now.timeIntervalSinceReferenceDate +
2
1. I’ve split x and y into two separate properties because it makes them
easier to work with.
2. The ySpeed will determine how fast this particle moves down the
screen, and the xSpeed is there to let us add a very small amount of
horizontal movement to make the particles look a bit more natural.
3. There’s a 2-second lifetime for each particle so they can fall most if
not all of the way down the screen before being destroyed.
4. It’s a class rather than a struct, so we need a custom initializer.
Next we need to write the ParticleSystem class. This is similarly enhanced
from our previous, simpler particle system, because now it needs some
extra features:
That second point is the most interesting from a code perspective, because
we need to make sure particles move at the same speed no matter whether
update() is called 60 times a second (the ideal for many devices), 120 times
a second (the ideal for devices that support ProMotion), or even just 30
frames a second if your app is busy doing a lot of other work.
Doing this means giving our ParticleSystem class an extra property that
stores when update() was last called. This can be the current date to begin
with, but it will be changed every time update() is called.
class ParticleSystem {
var particles = [Particle]()
var lastUpdate = Date.now.timeIntervalSinceReferenceDate
}
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.
Obviously I left the most important part out, which is where we update each
particle to take into account its movement across the screen. We need to
remove particles from our array whenever they pass their death date, just
like we did with the previous particle system, so we might as well use the
same loop to perform our frame-independent movement.
Add this loop in place of the // update all particles here comment:
That completes our data model, so now we can turn to the SwiftUI view to
render it all using TimelineView and Canvas. We’re going to write a few
versions of this, but our initial pass is identical to the ContentView struct
from our previous particle system apart from three changes:
1. We need to pass the canvas size into our particle system’s update()
method, so it knows the full range of space that can be used to create
particles.
2. We aren’t going to add a blend mode. You can add one if you want, but
it’s not the effect I’m looking for here.
3. We don’t need to offset the particle’s X and Y position by half the
circle width, because we don’t need to draw exactly under the user’s
finger any more.
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.
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 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))
}
}
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.
To see the result of all this, and the following code directly before the blur
filter:
If you run the code now you should see the metaball effect in full swing: as
the particles overlap each other they will appear to merge together as if they
were water drops rolling down a window. Hopefully you can see what I
meant when I said they looked organic!
To create an even more interesting effect, try using your TimelineView as
the mask for something else, such as a linear gradient, like this:
Now the metaballs will appear to change color the lower they get on the
screen, creating an effect reminiscent of lava lamps. Can we make an actual
lava lamp effect? Certainly, but that takes quite a bit more thinking…
OceanofPDF.com
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:
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.
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.
class ParticleSystem {
let particles: [Particle]
var lastUpdate = Date.now.timeIntervalSinceReferenceDate
Apart from the // move the particles comment, there is one important
difference here: rather than creating particles dynamically as the app runs,
we’re instead going to create them all up front. This is why particles can be
a constant array now, and also why it doesn’t have a default value – we
need to add an initializer to create all our particles up front.
We could make the number of particles fixed, but it’s hardly any extra work
to make that amount customizable. So, add this initializer to
ParticleSystem now:
init(count: Int) {
particles = (0..<count).map { _ in Particle() }
}
Now for the movement: if the particle is moving down, we’ll add to its Y
position using frame-independent movement, otherwise we’ll subtract from
it. Critically, we’ll add two extra checks to make sure particles flip their
movement when they are fully off the screen.
if particle.isMovingDown {
particle.y += particle.speed * delta
Now for the main event: rendering all this in SwiftUI. We’re going to take a
different approach here, partly because it demonstrates an important
Canvas technique, but honestly mostly because it makes it much easier to
switch over to the more advanced version if you decide to pursue it!
The different approach is this: rather than just filling circles in our Canvas
code, we are instead going to create our shapes as SwiftUI views and pass
them into the canvas as symbols. This is a real powerhouse Canvas
technique because it lets us place any kind of SwiftUI view directly into our
drawings. This will be really important in the more advanced lava lamp
effect, 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?
ctx.drawLayer { ctx in
// draw particles here
}
} symbols: {
// 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)
}
}
}
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.
Remember, this time we’re using relative positions for our particles, so we
need to multiply the particle’s X and Y positions by our canvas’s width and
height respectively.
Go ahead and replace the // draw particles here comment with this:
That’s us done, at least with the simplified version – try it out and see what
you think! The code is broadly similar to our previous particle system, but I
think it looks remarkably good.
Wouldn’t it be lava-ly?
Can we do better? Yes! If we create irregular polygons rather than circles,
we can adjust the length of each of their sides using animation, making the
shape change constantly. Thanks to our blur filter the shapes will still look
nice and smooth even though they are pretty rough polygons
I’ll be honest, this does take a bit of math. However, I have tried to remove
as much code as I possibly can, so hopefully it’s understandable.
First, the good news: Particle and ParticleSystem don’t need to change at
all, and only one small line of ContentView will change – most of what
we’re writing is new.
Now for the first piece of mathematics: we’re going to add an extension on
Array so that it conforms to both VectorArithmetic and
AdditiveArithmetic when it contains Double as its element type. These
protocols are used to provide animating values to SwiftUI, which in our
case means the polygon points we generate will animate to be longer or
shorter over time.
Yes, I know that extending a type we don’t own to support protocols we
don’t own is frowned upon, but it avoids having to create a wholly separate
type – this code really is as simple as I can make it!
There are quite a few parts here, so start by adding this empty extension so
we can fill it in bit by bit:
First, we need to tell SwiftUI what zero looks like, which for us means an
empty array with 0 in. Add this to the extension now:
Next we need to add operator overloads for += and -= so that SwiftUI can
add one array to another. I’m going to assume (again, this is simplified!)
that our lava polygons don’t change their side counts over time, so we can
safely enumerate over both arrays and add or subtract each item.
Next, the protocols require that add a scale(by:) method that multiplies
each item in the array by another number, so add this:
public mutating func scale(by rhs: Double) {
for (index, item) in self.enumerated() {
self[index] = item * rhs
}
}
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.
First, we can create a new struct that conforms to Shape, and has a property
to store an array of numbers that represent how much we should shrink or
extend each side of the polygon. In order for this to be animated we need to
either store these values in an animatableData property containing these
values, or store them in another property and provide a getter/setter combo
for animatableData.
We’re aiming for the simplest solution, so that means using a single
property. Add this now:
init(points: [Double]) {
animatableData = points
}
}
Now for the mathematics: we need to create a path(in:) method that creates
a path from our polygon’s points, taking into account the animatable data
array that describes how much to shrink or extend each side.
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:
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!
And that’s it! That completes all the major code changes to make the more
advanced lava lamp effect work.
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.
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.
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:
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:
Now try running it and see what you think! You should see a whole bunch
of ellipses swirling around on the screen, and it won’t look anything at all
like the soothing background animation I promised.
Fortunately, you’re just one line of code away from the final result, because
we just need to add a blur to each of the circles to make the blend together
nicely.
.blur(radius: 75)
Now you should find all the colors mix together in more interesting ways,
and in particular using blobs that are the same blue color as the background
causes our shapes to get cut out in interesting ways.
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:
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.
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 {
}
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.
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.
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.
That creates a main() function in our shader, with a single comment inside.
We’re going to write that line by line, so I can explain what it all does.
Before I dive into the code, though, I want to summarize how the effect
works:
All set?
Okay, the first step is to figure out how fast we want our effect to happen.
This can be done by reading two values that will be passed in externally:
u_time will tell us how much time has passed since the shader was created,
and u_speed will tell us how fast the user has requested for the water to
move. If we multiply those two together we are effectively making time
move faster, so the water will ripple more quickly.
Next we need to decide which pixel we’re going to read. Here we need to
use several more external values: v_tex_coord will tell us which pixel we
were asked to read, u_frequency will contain how fast the user wants
ripples to be created, and u_strength will tell us how strong the user wants
the effect to be.
Figuring out which pixel to use involves a small amount of mathematics, so
let me break it down:
We already set speed to the current time multiplied by how fast the
user wants the ripples to happen.
If we take multiply that speed by the ripple frequency it means the
pixel we choose will 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.
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.
At this point we now know exactly which pixel we want to read, so we need
to read that value and send it back. GLSL provides three helpers here:
Tip: If we try and read pixels from outside the bounds of the texture, Metal
will automatically wrap them around to the other side for us. As a result, it
usually works better if your SwiftUI views have a little padding around
them to avoid this wrapping.
I know it took a lot of explaining, but the entire function is really just this:
void main() {
float speed = u_time * u_speed;
We’re done with the shader now, but we still have some SpriteKit work to
do. First, when our scene loads we need to do some quick set up work:
This can all be done through the sceneDidLoad() method, which is the
SpriteKit equivalent to viewDidLoad() from UIKit. Add this method to the
class now:
spriteNode.shader = waterShader
addChild(spriteNode)
}
func updateTexture() {
guard view != nil else { return }
guard let image else { return }
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:
That doesn’t have a real body property yet, but before we add that I want to
explain what we have so far:
1. We have a WaterScene instance stored as state, meaning that SwiftUI
will keep it alive without watching it for changes.
2. We have properties to store the three user-configurable effect inputs:
speed, strength, and frequency.
3. Most importantly, we also have a view builder that will generate some
kind of SwiftUI content. This is what we’ll be using in our game
scene.
Tip: We need to make the struct generic so that it’s able to render any kind
of SwiftUI views.
Inside the body property comes the real work. You see, our content
property will contain a whole bunch of SwiftUI views, and we need to pass
those to the water scene to render. That thing expects a UIImage, not
SwiftUI views, so the first thing we need to do is render those SwiftUI
views as an image.
This can be done using the ImageRenderer struct, which accepts any views
as its input and has a uiImage property that contains the resulting image.
Tip: If you’re using macOS, access the nsImage property rather than
uiImage.
Now that we have our view image ready to go, we can read its size or
replace it with .zero if there was a problem:
Next we need to send all those user values into the shader. We have speed,
strength, and frequency as properties of this view, and our shader expects
u_speed, u_strength, and u_frequency to be provided in order to work. All
those “u” names are there for a reason: these are called uniforms, which are
the name for fixed values we assign to a shader to customize it. These are
specified by name and value, with the names matching the names used
inside the shader code.
There is one small complication here, but it’s best explained after you see
the code. Add this to your body property next:
scene.waterShader.uniforms = [
SKUniform(name: "u_speed", float: Float(speed)),
SKUniform(name: "u_strength", float: Float(strength) /
20.0),
SKUniform(name: "u_frequency", float: Float(frequency))
]
Do you see the complication? Rather than sending our strength value in
directly, we divide it by 20 to make it a lot smaller – we only need very
small offsets to make this work, but it’s hard to work with very small
values.
Moving on, we can now assign our new image to the scene, and call its
updateTexture() method:
scene.image = image
scene.updateTexture()
Finally, we can send back a new SpriteView with that scene in place,
making sure to enable transparency on it and also give it a frame matching
our rendered view’s size:
You’ll be pleased to know we’re now effectively done with our water code
– almost everything from now on is just using it somehow.
LabeledContent("Speed") {
Slider(value: $speed)
}
LabeledContent("Strength") {
Slider(value: $strength)
}
LabeledContent("Frequency") {
Slider(value: $frequency, in: 5...25)
}
}
.padding()
}
}
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.
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:
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.
OceanofPDF.com
Chapter 6
Performance
OceanofPDF.com
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.
import Combine
We need to write an initializer that accepts two values from the user: the
initial value to use for both the input and output (because they will be the
same to start with), and also how long we want our debounce to wait for.
The way this works is simple: elsewhere in SwiftUI we’ll adjust the input
property directly, but we’ll attach to that Combine debounce() and sink()
operators so that after a period of time has passed we copy the value from
input into output. Because output uses @Published, changing it will
automatically cause any observing SwiftUI views to reinvoke their body
property.
debounce = $input
.debounce(for: .seconds(delay), scheduler:
DispatchQueue.main)
.sink { [weak self] in
self?.output = $0
}
}
And that’s it – that’s our entire Debouncer class done already. To try it out,
create instances of it using @StateObject, then bind your controls to input
and your readouts to output. For example, this debounces a text field and a
slider:
Spacer().frame(height: 50)
I encourage you to try that out, because you’ll see how our view waits until
the user pauses for a moment before reloading.
For work that takes place outside of bindings, use Swift’s Task to spin off
the work, sleep for some amount of time, then execute it after the delay
finishes. This works well because if you need to schedule the same work
while the sleep is happening, you can cancel the task and schedule it again –
it’s debouncing, just done without Combine.
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()
}
}
}
OceanofPDF.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
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.
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")
}
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)
}
OceanofPDF.com
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.
init() {
timer = Timer.scheduledTimer(withTimeInterval: 0.1,
repeats: true) { _ in
self.objectWillChange.send()
}
}
}
If you run that you’ll see it looks and works just like any other SwiftUI
view – there’s no indication it’s doing vast amounts of work thanks to the
change notifications coming in.
To address this, I want to introduce you to a neat trick I learned from Peter
Steinberger, which is to create a random color extension such as this one:
With that in place it becomes obvious that are view is being recomputed,
because the text will literally flash through colors on the screen:
That doesn’t explain why something changed, though, and for that you need
the View._printChanges() method. This can be called from inside any
view’s body property, and will automatically print what triggered a change.
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!
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
}
return self
}
return self
}
}
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.
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.
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”.
OceanofPDF.com
The SwiftUI cycle of events
If there is one thing – just one thing – I would recommend everyone do in
order to better understand the sequence of events SwiftUI goes through
when working with all our views and modifiers, and in doing so get a much
better idea of what code is being run when our apps execute, it is this:
create a bunch of test structs that represent your app, some views,
modifiers, properties, initializers, etc, and make them all print out messages
explaining what’s going on. It takes only a few minutes to do, and yet most
people will be surprised what they see when it runs.
If you’re not sure where to start, something like this is a great beginning:
@main
struct MyApp: App {
@State private var property = ExampleProperty(location:
"App")
return WindowGroup {
NavigationStack {
ContentView()
}
}
}
init() {
print("In App.init")
}
}
struct ExampleProperty {
init(location: String) {
print("Creating ExampleProperty from \(location)")
}
}
init() {
print("In ContentView.init")
}
}
init() {
print("In DetailView.init")
}
}
When that runs you’ll see a whole lot of output, and while you use it some
more will come out. In particular, watch out for:
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.
OceanofPDF.com