Thinking in SwiftUI
A Transition Guide
by Chris Eidhof and Florian Kugler
Introduction
SwiftUI is a radical departure from UIKit, AppKit, and other object-oriented UI frameworks.
In SwiftUI, views are values instead of objects. Compared to how they’re handled in object-oriented frameworks, view construction and view updates are expressed in an entirely different, declarative way. While this eliminates a whole category of bugs (views getting out of sync with the application’s state), it also means you have to think differently about how to translate an idea into working SwiftUI code. The primary goal of this book is to help you develop and hone your intuition of SwiftUI and the new approach it entails.
SwiftUI also comes with its own layout system that fits its declarative nature. The layout system is simple at its core, but it can appear complicated at first. To help break this down, we explain the layout behavior of elementary views and view containers and how they can be composed. We also show advanced techniques for creating complex custom layouts.
Finally, this book covers animations. Like all view updates in SwiftUI, animations are triggered by state changes. We use several examples – ranging from implicit animations to custom ones – to show how to work with this new animation system.
What’s Not in This Book
Since SwiftUI is a young framework, this book is not a reference of all the (platform-specific) Swift APIs. For example, we won’t discuss how to use a navigation view on iOS, a split view on macOS, or a carousel on watchOS — especially since specific APIs will change and develop over the coming years. Instead, this book focuses on the concepts behind SwiftUI that we believe are essential to understand and which will prepare you for the next decade of SwiftUI development.
Acknowledgments
Thanks to Javier Nigro, Matt Gallagher, and Ole Begemann for your invaluable feedback on our book. Thanks to Natalye Childress for copy editing. Chris would like to thank Erni and Martina for providing a good place to write.
Overview
In this chapter, we’ll give you an overview of how SwiftUI works and how it works differently from frameworks like UIKit. SwiftUI is a radical conceptual departure from the previous way of developing apps on Apple’s platforms, and it requires you to rethink how to translate an idea you have in mind into working code.
We’ll walk through a simple SwiftUI application and explore how views are constructed, laid out, and updated. Hopefully this will give you a first look at the new mental model that’s required for working with SwiftUI. In subsequent chapters, we’ll dive into more detail on each of the aspects described in this chapter.
We’ll build a simple counter for our sample application. The app has a button to increase the counter, and below the button is a label. The label shows either the number of times the counter was tapped, or a placeholder if the button hasn’t been tapped yet:


