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 3when 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:
// 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.
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:
The maximum height of the header (which you currently have set to 300).
The minimum height of the header that it can shrink to.
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. 👍
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
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
Chris Parker
q8yas q8
Chris Parker
q8yas q8
q8yas q8