Day 92
Day 92 êŽë š
Project 18, part 1
In our final technique project for these 100 days, weâll be looking at how SwiftUI handles layout and geometry. Yes, I realize you might have expected this kind of thing to be covered much earlier, but one of the great things about SwiftUI is that it does so much work for us â that this kind of tutorial appears so late in this series is testament to how good SwiftUIâs standard layout is.
Randall Munroe, author of the xkcd comic, once said âif you really hate someone, teach them to recognize bad kerning.â If you werenât already aware, kerning is the spacing between letters, and bad kerning is surprisingly common â once you learn to spot it, you realize itâs everywhere.
Today youâre going to look at alignment, and this is another thing that is hard to ignore when you know about it. Sure, itâs easy to spot when one thing is centered and another thing isnât, but what if two things are aligned to slightly different leading edges? Itâs invisible until you know about, but when you start noticing itâs impossible to stop!
Today you have five topics to work through, in which youâll learn about the rules of layout, alignment, custom guides, and more.
Layout and geometry: Introduction
Layout and geometry: Introduction
In this technique project weâre going to explore how SwiftUI handles layout. Some of these things have been explained a little already, some of them you might have figured out yourself, but many more are things you might just have taken for granted to this point, so I hope a detailed exploration will really shed some light on how SwiftUI works.
Along the way youâll also learn about creating more advanced layout alignments, building special effects using GeometryReader
, and more â some real power features that I know youâll be keen to deploy in your own apps.
Go ahead and create a new iOS project using the App template, naming it LayoutAndGeometry. Youâll need an image in your asset catalog in order to follow the chapter on custom alignment guides, but it can be anything you want â itâs just a placeholder really.
How layout works in SwiftUI
How layout works in SwiftUI
All SwiftUI layout happens in three simple steps, and understanding these steps is the key to getting great layouts every time. The steps are:
- A parent view proposes a size for its child.
- Based on that information, the child then chooses its own size and the parent must respect that choice.
- The parent then positions the child in its coordinate space.
Behind the scenes, SwiftUI performs a fourth step: although it stores positions and sizes as floating-point numbers, when it comes to rendering SwiftUI rounds off any pixels to their nearest values so our graphics remain sharp.
Those three rules might seem simple, but they allow us to create hugely complicated layouts where every view decides how and when it resizes without the parent having to get involved.
To demonstrate these rules in action, Iâd like you to use a simple Text
with a background()
modifier, like this:
struct ContentView: View {
var body: some View {
Text("Hello, World!")
.background(.red)
}
}
Youâll see the background color sits tightly around the text itself â it takes up only enough space to fit the content we provided.
Now, think about this question: how big is ContentView
? As you can see, the body of ContentView
â the thing that it renders â is some text with a background color. And so the size of ContentView
is exactly and always the size of its body, no more and no less. This is called being layout neutral: ContentView
doesnât have any size of its own, and instead happily adjusts to fit whatever size is needed.
Back in project 3 I explained to you that when you apply a modifier to a view we actually get back a new view type called ModifiedContent
, which stores both our original view and its modifier. This means when we apply a modifier, the actual view that goes into the hierarchy is the modified view, not the original one.
In our simple background()
example, that means the top-level view inside ContentView
is the background, and inside that is the text. Backgrounds are layout neutral just like ContentView
, so it will just pass on any layout information as needed â you can end up with a chain of layout information being passed around until a definitive answer comes back.
If we put this into the three-step layout system, we end up with a conversation a bit like this:
- SwiftUI: âHey, ContentView, you have the whole screen to yourself â how much of it do you need?â (Parent view proposes a size)
- ContentView: âI donât care; Iâm layout neutral. Let me ask my child: hey, background, you have the whole screen to yourself â how much of it do you need?â (Parent view proposes a size)
- Background: âI also donât care; Iâm layout neutral too. Let me ask my child: hey, text, you can have the whole screen to yourself â how much of it do you need?â (Parent view proposes a size)
- Text: âWell, I have the letters âHello, Worldâ in the default font, so I need exactly X pixels width by Y pixels height. I donât need the whole screen, just that.â (Child chooses its size.)
- Background: âGot it. Hey, ContentView: I need X by Y pixels, please.â
- ContentView: âRight on. Hey, SwiftUI: I need X by Y pixels.â
- SwiftUI: âNice. Well, that leaves lots of space, so Iâm going to put you at your size in the center.â (Parent positions the child in its coordinate space.)
So, when we say Text("Hello, World!").background(.red)
, the text view becomes a child of its background. SwiftUI effectively works its way from bottom to top when it comes to a view and its modifiers.
Now consider this layout:
Text("Hello, World!")
.padding(20)
.background(.red)
This time the conversation is more complicated: padding()
no longer offers all its space to its child, because it needs to subtract 20 points from each side to make sure thereâs enough space for the padding. Then, when the answer comes back from the text view, padding()
adds 20 points on each side to pad it out, as requested.
So, itâs more like this:
- SwiftUI: You can have the whole screen, how much of it do you need, ContentView?
- ContentView: You can have the whole screen, how much of it do you need, background?
- Background: You can have the whole screen, how much of it do you need, padding?
- Padding: You can have the whole screen minus 20 points on each side, how much of it do you need, text?
- Text: I need X by Y.
- Padding: I need X by Y plus 20 points on each side.
- Background: I need X by Y plus 20 points on each side.
- ContentView: I need X by Y plus 20 points on each side.
- SwiftUI: OK; Iâll center you.
If you remember, the order of our modifiers matters. That is, this code:
Text("Hello, World!")
.padding()
.background(.red)
And this code:
Text("Hello, World!")
.background(.red)
.padding()
Yield two different results. Hopefully now you can see why: background()
is layout neutral, so it determines how much space it needs by asking its child how much space it needs and using that same value. If the child of background()
is the text view then the background will fit snugly around the text, but if the child is padding()
then it receive back the adjusted values that including the padding amount.
There are two interesting side effects that come as a result of these layout rules.
First, if your view hierarchy is wholly layout neutral, then it will automatically take up all available space. For example, shapes and colors are layout neutral, so if your view contains a color and nothing else it will automatically fill the screen like this:
var body: some View {
Color.red
}
Remember, Color.red
is a view in its own right, but because it is layout neutral it can be drawn at any size. When we used it inside background()
the abridged layout conversation worked like this:
- Background: Hey text, you can have the whole screen â how much of that do you want?
- Text: I need X by Y points; I donât need the rest.
- Background: OK. Hey, Color.red: you can have X by Y points â how much of that do you want?
- Color.red: I donât care; Iâm layout neutral, so X by Y points sounds good to me.
The second interesting side effect is one we faced earlier: if we use frame()
on an image that isnât resizable, we get a larger frame without the image inside changing size. This might have been confusing before, but it makes absolute sense once you think about the frame as being the parent of the image:
ContentView
offers the frame the whole screen.- The frame reports back that it wants 300x300.
- The frame then asks the image inside it what size it wants.
- The image, not being resizable, reports back a fixed size of 64x64 (for example).
- The frame then positions that image in the center of itself.
When you listen to Appleâs own SwiftUI engineers talk about modifiers, youâll hear them refer them to as views â âthe frame viewâ, âthe background viewâ, and so on. I think thatâs a great mental model to help understand exactly whatâs going on: applying modifiers creates new views rather than just modifying existing views in-place.
Alignment and alignment guides
Alignment and alignment guides
SwiftUI gives us a number of valuable ways of controlling the way views are aligned, and I want to walk you through each of them so you can see them in action.
The simplest alignment option is to use the alignment
parameter of a frame()
modifier. Remember, a text view always uses the exact width and height required to show its text, but when we place a frame around it that can be any size. As the parent doesnât have a say in the final size of the child, code like this will create a 300x300 frame with a smaller text view centered inside it:
Text("Live long and prosper")
.frame(width: 300, height: 300)
If you donât want the text to be centered, use the alignment
parameter of the frame()
. For example, this code places the view in the top-left corner when running on a left-to-right environment
.frame(width: 300, height: 300, alignment: .topLeading)
You can then use offset(x:y:)
to move the text around inside that frame.
The next option up is to use the alignment
parameter of a stack. For example, here are four text views of varying sizes arranged in a HStack
:
HStack {
Text("Live")
.font(.caption)
Text("long")
Text("and")
.font(.title)
Text("prosper")
.font(.largeTitle)
}
We havenât specified an alignment there, so they will be centered by default. That doesnât look great, so you might think to align them all to one edge to get a neater line, like this:
HStack(alignment: .bottom) {
However, that also looks bad: because each of the text views has a different size, they also have a different baseline â thatâs the name for where letters such as âabcdeâ sit on a line, which excludes letters that go below the line such as âgjpyâ. As a result, the bottom of the small text sits lower than the bottom of the bigger text.
Fortunately, SwiftUI has two special alignments that align text on the baseline of either the first child or the last child. This will cause all views in a stack to be aligned on a single unified baseline, regardless of their font:
HStack(alignment: .lastTextBaseline) {
Moving on, for more fine-grained control we can customize what âalignmentâ means for each individual view. To get a really good idea of how this works weâre going to start with this code:
struct ContentView: View {
var body: some View {
VStack(alignment: .leading) {
Text("Hello, world!")
Text("This is a longer line of text")
}
.background(.red)
.frame(width: 400, height: 400)
.background(.blue)
}
When that runs youâll see the VStack
sits tightly around its two text views with a red background. The two text views have different lengths, but because we used the .leading
alignment they will both be aligned to their left edge in a left-to-right environment. Outside of that thereâs a larger frame that has a blue background. Because the frame is larger than the VStack
, the VStack
is centered in the middle.
Now, when the VStack
comes to aligning each of those text views, it asks them to provide their leading edge. By default this is obvious: it uses either the left or right edge of the view, depending on the system language. But what if we wanted to change that â what if we wanted to make one view have a custom alignment?
SwiftUI provides us with the alignmentGuide()
modifier for just this purpose. This takes two parameters: the guide we want to change, and a closure that returns a new alignment. The closure is given a ViewDimensions
object that contains the width and height of its view, along with the ability to read its various edges.
By default, the .leading
alignment guide for a view is its leading alignment guide â I know that sounds obvious, but its effectively equivalent to this:
VStack(alignment: .leading) {
Text("Hello, world!")
.alignmentGuide(.leading) { d in d[.leading] }
Text("This is a longer line of text")
}
We could rewrite that alignment guide to use the viewâs trailing edge for its leading alignment guide, like this:
VStack(alignment: .leading) {
Text("Hello, world!")
.alignmentGuide(.leading) { d in d[.trailing] }
Text("This is a longer line of text")
}
And now youâll see why I added colors: the first text view will move to the left so that its right edge sits directly above the left edge of the view below, the VStack
will expand to contain it, and the whole thing will still be centered within the blue frame.
This result is different from using the offset()
modifier: if you offset a text its original dimensions donât actually change, even though the resulting view is rendered in a different location. If we had offset the first text view rather than changing its alignment guide, the VStack
wouldnât expand to contain it.
Although the alignment guide closure is passed your viewâs dimensions, you donât need to use them if you donât want to â you can send back a hard-coded number, or create some other calculation. For example, this creates a tiered effect for 10 text views by multiplying their position by -10:
var body: some View {
VStack(alignment: .leading) {
ForEach(0..<10) { position in
Text("Number \(position)")
.alignmentGuide(.leading) { _ in Double(position) * -10 }
}
}
.background(.red)
.frame(width: 400, height: 400)
.background(.blue)
}
For complete control over your alignment guides you need to create a custom alignment guide. And I think that deserves a mini chapter all of its ownâŠ
How to create a custom alignment guide
How to create a custom alignment guide
SwiftUI gives us alignment guides for the various edges of our views (.leading
, .trailing
, .top
, and so on) plus .center
and two baseline options to help with text alignment. However, none of these work well when youâre working with views that are split across disparate views â if you have to make two views aligned the same when they are in entirely different parts of your user interface.
To fix this, SwiftUI lets us create custom alignment guides, and use those guides in views across our UI. It doesnât matter what comes before or after these views; they will still line up.
For example, hereâs a layout that shows my Twitter account name and my profile picture on the left, and on the right shows âFull name:â plus âPaul Hudsonâ in a large font:
struct ContentView: View {
var body: some View {
HStack {
VStack {
Text("@twostraws")
Image(.paulHudson)
.resizable()
.frame(width: 64, height: 64)
}
VStack {
Text("Full name:")
Text("PAUL HUDSON")
.font(.largeTitle)
}
}
}
}
If you want â@twostrawsâ and âPaul Hudsonâ to be vertically aligned together, youâll have a hard time right now. The horizontal stack contains two vertical stacks inside it, so thereâs no built-in way to get the alignment you want â things like HStack(alignment: .top)
just wonât come close.
To fix this we need to define a custom layout guide. This should be an extension on either VerticalAlignment
or HorizontalAlignment
, and be a custom type that conforms to the AlignmentID
protocol.
When I say âcustom typeâ you might be thinking of a struct, but itâs actually a good idea to implement this as an enum instead as Iâll explain shortly. The AlignmentID
protocol has only one requirement, which is that the conforming type must provide a static defaultValue(in:)
method that accepts a ViewDimensions
object and returns a CGFloat
specifying how a view should be aligned if it doesnât have an alignmentGuide()
modifier. Youâll be given the existing ViewDimensions
object for the view, so you can either pick one of those for your default or use a hard-coded value.
Letâs write out the code so you can see how it looks:
extension VerticalAlignment {
struct MidAccountAndName: AlignmentID {
static func defaultValue(in context: ViewDimensions) -> CGFloat {
context[.top]
}
}
static let midAccountAndName = VerticalAlignment(MidAccountAndName.self)
}
You can see Iâve used the .top
view dimension by default, and Iâve also created a static constant called midAccountAndName
to make the custom alignment easier to use.
Now, I mentioned that using an enum is preferable to a struct, and hereâs why: we just created a new struct called MidAccountAndName
, which means we could (if we wanted) create an instance of that struct even though doing so doesnât make sense because it doesnât have any functionality. If you replace struct MidAccountAndName
with enum MidAccountAndName
then you canât make an instance of it any more â it becomes clearer that this thing exists only to house some functionality.
Regardless of whether you choose an enum or a struct, its usage stays the same: set it as the alignment for your stack, then use alignmentGuide()
to activate it on any views you want to align together. This is only a guide: it helps you align views along a single line, but doesnât say how they should be aligned. This means you still need to provide the closure to alignmentGuide()
that positions the views along that guide as you want.
For example, we could update our Twitter code to use .midAccountAndName
, then tell the account and name to use their center position for the guide. To be clear, that means âalign these two views so their centers are both on the .midAccountAndName
guideâ.
Hereâs how that looks in code:
HStack(alignment: .midAccountAndName) {
VStack {
Text("@twostraws")
.alignmentGuide(.midAccountAndName) { d in d[VerticalAlignment.center] }
Image(.paulHudson)
.resizable()
.frame(width: 64, height: 64)
}
VStack {
Text("Full name:")
Text("PAUL HUDSON")
.alignmentGuide(.midAccountAndName) { d in d[VerticalAlignment.center] }
.font(.largeTitle)
}
}
That will make sure they are vertically aligned regardless of what comes before or after. I suggest you try adding some more text views before and after our examples â SwiftUI will reposition everything to make sure the two we aligned stay that way.
Absolute positioning for SwiftUI views
Absolute positioning for SwiftUI views
SwiftUI gives us two ways of positioning views: absolute positions using position()
, and relative positions using offset()
. They might seem similar, but once you understand how SwiftUI places views inside frames the underlying differences between position()
and offset()
become clearer.
A simple SwiftUI view looks like this:
struct ContentView: View {
var body: some View {
Text("Hello, world!")
}
}
SwiftUI offers the full available space to ContentView
, which in turn passes it on to the text view. The text view automatically uses only as much as space as its text needs, so it passes that back up to ContentView
, which is always and exactly the same size as its body
(so it directly fits around the text). As a result, SwiftUI centers ContentView
in the available space, which from a userâs perspective is what places the text in the center.
If you want to absolutely position a SwiftUI view you should use the position()
modifier like this:
Text("Hello, world!")
.position(x: 100, y: 100)
That will position the text view at x:100 y:100 within its parent. Now, to really see whatâs happening here I want you to add a background color:
Text("Hello, world!")
.background(.red)
.position(x: 100, y: 100)
Youâll see the text has a red background tightly fitted around it. Now try moving the background()
modifier below the position()
modifier, like this:
Text("Hello, world!")
.position(x: 100, y: 100)
.background(.red)
Now youâll see the text is in the same location, but the whole safe area is colored red.
To understand whatâs happening here you need to remember the three step layout process of SwiftUI:
- A parent view proposes a size for its child.
- Based on that information, the child then chooses its own size and the parent must respect that choice.
- The parent then positions the child in its coordinate space.
So, the parent is responsible for positioning the child, not the child. This causes a problem, because weâve just told our text view to be at an exact position â how can SwiftUI resolve this?
The answer to this is also why our background()
color made the whole safe area red: when we use position()
we get back a new view that takes up all available space, so it can position its child (the text) at the correct location.
When we use text, position, then background the position will take up all available space so it can position its text correctly, then the background will use that size for itself. When we use text, background, then position, the background will use the text size for its size, then the position will take up all available space and place the background in the correct location.
When discussing the offset()
modifier earlier, I said âif you offset some text its original dimensions donât actually change, even though the resulting view is rendered in a different location.â With that in mind, try running this code:
var body: some View {
Text("Hello, world!")
.offset(x: 100, y: 100)
.background(.red)
}
Youâll see the text appears in one place and the background in another. Iâm going to explain why that is, but first I want you to think about it yourself because if you understand that then you really understand how SwiftUIâs layout system works.
When we use the offset()
modifier, weâre changing the location where a view should be rendered without actually changing its underlying geometry. This means when we apply background()
afterwards it uses the original position of the text, not its offset. If you move the modifier order so that background()
comes before offset()
then things work more like you might have expected, showing once again that modifier order matters.