SwiftUI GeometryReader: Sticky Header When Scrolling - Part 5

Mark Moeykens
Nov 18, 2019


This is the final part of the GeometryReader series! Congrats on making it this far. 🎉 You can find Part 4 here.

In this article, you're going to learn how to persist a header or make it stick to the top while the rest of the content scrolls beneath it. "Huh?"

Just look at this example 😃 :



Notice how the header ("UTAH"):
  • Expands when the user scrolls down but only to a specific height
  • Resizes when scrolling up or down
  • Gets smaller when the user scrolls up but only to a specific height

Does that sound like fun? Then let's get to it!

Assets

You're going to use the same assets you got in Part 3. If you didn't get them and want to get them now, then go to this GitHub page I set up with the assets.

Concepts

In order to achieve this effect, the header has to be ON TOP of the rest of the content.

(For some reason the Debug View Hierarchy isn't showing the images being clipped by the cornerRadius modifier.)

So this means you will need a ZStack INSIDE the ScrollView. It has to be inside because the header's size will be affected by the scrolling.

OK, time to start building.

Build the Sticky Header


1. Setup the Project
Use the same SwiftUI project from Part 3 when you built the parallax scrolling effect. If you didn't do that, then you will need to go back to Part 3 and create a SwiftUI file for the Tile view. You are going to use that in this project.

2. New SwiftUI View
Create a new SwiftUI file for this Sticky Header view. And add the following code:
struct StickyHeader: View { 
    var body: some View { 
        ScrollView { 
            ZStack { 
                // Bottom Layer 
                VStack(spacing: 20) { 
                    Tile(imageName: "Arches", tileLabel: "Arches") 
                    Tile(imageName: "Canyonlands", tileLabel: "Canyonlands") 
                    Tile(imageName: "BryceCanyon", tileLabel: "Bryce Canyon") 
                    Tile(imageName: "GoblinValley", tileLabel: "Goblin Valley") 
                    Tile(imageName: "Zion", tileLabel: "Zion") 
                } 
                .padding(.horizontal, 20) 
            } 
        }.edgesIgnoringSafeArea(.vertical) 
    } 

OK, this is a start and gives us our scrollable list of Utah national parks:




3. Add the Header
Now we want to add a second layer on top of this content for the header image:

struct StickyHeader: View {
    var body: some View {
        ScrollView {
            ZStack {
                // Bottom Layer
                VStack(spacing: 20) {
                    Tile(imageName: "Arches", tileLabel: "Arches")
                    Tile(imageName: "Canyonlands", tileLabel: "Canyonlands")
                    Tile(imageName: "BryceCanyon", tileLabel: "Bryce Canyon")
                    Tile(imageName: "GoblinValley", tileLabel: "Goblin Valley")
                    Tile(imageName: "Zion", tileLabel: "Zion")
                }
                .padding(.horizontal, 20)
               
                // Top Layer (Header)
                GeometryReader { gr in
                    VStack {
                        Image("Utah")
                            .resizable()
                            .aspectRatio(contentMode: .fill)
                            .frame(height: 300)
                            .overlay(
                                Text("UTAH")
                                    .font(.system(size: 70, weight: .black))
                                    .foregroundColor(.white)
                                    .opacity(0.8))
                        Spacer() // Push header to top
                    }
                }

            } 
        }.edgesIgnoringSafeArea(.vertical) 
    } 

I created a VStack just so I can add a Spacer to push the Image view up to the top.
The image's height is hard-coded to 300 right now, but soon you will be changing this.

Here is how it looks so far:


The header is completely covering our tiles. Since the header is hard-coded to 300, you can just push the bottom layer down by 300.

4. Push down the bottom later.
 
// Bottom Layer
VStack(spacing: 20) {
    Tile(imageName: "Arches", tileLabel: "Arches")
    Tile(imageName: "Canyonlands", tileLabel: "Canyonlands")
    Tile(imageName: "BryceCanyon", tileLabel: "Bryce Canyon")
    Tile(imageName: "GoblinValley", tileLabel: "Goblin Valley")
    Tile(imageName: "Zion", tileLabel: "Zion")
}
.padding(.horizontal, 20)
.padding(.top, 300) 

Pretty simple. I used the padding modifier but there are other ways to do this (like using offset for one). This is looking better:

The header will move up when scrolled. You don't want this!

What we want to do is constantly offset that header by the same amount it's being scrolled up.



If it scrolls up -100, then just push it back down by +100.

The opposite will also be true. If it is scrolled down +100, push it back up by -100.

5. Offset the Header
Here is the code to offset the header.
// Top Layer (Header)
GeometryReader { gr in
    VStack {
        Image("Utah")
            .resizable()
            .aspectRatio(contentMode: .fill)
            .frame(height: 300)
            .overlay(
                Text("UTAH")
                    .font(.system(size: 70, weight: .black))
                    .foregroundColor(.white)
                    .opacity(0.8))
            // Offset just on the Y axis
            .offset(y: gr.frame(in: .global).origin.y < 0 // Is it going up?
                ? abs(gr.frame(in: .global).origin.y) // Push it down!
                : -gr.frame(in: .global).origin.y) // Push it up!

        Spacer() // Push header to top
    }
}

Note: The abs function used here is the absolute number function that always returns a positive number.

Our header now sticks to the top!

Hooray!

Now there's nothing really special about this layout because you might be thinking to yourself, "Can't you do this with a VStack and just use a ScrollView as your second view?"

Yeah, you totally could have and probably should if that's all you needed.

But we're going to take this further and resize that header depending on the scrolling.

6. Create a Function to Adjust the Frame Height
Let's think about the logic here for a second. There are 3 variables you want to work with:
  1. The maximum height of the header (which you currently have set to 300).
  2. The minimum height of the header that it can shrink to.
  3. The Y origin offset of the geometry reader that changes as the scrolling takes place.

Using these three variables, you can calculate what the header height can shrink to or expand using this function:
 
func calculateHeight(minHeight: CGFloat, maxHeight: CGFloat, yOffset: CGFloat) -> CGFloat {
    // If scrolling up, yOffset will be a negative number
    if maxHeight + yOffset < minHeight {
        // SCROLLING UP
        // Never go smaller than our minimum height
        return minHeight
    }
    
    // SCROLLING DOWN
    return maxHeight + yOffset
}

One thing to note here: When scrolling down, you don't care how big the header gets.

It can go over your maxHeight because the scroll view snaps back to the original position when you let go and the height will reset back to the maxHeight that you set.

7. Apply the Frame Height to the Header
Now, call that function when you set the header frame's height by passing in the geometry reader's Y origin value for the yOffset:

// Top Layer (Header)
GeometryReader { gr in
    VStack {
        Image("Utah")
            .resizable()
            .aspectRatio(contentMode: .fill)
            .frame(height:
                self.calculateHeight(minHeight: 120,
                                     maxHeight: 300,
                                     yOffset: gr.frame(in: .global).origin.y))

            .overlay(
                Text("UTAH")
                    .font(.system(size: 70, weight: .black))
                    .foregroundColor(.white)
                    .opacity(0.8))
            // Offset just on the Y axis
            .offset(y: gr.frame(in: .global).origin.y < 0 // Is it going up?
                ? abs(gr.frame(in: .global).origin.y) // Push it down!
                : -gr.frame(in: .global).origin.y) // Push it up!
        
        Spacer() // Push header to top
    }
}

(In this example, you're using .frame(in: .global).origin.y but remember, you could also use .frame(in: .global).minY.)

Test this and you should see the header expand and shrink when scrolling:



Exciting!

Why does the image change size when the frame size changes?
The image's resizable modifier allows the image to change size and fit inside the frame. The fill aspect ratio modifier makes sure the image will adjust to fill in the entire frame.

That's why it's important to use an image that is wide enough to fill the space when the header shrinks.

(Interesting note: I'm not 100% certain that this is Utah. I think it's Monument Valley but it could also be Northern Arizona. 🤔)

BONUS EFFECTS!

You got the basics down of creating a sticky, bouncy and stretchy header. Here are some bonus tips to add some other cool effects!

Shadow
You could give the header (Image) a shadow when it reaches the minimum height. This will be a subtle effect that adds to the idea that the header is above the tiles.

// Show a shadow when minHeight is reached 
.shadow(radius: self.calculateHeight(minHeight: 120, 
                                     maxHeight: 300, 
                                     yOffset: gr.frame(in: .global).origin.y) < 140 ? 8 : 0) 

I used less than 140 instead of our minimum height of 120 so the shadow appears a little before the first tile starts to go under the header (the Image("Utah") view).

Oh, if you use this shadow, put the modifier BEFORE the overlay modifier or else it will add a shadow to your text too. 😉 I learned my lesson. 😄



Pulling Away Effect
I was inspired by Meng To on this one. The idea is that when you pull the tiles down past the header's max height, the tiles start to pull away further and further from the header while the header is still expanding but at a slower rate.

To achieve this effect, you will need to update the calculateHeight function:
 
func calculateHeight(minHeight: CGFloat, maxHeight: CGFloat, yOffset: CGFloat) -> CGFloat {
    // If scrolling up, yOffset will be a negative number
    if maxHeight + yOffset < minHeight {
        // SCROLLING UP
        // Never go smaller than our minimum height
        return minHeight
    }
    else if maxHeight + yOffset > maxHeight {
        // SCROLLING DOWN PAST MAX HEIGHT
        return maxHeight + (yOffset * 0.5) // Lessen the offset
    }

    
    // Return an offset that is between the min and max heights 
    return maxHeight + yOffset 

All you are really doing is lowering the Y offset value so it's no longer expanding at the same rate as the tiles when scrolling down:



Summary

Congratulations on completing this 5-Part series on "Fun with GeometryReader"! 🎉

I hope you learned a lot about the power of the GeometryReader in getting the size and position of spaces.

I would love to see your creations! If you followed this tutorial, share what you made on Twitter and tag me with @BigMtnStudio. 👍


Free SwiftUI Picture Book


Screenshots for every code example? Yes! Get your FREE SwiftUI picture book here.

15 comments

Chris Parker
Nov 18, 2019
Great stuff Mark.
q8yas q8
Nov 18, 2019
hi brother how are you ? where source code pls sorry for repeat my ask but my english is bad so i must see code to understand :* thank you
Chris Parker
Nov 19, 2019
That's correct. That gitHub repo is only for the image Assets. You download those assets and then drag them into your project and follow the instructions in the lesson above.
q8yas q8
Nov 26, 2019
where add ؟؟ // Show a shadow when minHeight is reached .shadow(radius: self.calculateHeight(minHeight: 120, maxHeight: 300, yOffset: gr.frame(in: .global).origin.y) < 140 ? 8 : 0) sorry but now 1 week to try understand ur explained
q8yas q8
Dec 1, 2019
ok thank you brother i hope more course like this and happy dey brother :*