Day 93
Day 93 êŽë š
Project 18, part 2
Today weâre continuing our technique project on view layout, exploring one of the most powerful layout views we have available to us: GeometryReader
. This lets us read the size and position for a view at runtime, and keep reading those values as they change over time.
I realize that probably doesnât sound terribly special, but it opens the door to a number of fascinating effects that look great and only take one or two lines of code to create. Yes, one or two â once you understand how GeometryReader
works I really hope youâre able to take some time to experiment!
As the British poet William Blake once said, âthe true method of knowledge is experiment,â so if you really want this stuff to stick in your head you should play around with it!
Today you have four topics to work through, in which youâll learn about frames, coordinate spaces, GeometryReader
, and more.
Resizing images to fit the screen using GeometryReader
Resizing images to fit the screen using GeometryReader
SwiftUI lets us create views with exact sizes like this:
Image(.example)
.resizable()
.scaledToFit()
.frame(width: 300, height: 300)
All this works great if we want fixed-sized views, but very often you want images that automatically scale up to fill more of the screen in one or both dimensions. That is, rather than hard-coding a width of 300, what you really want to say is âmake this image fill 80% of the width of the screen.â
One option is to use the containerRelativeFrame()
modifier, which we covered back in project 8. But SwiftUI also gives us a dedicated type for this work called GeometryReader
, and itâs remarkably powerful.
Weâll go into much more detail on GeometryReader
shortly, but for now weâre going to use it for one job: to make sure our image fills some percentage of its container's width.
GeometryReader
is a view just like the others weâve used, except when we create it weâll be handed a GeometryProxy object to use. This lets us query the environment: how big is the container? What position is our view? Are there any safe area insets? And so on.
In principle that seems simple enough, but in practice you need to use GeometryReader
carefully because it automatically expands to take up available space in your layout, then positions its own content aligned to the top-left corner.
For example, we could make an image thatâs 80% the width of the screen, with a fixed height of 300:
GeometryReader { proxy in
Image(.example)
.resizable()
.scaledToFit()
.frame(width: proxy.size.width * 0.8, height: 300)
}
You can even remove the height
from the image, like this:
GeometryReader { proxy in
Image(.example)
.resizable()
.scaledToFit()
.frame(width: proxy.size.width * 0.8)
}
Weâve given SwiftUI enough information that it can automatically figure out the height: it knows the original width, it knows our target width, and it knows our content mode, so it understands how the target height of the image will be proportional to the target width.
Now, you're probably wondering how this is any different from using containerRelativeFrame()
. Well, the problem is that containerRelativeFrame()
has a very precise definition of what constitutes a "container": it might be the whole screen, it might be a NavigationStack
, it might be a List
or a ScrollView
, and so on, but it won't consider a HStack
or a VStack
a container.
This causes problems when using views in stacks, because you can't easily subdivide them using containerRelativeFrame()
. For example, the code below places two views in a HStack
, with one being given a fixed width and the other using a container relative frame:
HStack {
Text("IMPORTANT")
.frame(width: 200)
.background(.blue)
Image(.example)
.resizable()
.scaledToFit()
.containerRelativeFrame(.horizontal) { size, axis in
size * 0.8
}
}
That's not going to lay out well at all, because the containerRelativeFrame()
will read the whole screen width for its size, meaning that image will be 80% the screen width despite 200 points of the screen being a text view.
On the other hand, using a GeometryReader
will subdivide the space correctly:
GeometryReader { proxy in
Image(.example)
.resizable()
.scaledToFit()
.frame(width: proxy.size.width * 0.8)
}
Of course, that introduces a different problem: our image is now aligned to the top-left corner of the GeometryReader
!
Fortunately, this is easily solved. If you ever want to center a view inside a GeometryReader
, rather than aligning to the top-left corner, add a second frame that makes it fill the full space of the container, like this:
GeometryReader { proxy in
Image(.example)
.resizable()
.scaledToFit()
.frame(width: proxy.size.width * 0.8)
.frame(width: proxy.size.width, height: proxy.size.height)
}
Understanding frames and coordinates inside GeometryReader
Understanding frames and coordinates inside GeometryReader
SwiftUIâs GeometryReader
allows us to use its size and coordinates to determine a child viewâs layout, and itâs the key to creating some of the most remarkable effects in SwiftUI.
You should always keep in mind SwiftUIâs three-step layout system when working with GeometryReader
: parent proposes a size for the child, the child uses that to determine its own size, and parent uses that to position the child appropriately.
In its most basic usage, what GeometryReader
does is let us read the size that was proposed by the parent, then use that to manipulate our view. For example, we could use GeometryReader
to make a text view have 90% of all available width regardless of its content:
struct ContentView: View {
var body: some View {
GeometryReader { proxy in
Text("Hello, World!")
.frame(width: proxy.size.width * 0.9)
.background(.red)
}
}
}
That proxy
parameter that comes in is a GeometryProxy
, and it contains the proposed size, any safe area insets that have been applied, plus a method for reading frame values that weâll look at in a moment.
GeometryReader
has an interesting side effect that might catch you out at first: the view that gets returned has a flexible preferred size, which means it will expand to take up more space as needed. You can see this in action if you place the GeometryReader
into a VStack
then put some more text below it, like this:
struct ContentView: View {
var body: some View {
VStack {
GeometryReader { proxy in
Text("Hello, World!")
.frame(width: proxy.size.width * 0.9, height: 40)
.background(.red)
}
Text("More text")
.background(.blue)
}
}
}
Youâll see âMore textâ gets pushed right to the bottom of the screen, because the GeometryReader
takes up all remaining space. To see it in action, add background(.green)
as a modifier to the GeometryReader
and youâll see just how big it is. Note: This is a preferred size, not an absolute size, which means itâs still flexible depending on its parent.
When it comes to reading the frame of a view, GeometryProxy
provides a frame(in:)
method rather than simple properties. This is because the concept of a âframeâ includes X and Y coordinates, which donât make any sense in isolation â do you want the viewâs absolute X and Y coordinates, or their X and Y coordinates compared to their parent?
SwiftUI calls these options coordinate spaces, and those two in particular are called the global space (measuring our viewâs frame relative to the whole screen), and the local space (measuring our viewâs frame relative to its parent). We can also create custom coordinate spaces by attaching the coordinateSpace()
modifier to a view â any children of that can then read its frame relative to that coordinate space.
To demonstrate how coordinate spaces work, we could create some example views in various stacks, attach a custom coordinate space to the outermost view, then add an onTapGesture
to one of the views inside it so it can print out the frame globally, locally, and using the custom coordinate space.
Try this code:
struct OuterView: View {
var body: some View {
VStack {
Text("Top")
InnerView()
.background(.green)
Text("Bottom")
}
}
}
struct InnerView: View {
var body: some View {
HStack {
Text("Left")
GeometryReader { proxy in
Text("Center")
.background(.blue)
.onTapGesture {
print("Global center: \(proxy.frame(in: .global).midX) x \(proxy.frame(in: .global).midY)")
print("Custom center: \(proxy.frame(in: .named("Custom")).midX) x \(proxy.frame(in: .named("Custom")).midY)")
print("Local center: \(proxy.frame(in: .local).midX) x \(proxy.frame(in: .local).midY)")
}
}
.background(.orange)
Text("Right")
}
}
}
struct ContentView: View {
var body: some View {
OuterView()
.background(.red)
.coordinateSpace(name: "Custom")
}
}
The output you get when that code runs depends on the device youâre using, but hereâs what I got:
- Global center: 191.33 x 440.60
- Custom center: 191.33 x 381.60
- Local center: 153.66 x 350.63
Those sizes are mostly different, so hopefully you can see the full range of how these frame work:
- A global center X of 191 means that the center of the geometry reader is 191 points from the left edge of the screen.
- A global center Y of 440 means the center of the geometry reader is 440 points from the top edge of the screen. This isnât dead in the center of the screen because there is more safe area at the top than the bottom.
- A custom center X of 191 means the center of the geometry reader is 191 points from the left edge of whichever view owns the âCustomâ coordinate space, which in our case is
OuterView
because we attach it inContentView
. This number matches the global position becauseOuterView
runs edge to edge horizontally. - A custom center Y of 381 means the center of the geometry reader is 381 points from the top edge of
OuterView
. This value is smaller than the global center Y becauseOuterView
doesnât extend into the safe area. - A local center X of 153 means the center of the geometry reader is 153 points from the left edge of its direct container.
- A local center Y of 350 means the center of the geometry reader is 350 points from the top edge of its direct container.
Which coordinate space you want to use depends on what question you want to answer:
- Want to know where this view is on the screen? Use the global space.
- Want to know where this view is relative to its parent? Use the local space.
- What to know where this view is relative to some other view? Use a custom space.
ScrollView effects using GeometryReader
ScrollView effects using GeometryReader
When we use the frame(in:)
method of a GeometryProxy
, SwiftUI will calculate the viewâs current position in the coordinate space we ask for. However, as the view moves those values will change, and SwiftUI will automatically make sure GeometryReader
stays updated.
Previously we used DragGesture
to store a width and height as an @State
property, because it allowed us to adjust other properties based on the drag amount to create neat effects. However, with GeometryReader
we can grab values from a viewâs environment dynamically, feeding in its absolute or relative position into various modifiers. Even better, you can nest geometry readers if needed, so that one can read the geometry for a higher-up view and the other can read the geometry for something further down the tree.
To try some effects with GeometryReader
, we could create a spinning helix effect by creating 50 text views in a vertical scroll view, each of which has an infinite maximum width so they take up all the screen space, then apply a 3D rotation effect based on their own position.
Start by making a basic ScrollView
of text views with varying background colors:
struct ContentView: View {
let colors: [Color] = [.red, .green, .blue, .orange, .pink, .purple, .yellow]
var body: some View {
ScrollView {
ForEach(0..<50) { index in
GeometryReader { proxy in
Text("Row #\(index)")
.font(.title)
.frame(maxWidth: .infinity)
.background(colors[index % 7])
}
.frame(height: 40)
}
}
}
}
To apply a helix-style spinning effect, place this rotation3DEffect()
directly below the background()
modifier:
.rotation3DEffect(.degrees(proxy.frame(in: .global).minY / 5), axis: (x: 0, y: 1, z: 0))
When you run that back youâll see that text views at the bottom of the screen are flipped, those at the center are rotated about 90 degrees, and those at the very top are normal. More importantly, as you scroll around they all rotate as you move in the scroll view.
Thatâs a neat effect, but itâs also problematic because the views only reach their natural orientation when they are at the very top â itâs really hard to read. To fix this, we can apply a more complex rotation3DEffect()
that subtracts half the height of the main view, but that means using a second GeometryReader
to get the size of the main view:
struct ContentView: View {
let colors: [Color] = [.red, .green, .blue, .orange, .pink, .purple, .yellow]
var body: some View {
GeometryReader { fullView in
ScrollView {
ForEach(0..<50) { index in
GeometryReader { proxy in
Text("Row #\(index)")
.font(.title)
.frame(maxWidth: .infinity)
.background(colors[index % 7])
.rotation3DEffect(.degrees(proxy.frame(in: .global).minY - fullView.size.height / 2) / 5, axis: (x: 0, y: 1, z: 0))
}
.frame(height: 40)
}
}
}
}
}
With that in place, the views will reach a natural orientation nearer the center of the screen, which will look better.
We can use a similar technique to create CoverFlow-style scrolling rectangles:
struct ContentView: View {
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
ForEach(1..<20) { num in
GeometryReader { proxy in
Text("Number \(num)")
.font(.largeTitle)
.padding()
.background(.red)
.rotation3DEffect(.degrees(-proxy.frame(in: .global).minX) / 8, axis: (x: 0, y: 1, z: 0))
.frame(width: 200, height: 200)
}
.frame(width: 200, height: 200)
}
}
}
}
}
All this code works well, and I think you'll agree these are fun effects. But they aren't exactly easy. Fortunately, SwiftUI provides better options â let's look at them nextâŠ
ScrollView effects using visualEffect() and scrollTargetBehavior()
ScrollView effects using visualEffect() and scrollTargetBehavior()
Previously we looked at how to use GeometryReader
to create varying effects based on where a view is on the screen. That code all works fine, and you'll certainly see it in lots of apps, but SwiftUI provides some helpful alternatives that can be much easier.
First, let's look again at some previous code â this creates a simple CoverFlow-style effect, where we can swipe horizontally to see views moving in 3D space:
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
ForEach(1..<20) { num in
GeometryReader { proxy in
Text("Number \(num)")
.font(.largeTitle)
.padding()
.background(.red)
.rotation3DEffect(.degrees(-proxy.frame(in: .global).minX) / 8, axis: (x: 0, y: 1, z: 0))
.frame(width: 200, height: 200)
}
.frame(width: 200, height: 200)
}
}
}
That code uses GeometryReader
to read each view's position in the scroll view, but we've needed to add an explicit width and height to make stop our GeometryReader
from automatically expanding to take up all available space.
SwiftUI gives us an alternative called visualEffect()
, and it has a very specific purpose and a very specific restriction: it lets us apply effects that change the way something looks, which in practice means it can't do anything that affects the actual layout position or frame of a view.
This modifier works in a very interesting way: we pass it a closure to run, and we'll be given the content we're modifying as well as a GeometryProxy
for it. That content we're modifying is our view, but we can't just apply any modifiers we want like we normally would â again, we can't do anything that affects the layout position of the view.
Fortunately, that still leaves lots of modifiers for us to use, including some that might surprise you â we can use rotationEffect()
, rotation3DEffect()
, and even offset()
, because although they effect how views are drawn, they don't change the frame of the view.
So, we can rewrite our code using visualEffect()
like this:
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 0) {
ForEach(1..<20) { num in
Text("Number \(num)")
.font(.largeTitle)
.padding()
.background(.red)
.frame(width: 200, height: 200)
.visualEffect { content, proxy in
content
.rotation3DEffect(.degrees(-proxy.frame(in: .global).minX) / 8, axis: (x: 0, y: 1, z: 0))
}
}
}
}
Although the code is only a little shorter, this is a much neater solution than using GeometryReader
because we no longer need to add a second frame()
modifier to stop things taking up the full screen â this scroll view can fit alongside other parts of our SwiftUI layout without screwing things up.
What we have now is a lot nicer, but with just two extra modifiers we can make this effect work a lot better.
The first is scrollTargetLayout()
, which I'd like you to apply to the HStack
. That tells SwiftUI we want to make each view inside this HStack
a scroll target â something that is considered important when it comes to scrolling around.
The second is .scrollTargetBehavior(.viewAligned)
, which I'd like you to apply to the ScrollView
. That tells SwiftUI it should make this scroll view move smoothly between all scroll targets, which we just defined as being every view inside our HStack
.
If you put those two together, the result is lovely: we can now scroll smoothly between our text views, and whenever we let go SwiftUI will automatically ensure one view snaps to the left edge.
If you make some fun effects, try recording a video and sharing it online â itâs a great way to stay accountable, but also to show folks how far youâve come!