Skip to main content

Day 93

About 11 minSwiftcrashcoursepaul-hudsonswiftswiftuihacking-with-swiftxcodeappstore

Day 93 ꎀ렚


100 Days of SwiftUI - Day 93

Project 18, part 2

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
100 Days of SwiftUI - Day 93 - 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
100 Days of SwiftUI - Day 93 - 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 in ContentView. This number matches the global position because OuterView 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 because OuterView 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
100 Days of SwiftUI - Day 93 - 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()
100 Days of SwiftUI - Day 93 - 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!


ìŽì°ŹíŹ (MarkiiimarK)
Never Stop Learning.