We strongly recommend following along by running and modifying the code yourself. Consider the following quote:
The only way to learn a new programming language is by writing programs in it. — Dennis Ritchie
We believe this advice applies not just to programming languages, but also to complicated frameworks such as SwiftUI. And as a matter of fact, this describes our experience with learning SwiftUI.
View Construction
To construct views in SwiftUI, you create a tree of view values that describe what should be onscreen. To change what’s onscreen, you modify state, and a new tree of view values is computed. SwiftUI then updates the screen to reflect these new view values. For example, when the user taps the counter button, we should increment our state and let SwiftUI rerender the view tree.
Note: At the time of writing, Xcode’s built-in previews for SwiftUI, Playgrounds, and the simulator don’t always work. When you see unexpected behavior, make sure to doublecheck on a real device.
Here’s the entire SwiftUI code for the counter application:
import SwiftUI
struct ContentView: View {
@State var counter = 0
var body: some View {
VStack {
Button(action: { self.counter += 1 }, label: {
Text("Tap me!")
.padding()
.background(Color(.tertiarySystemFill))
.cornerRadius(5)
})
if counter > 0 {
Text("You've tapped \(counter) times")
} else {
Text("You've not yet tapped")
}
}
}
}
The ContentView
contains a vertical stack with two nested views: a button, which increments the counter
property when it’s tapped, and a text label that shows either the number of taps or a placeholder text.
Note that the button’s action closure does not change the tap count Text
view directly. The closure doesn’t capture a reference to the Text
view, but even if it did, modifying regular properties of a SwiftUI view after it is presented onscreen will not change the onscreen presentation. Instead, we must modify the state (in this case, the counter
property), which causes SwiftUI to call the view’s body, generating a new description of the view with the new value of counter
.
Looking at the type of the view’s body
property, some View
, doesn’t tell us much about the view tree that’s being constructed. It only says that whatever the exact type of the body might be, this type definitely conforms to the View
protocol. The real type of the body looks like this:
VStack<
TupleView<
(
Button<
ModifiedContent<
ModifiedContent<
ModifiedContent<
Text,
_PaddingLayout
>,
_BackgroundModifier<Color>
>,
_ClipEffect<RoundedRectangle>
>
>,
_ConditionalContent<Text, Text>
)
>
>
That’s a huge type with lots of generic parameters — and it immediately explains why a construct like some View
(an opaque type) is required for abstracting away these complicated view types. However, for learning purposes, it’s instructive to look at this type in more detail.
To inspect the underlying type of the body, we created the following helper function, which uses Swift’s
Mirror
API:extension View { func debug() -> Self { print(Mirror(reflecting: self).subjectType) return self } }
The function is used like this to print out the view’s type when
body
gets executed:var body: some View { VStack { /*... */ }.debug() }
Here’s the same type visualized as a tree diagram:
The first thing to notice is that the type of the view constructed in the body
property contains the structure of the entire view tree — not just the part that’s onscreen at the moment, but all views that could ever be onscreen during the app’s lifecycle. The if
statement has been encoded as a value of type _ConditionalContent
, which contains the type of both branches. You might wonder how this is even possible. Isn’t if
a language-level construct that’s being evaluated at runtime?
To make this possible, SwiftUI leverages a Swift feature called function builders. As an example, the trailing closure after VStack
is not a normal Swift function; it’s a ViewBuilder
(which is implemented using Swift’s function builders feature). In view builders, you can only write a very limited subset of Swift: for example, you cannot write loops, guards, or if let
s. However, you can write simple Boolean if
statements to construct a view tree that’s dependent on the app’s current state — like the counter
variable in the example above (see the section below for more details about view builders).
The advantage of the view tree containing the entire structure instead of just the currently visible structure is that it’s more efficient for SwiftUI to figure out what has changed after a view update — but we’ll get to view updates later in this chapter.
The second feature to highlight in this type is the deep nesting of ModifiedContent
values. The padding
, background
, and cornerRadius
APIs we’re using on the button are not simply changing properties on the button. Rather, each of these method calls creates another layer in the view tree. Calling .padding()
on the button wraps the button in a value of type ModifiedContent
, which contains the information about the padding that should be applied. Calling .background
on this value in turn creates another ModifiedContent
value around the existing one, this time adding on the information about the background color. Note that .cornerRadius
is implemented by clipping the view with a rounded rectangle, which is also reflected in the type.
Since all these modifiers create new layers in the view tree, their sequence often matters. Calling .padding().background(...)
is different than calling .background(...).padding()
. In the former case, the background will extend to the outer edge of the padding; the background will only appear within the padding in the latter case.
In the rest of this book, we’ll simplify the diagrams for readability, leaving out things like ModifiedContent
. For example, here’s the previous diagram, simplified:
View Builders
As mentioned above, SwiftUI relies heavily on view builders to construct the view tree. A view builder looks similar to a regular Swift closure expression, but it only supports a very limited syntax. While you can write any kind of expression that returns a View
, there are very few statements you can write. The following example contains almost all possible statements in a view builder:
VStack {
Text("Hello")
if true {
Image(systemName: "circle")
}
if false {
Image(systemName: "square")
} else {
Divider()
}
Button(action: {}, label: {
Text("Hi")
})
}
The type of the view above is:
VStack<
TupleView<(
Text,
Optional<Image>,
_ConditionalContent<Image, Divider>,
Button<Text>
)>
>
Each statement in a view builder gets translated into a different type:
- A view builder with a single statement (for example, the button’s label) evaluates to the type of that statement (in this case, a
Text
). - An
if
statement without anelse
inside a view builder becomes an optional. For example, theif true { Image(...) }
gets the typeOptional<Image>
. - An
if/else
statement becomes a_ConditionalContent
. For example, theif/else
above gets translated into a_ConditionalContent<Image, Divider>
. Note that inside view builders, it is perfectly fine to have different types for the branches of anif/else
statement (whereas you can’t return different types from the branches of anif
statement outside of a view builder). Multiple statements get translated into a
TupleView
with a tuple that has one element for every statement. For example, the view builder we pass to theVStack
contains four statements, and its type is:TupleView<( Text, Optional<Image>, _ConditionalContent<Image, Divider>, Button<Text> )>
At the moment, it is not possible to write loops or switches, declare variables, or use syntax such as if let
. Most of these statements will be supported in the future, but at the time of writing, this support hadn’t been implemented.
Compared to UIKit
When we talk about views or view controllers in UIKit, we refer to instances of the UIView
or UIViewController
classes. View construction in UIKit means building up a tree of view controllers and view objects, which can be modified later on to update the contents of the screen.
View construction in SwiftUI refers to an entirely different process, because there are no instances of view classes in SwiftUI. When we talk about views, we’re talking about values conforming to the View
protocol. These values describe what should be onscreen, but they do not have a one-to-one relationship to what you see onscreen like UIKit views do: view values in SwiftUI are transient and can be recreated at any time.
Another big difference is that in UIKit, view construction for the counter app would only be one part of the necessary code; you’d also have to implement an event handler for the button that modifies the counter, which in turn would need to trigger an update to the text label. View construction and view updates are two different code paths in UIKit.
In the SwiftUI example above, these two code paths are unified: there is no extra code we have to write in order to update the text label onscreen. Whenever the state changes, the view tree gets reconstructed, and SwiftUI takes over the responsibility of making sure that the screen reflects the description in the view tree.
View Layout
SwiftUI’s layout system is a marked departure from UIKit’s constraint- or frame-based system. In this section, we’ll walk you through the basics, and we’ll expand on the topic in the view layout chapter later in the book.
SwiftUI starts the layout process at the outermost view. In our case, that’s the ContentView
containing a single VStack
. The layout system offers the ContentView
the entire available space, since it’s the root view in the hierarchy. The ContentView
then offers the same space to the VStack
to lay itself out. The VStack
divides the available space by the number of its children, and it offers this space to each child (this is an oversimplification of how stacks divide up the available space between their children, but we’ll come back to this in the layout chapter). In our example, the vertical stack will consult the button (wrapped in several modifiers) and the conditional text label below it.
The first child of the stack (the button) is wrapped in three modifiers: the first (cornerRadius
) takes care of clipping the rounded corners, the second (background
) applies a background color, and the third (padding
) adds padding. The first two modifiers don’t modify the proposed size. However, the padding modifier will take the space it’s offered by its parent, subtract the padding, and offer the now slightly reduced space to the button. The button in turn offers this space to its label, which responds with the size it really needs based on the text. The button takes on the size of the text label, the padding modifier takes on the size of the button plus the padding, the two other modifiers just take on the size of their children, and the final size is communicated up to the vertical stack.
After the vertical stack has gone through the same process with its second child, the conditional text label, it can determine its own size, which it reports back to its parent. Recall that the vertical stack was offered the entire available space by the ContentView
, but since the stack needs much less space, the layout algorithm centers it onscreen by default.
At first, laying out views in SwiftUI feels a bit like doing it in UIKit: setting frames and working with stack views. However, we’re never setting a frame property of a view in SwiftUI, since we’re just describing what should be onscreen. For example, adding this to the vertical stack looks like we’re setting a frame, but we’re not:
VStack {
// ...
}.frame(width: 200, height: 200)
When calling .frame
, all we’re doing is wrapping the vertical stack in another modifier (which itself conforms to the View
protocol). The type of the view’s body now has changed to:
ModifiedContent<VStack<...>, _FrameLayout>
This time, the entire space onscreen will be offered to the frame modifier, which in turn will offer its (200, 200)
space to the vertical stack. The vertical stack will still end up the same size as before, being centered within the (200, 200)
frame modifier by default. It’s important to keep in mind that calling APIs like .frame
and .offset
does not modify properties of the view, but rather wraps the view in a modifier. This really makes a difference when you try to combine these calls with other things like backgrounds or borders.
Let’s say we want to add a border to the (200, 200)
frame we’ve specified on the vertical stack. At first, we might try something like this:
VStack {
// ...
}
.border(Color.black)
.frame(width: 200, height: 200)
Perhaps surprisingly, the border will only show up around the minimal area of the vertical stack instead of in the 200-by-200 area we’ve specified. The reason is that the .border
call added an overlay modifier around the vertical stack, which just takes on the size of its children. If we want to draw the border around the entire (200, 200)
area, we’d have to reverse the calls:
VStack {
// ...
}
.frame(width: 200, height: 200)
.border(Color.black)
This time, the frame modifier gets wrapped in an overlay modifier, so the overlay (the border) now takes on the size of its child: the frame modifier with the fixed 200-by-200 size we’ve specified. This demonstrates how, while it might seem like a theoretical issue, the order of modifiers quickly becomes important to anyone who writes more than a short example.
Putting borders around views can be a helpful debugging technique to visualize the views’ frames.
In SwiftUI, you never force a view to take on a particular size directly. You can only wrap it in a frame modifier, whose available space will then be offered to its child. As we’ll see in the view layout chapter, views can define their ideal size (similar to UIKit’s sizeThatFits
method), and you can force a view to become its ideal size.
Implementing layouts where the layout of a parent is dependent on the size of its children (for example, if you wanted to reimplement VStack
) is a bit more complicated and requires the use of geometry readers and preferences, which we’ll cover later in this book.
View Updates
Now that the views have been constructed and laid out, SwiftUI displays them onscreen and waits for any state changes that affect the view tree. In our example, tapping the button triggers such a state change, since this modifies the @State
property counter
.
Properties that need to trigger view updates are marked with the @State
, @ObservedObject
, or @EnvironmentObject
property attributes (among others we’ll discuss in the next chapter). For now, it’s enough to know that changes to properties marked with any of these attributes will cause the view tree to be reevaluated.
When the counter
property in our example is changed, SwiftUI will access the body
property of the content view again to retrieve the view tree for the new state. Note that the type of the view tree (the complicated type hidden behind the some View
discussed above) does not change. In fact, it cannot change, since the type is fixed at compile time. Therefore, the only things that can change are properties of the views (like the text of the label showing the number of taps) and which branch of the if
statement is taken. The static encoding of the view tree’s structure in the view type has important performance advantages, which we’ll discuss in detail in the next chapter.
It’s important to keep in mind that changing state properties is the only way to trigger a view update in SwiftUI. We cannot do what is common in UIKit, i.e. modify the view tree in an event handler. This new way of doing things eliminates a whole category of common bugs — views getting out of sync with the application’s state — but it requires us to think differently: we have to model the application state explicitly and describe to SwiftUI what should be onscreen for each given state.
Takeaways
- SwiftUI views are values, not objects: they are immutable, transient descriptions of what should be onscreen.
- Almost all methods we call on a view (like
frame
orbackground
) wrap the view in a modifier. Therefore, the sequence of these calls matters, unlike with mostUIView
properties. - Layouts proceed top down: parent views offer their available space to their children, which decide their size based on that.
- We can’t update what’s onscreen directly. Instead, we have to modify state properties (e.g.
@State
or@ObservedObject
) and let SwiftUI figure out how the view tree has changed.
View Updates
In the first chapter, we looked at how the view tree gets constructed in SwiftUI and how it’s updated in response to state changes. In this chapter, we’ll go into detail about the view update process and explain what you need to know to write clean and efficient view update code.
Environment
The environment is an important piece of the puzzle for understanding how SwiftUI functions. In short, it is the mechanism SwiftUI uses to propagate values down the view tree, i.e. from a parent view to its contained subview tree. SwiftUI makes extensive use of the environment, but we can also leverage it for our own purposes.
In the first part of this chapter, we’ll explore how the environment works and how SwiftUI uses it. In the second part, we’ll read from the environment to customize view drawing. We’ll also use the environment to store custom values in a way similar to how SwiftUI uses the environment for built-in views. Lastly, we’ll look at environment objects, which allow dependency injection through a special mechanism built on top of the environment.
How the Environment Works
In SwiftUI, there are a lot of methods available on views that don’t seem to fit the view at hand: methods like font
, foregroundColor
, and lineSpacing
are available on all view types. For example, we can set the font on a VStack
or the line spacing on a Color
. How does this make sense?
To explore what’s going on here, we’ll again look at the type of the view we’re building up. Let’s start with a simple VStack
containing a text label:
var body: some View {
VStack {
Text("Hello World!")
}.debug()
}
// VStack<Text>
We’re using the debug
helper function from the first chapter to print out the concrete type of the view. In this case, it’s VStack<Text>
. Now let’s call the font
method on the stack and see how the type changes:
var body: some View {
VStack {
Text("Hello World!")
}
.font(Font.headline)
.debug()
}
/*
ModifiedContent<
VStack<Text>,
_EnvironmentKeyWritingModifier<Optional<Font>>
>
*/
The type tells us that the .font
call has wrapped the vertical stack in another view called ModifiedContent
, which has two generic parameters: the first one is the type of the content itself, and the second one is the modifier that’s being applied to this content. In this case, it’s the private _EnvironmentKeyWritingModifier
, which — as the name suggests — writes a value to the environment. For a .font
call, an optional Font
value is written to the environment. Since the environment is passed down the view tree, the text label within the stack can read the font value from the environment.
Even though setting a font on a vertical stack doesn’t make immediate sense, the font setting is not lost; it’s preserved via the environment for any child view in the tree that might actually be interested in it. To verify that the font value is available to the text label, we can print out the part of the environment we’re interested in. (Usually we would use the @Environment
property wrapper to read a specific value from the environment, but for debugging purposes, we can use transformEnvironment
):
var body: some View {
VStack {
Text("Hello, world!")
.transformEnvironment(\.font) { dump($0) }
}
.font(Font.headline)
}
// ...
// - style: SwiftUI.Font.TextStyle.headline
We can look up all the publicly available environment properties on the EnvironmentValues
type. However, SwiftUI stores more than just these public properties in the environment. To see everything that’s in there, we can use the transformEnvironment
modifier and pass \.self
as the key path.
Since font
is a public property on EnvironmentValues
, we can also use it to set the font in the environment instead of calling the font
method:
var body: some View {
VStack {
Text("Hello World!")
}
.environment(\.font, Font.headline)
.debug()
}
/*
ModifiedContent<
VStack<Text>,
_EnvironmentKeyWritingModifier<Optional<Font>>
>
*/
Layout
The view layout process has the task of assigning each view in the view tree a position and a size. In SwiftUI, the algorithm for this is simple in principle: for each view in the hierarchy, SwiftUI proposes a size (the available space). The view lays itself out within that available space and reports back with its actual size. The system (by default) then centers the view in the available space. While there’s no public API available for this, imagine that each View
has the following method implemented:
struct ProposedSize {
var width, height: CGFloat?
}
extension View {
func layout(in: ProposedSize) -> CGSize {
// ...
for child in children {
child.layout(in: ...)
}
// ...
}
}
To explain the layout behavior of individual views, we’ll pretend that the above method exists.
What makes layouts in SwiftUI complex is that each view (or view modifier) behaves differently when it comes to determining the actual size from the proposed size. For example, a shape always fits itself into the proposed size; a horizontal stack takes on the size that’s needed for its children (up to the proposed size); and a text view takes on the size needed to render its text, unless its size exceeds the proposed size, in which case the text gets clipped.
Typically, both dimensions of the proposed size will have a non-nil
value. A nil
value for a dimension means that the view can become its ideal size in that dimension. We’ll look at ideal sizes later in this chapter.
While the layout process assigns a size and a position to each view (in other words: a rectangle), the view doesn’t always draw itself within those bounds. This is especially useful during animations: we might want to keep a view’s layout position (so that other views stay in place as well) but draw the view with an offset or rotation.
In the rest of this chapter, we’ll explore SwiftUI’s layout process. We’ll start with the layout behavior of some elementary views, like Text
and Image
. Then we’ll look at several layout modifiers, like .frame
and .offset
. Finally, we’ll talk about stack views.
Elementary Views
Let’s take a more detailed look at the layout behavior of some commonly used views: shapes, images, and text views.
Since the exact layout behavior of SwiftUI’s views is not documented, we have to determine it by experimentation. A simple way of doing this is to wrap any view within a frame. The frame size is controlled by two sliders, which makes it easy to experiment:
struct MeasureBehavior<Content: View>: View {
@State private var width: CGFloat = 100
@State private var height: CGFloat = 100
var content: Content
var body: some View {
VStack {
content
.border(Color.gray)
.frame(width: width, height: height)
.border(Color.black)
Slider(value: $width, in: 0...500)
Slider(value: $height, in: 0...200)
}
}
}
We’re drawing two borders to show the actual sizes: the gray border is drawn around the view, and the black border is drawn around the frame. Sometimes the view chooses to be smaller than the proposed size, in which case the gray border will be smaller than the black border. When the view is larger than the proposed size (i.e. draws out of bounds) the gray border will be larger than the black border.
If we just want to visualize the actual size of a view, the easiest way is by putting a border around it.
Paths
A Path
is a list of 2D drawing instructions (similar to a CGPath
in Cocoa). When the layout
method on Path
is called, it always returns the proposed size as its actual size. If any of the proposed dimensions is nil
, it returns a default value of 10
. For example, here’s a Path
that draws a triangle in the top-left corner:
Path { p in
p.move(to: CGPoint(x: 50, y: 0))
p.addLines([
CGPoint(x: 100, y: 75),
CGPoint(x: 0, y: 75),
CGPoint(x: 50, y: 0)
])
}
In the example above, the bounding rectangle of the path has an origin of 0
, a width of 100
, and a height of 75
. While the path itself is drawn within the rectangle, the layout
method ignores the bounding rectangle; it still returns the proposed size.
Shapes
Often, we want a Path
to fit or fill the proposed size. We can achieve this using the Shape
protocol. Here’s the full definition of Shape
:
protocol Shape : Animatable, View {
func path(in rect: CGRect) -> Path
}
Shapes and paths have some of the most predictable layout behavior. Like Path
, the layout
method on Shape
always returns the proposed size as its actual size. Similar to Path
, Shape
chooses a default value of 10
when a proposed dimension is nil
. During the layout process, a Shape
receives a call to path(in:)
, and the rect
parameter contains the proposed size as its size. This makes it possible to draw a Path
that’s dependent on the proposed size.
Built-in shapes such as Rectangle
, Circle
, Ellipse
, and Capsule
draw themselves within the proposed size. Shapes without constrained aspect ratios, like Rectangle
, draw themselves by filling up the entire available space, whereas shapes like Circle
draw themselves to fit into the available space. When creating custom shapes, it’s good practice to stick with the same behavior and take the available space into account. For example, here’s another triangle, but it’s defined as a Shape
that fills the proposed size:
struct Triangle: Shape {
func path(in rect: CGRect) -> Path {
return Path { p in
p.move(to: CGPoint(x: rect.midX, y: rect.minY))
p.addLines([
CGPoint(x: rect.maxX, y: rect.maxY),
CGPoint(x: rect.minX, y: rect.maxY),
CGPoint(x: rect.midX, y: rect.minY)
])
}
}
}
A Shape
can have modifiers. For example, we could create a rotated Rectangle
:
Rectangle()
.rotation(.degrees(45))
.fill(Color.red)
The rotation
modifier returns a RotatedShape<Rectangle>
. In RotatedShape
’s layout
method, it passes the proposed size on to its child, unchanged, and it also returns that same size. In other words, the rotated rectangle above draws outside of its bounds. To visualize this, we can add a border around the rectangle:
Rectangle()
.rotation(.degrees(45))
.fill(Color.red)
.border(Color.blue)
.frame(width: 100, height: 100)
The blue border visualizes the size as far the layout system is concerned, yet the rectangle draws itself out of bounds. The offset
modifier on shapes exhibits the same behavior: it doesn’t change the layout; rather it draws the shape in a different position (as an OffsetShape
).
Custom Layout
In the previous chapter, we looked at the built-in ways of specifying layouts. In many cases, using the views and modifiers from the view layout chapter allows us to express a layout, but sometimes we need more customization. For example, what if we wanted to display completely different views depending on the available width? Or how do we implement something like a flow layout, in which items are put on a horizontal line until they don’t fit anymore, after which a new line is started (similar to word wrapping when rendering text)?
In this chapter, we’ll build upon the foundations of the previous chapter and show some techniques that will help you hook into (and customize) the layout process. We’ll discuss geometry readers (which allow you to receive the proposed layout size), preferences (which allow you to communicate up the view tree), and anchors. Finally, we’ll show how to build a custom layout by combining all these techniques.
Many of the techniques in this chapter feel like workarounds for the current limitations of SwiftUI. We believe that in future versions, SwiftUI will provide us with APIs that make a lot of the code in this chapter simpler or even unnecessary. That said, while some layouts are currently impossible without using these techniques, we recommend applying them cautiously.
Geometry Readers
We can hook into the layout process by using a GeometryReader
. Most importantly, we can use it to receive the proposed layout size for a view. A GeometryReader
is configured with a ViewBuilder
(just like any other container view), but unlike other containers, the view builder for a geometry reader receives a parameter: the GeometryProxy
. The proxy has a property for the view’s proposed layout size and a subscript to resolve anchors. Inside the ViewBuilder
, we can use this information to lay out our subviews.
For example, if we want to draw a Rectangle
with a width that’s a third of the proposed size, we can use a GeometryReader
:
GeometryReader { proxy in
Rectangle()
.fill(Color.red)
.frame(width: proxy.size.width/3)
}
An important caveat when working with GeometryReader
is that it reports its proposed size back as the actual size. Because of this sizing behavior, geometry readers are often especially useful when used as the background or overlay of another view: they become the exact size of the view. We can use this size either to draw something in the bounds of the view or to measure the size of the view (or both).
In the previous chapter, we built a small rounded button similar to the one in iOS’s built-in stopwatch app:
Circle()
.fill(Color.blue)
.overlay(Circle().strokeBorder(Color.white).padding(3))
.overlay(Text("Start").foregroundColor(.white))
.frame(width: 75, height: 75)
Unfortunately, we had to give the button a fixed size. To make it fit the text automatically, we could resort to a simple workaround: we could place a geometry reader inside the text’s background and use that to draw the circle. By putting some padding around the text, the circle would be slightly wider than the text:
Text("Start")
.foregroundColor(.white)
.padding(10)
.background(
GeometryReader { proxy in
Circle()
.fill(Color.blue)
.frame(width: proxy.size.width,
height: proxy.size.width)
})
One problem with the approach above is that the size of the entire view will still be the size of the text plus the padding; the height of the circle will be ignored. For example, if we clip the view using .clipped()
, it will simply show a blue rectangle as the background. Likewise, if we put multiple buttons like these in a vertical stack, the circles will overlap each other:
To fix this problem, we could put a frame around the entire view with the same width and height as the text’s width (plus padding). Unfortunately, we won’t know the width of the text until the views are laid out. In the next section, we’ll show how to use preferences to deal with this.
Preferences and GeometryReaders
Using preferences, we can communicate values up the view tree, from views to their ancestors. For example, if we have a way to measure the size of a view, we can communicate this size back to a parent view using a preference. Preferences are set using keys and values, and views can set a value for a specific preference key.
To solve the problem from the previous solution, our approach is as follows: using a GeometryReader
, we measure the size of the Text
inside our button. We then use a preference to communicate that value up the tree and add a frame around the entire view, with the width and height equal to the width of the text. (We’ll assume that the text is always wider than it is tall.)
Using the PreferenceKey
protocol, we can define our own preference keys. The protocol has one associated type (for the values) and two further requirements: a default value (for when there are no views that define a preference), and a way to combine two values. The latter requirement is necessary because a parent view can have more than one child, and each child could define its own preference. As such, the parent needs a way to combine all the preferences of the children into a single preference.
Going back to the example of our button, we want to collect the width of the Text
view. We’ll use a CGFloat?
as the value, where nil
means that we haven’t seen any Text
. For now, we’re not interested in combining values, so we simply take the first non-nil
value we see:
struct WidthKey: PreferenceKey {
static let defaultValue: CGFloat? = nil
static func reduce(value: inout CGFloat?,
nextValue: () -> CGFloat?) {
value = value ?? nextValue()
}
}
We are now ready to propagate our new preference. Like before, we start with the text and add a GeometryReader
— which will receive the text’s size as its proposed size — as the background. To set the preference, we have to call .preference(...)
on some view within the geometry reader’s closure. Since we don’t want to draw anything, we’ll use a Color.clear
view:
Text("Hello, world")
.background(GeometryReader { proxy in
Color.clear.preference(key: WidthKey.self, value: proxy.size.width)
})
We have tried to use
EmptyView()
instead ofColor.clear
to propagate the preference, but surprisingly, it didn’t work. We’re not sure if it’s just a bug or if there’s a deeper reason for the different behavior ofEmptyView()
andColor.clear
.
Animations
Since the very beginning of iOS, animations have been a key part of the user experience. Scroll views animate their content fluidly and have a bounce animation when they reach the end. Likewise, when you tap an app icon on your home screen, the app animates in from the icon’s position. These animations aren’t just ornamental; they provide the user with context.
SwiftUI has animations built into it from the start. You can use implicit animations and explicit animations, and you can even take full manual control of how things move over time onscreen. First, we’ll show some examples of what you can do with basic implicit animations, and then we’ll examine how animations work under the hood. Finally, we’ll show how you can create custom animations.
Implicit Animations
An implicit animation is part of the view tree: by adding an .animation
modifier to a view, any change to that view is automatically animated. As a first example, we’ll create a rounded button that changes its color and size when it’s tapped:
struct ContentView: View {
@State var selected: Bool = false
var body: some View {
Button(action: { self.selected.toggle() }) {
RoundedRectangle(cornerRadius: 10)
.fill(selected ? Color.red : .green)
.frame(width: selected ? 100 : 50, height: selected ? 100 : 50)
}.animation(.default)
}
}
When tapped, the button rectangle fluidly animates its size between (50, 50)
and (100, 100)
points, and the color changes from red to green. Creating animations in this way feels a bit like Magic Move in Apple’s Keynote application: we define the starting point and the end point, and the software figures out how to animate between the two.
Animations in SwiftUI are part of the view update system, which we looked at in chapter two. Like other view updates, animations can only be triggered through state changes (in the example above, by toggling the selected
property). When we add a modifier like .animation(.default)
to a view tree, SwiftUI animates the changes between the old and the new view tree on view updates.
Because animations are driven by state changes, some animations require us to get creative. For example, what if we want to build something like iOS’s built-in activity indicator, in which an image rotates infinitely? We can solve this through the use of a few tricks. First of all, we need some state that we can change to trigger the animation, so we’ll use a Boolean property, which we immediately set to true
when the view appears. Second, we need to repeat the animation indefinitely by adding repeatForever
to a linear animation. By default, a repeating animation reverses itself every other repeat, but we don’t want that (it would cause the indicator to rotate a full turn and then rotate backward), so we specify autoreverses
to be false
:
struct LoadingIndicator: View {
@State private var animating = false
var body: some View {
Image(systemName: "rays")
.rotationEffect(animating ? Angle.degrees(360) : .zero)
.animation(Animation
.linear(duration: 2)
.repeatForever(autoreverses: false)
)
.onAppear { self.animating = true }
}
}
While the solution above does not feel very clean due to the use of onAppear
and the Boolean state property, we were at least able to hide all the implementation details. Using this loading indicator is as simple as writing LoadingIndicator()
.
Transitions
The animations we’ve looked at so far animate a view that’s onscreen from one state to another. But sometimes we might want to animate the insertion of a new view or the removal of an existing view. SwiftUI has a specific construct for this purpose: transitions. For example, here’s a transition that animates a rectangle on and off the screen using a slide animation. When the rectangle gets inserted into the view tree, it animates in from the left, and when it gets removed from the view tree, it animates to the right:
struct ContentView: View {
@State var visible = false
var body: some View {
VStack {
Button("Toggle") { self.visible.toggle() }
if visible {
Rectangle()
.fill(Color.blue)
.frame(width: 100, height: 100)
.transition(.slide)
.animation(.default)
}
}
}
}
Note that transitions don’t animate by themselves — we still have to enable animations. Just like before, we use .animation(.default)
for this. We can also combine transitions. For example, AnyTransition.move(edge: .leading).combined(with: .opacity)
moves the view from and to the leading edge and performs a fade at the same time. To further customize transitions, we can use .asymmetric
, which lets us specify one transition for insertion and another for removal of the view.