How can I have a ScrollView scroll its contents to align with another View's bottom edge?

2 weeks ago 11
ARTICLE AD BOX

I would suggest simply adding padding to the top of the VStack when the view is pulled down. This will allow the date and time to be seen behind the VStack. Some notes:

.onScrollGeometryChange can be used to detect when the view is pulled down. You might want to inspect the scroll phase too, so that the header is only shown when the user interacts with the scroll view from rest, not when scrolling long content back into view. To do this, .onScrollPhaseChange can be used. There is a version of .onScrollPhaseChange that provides ScrollPhaseChangeContext to the closure. This makes it possible to examine the scroll offset when the phase changes too. However, I found that it only reports the offset at the moment of phase change, not on a continuous basis. So .onScrollGeometryChange is probably needed too. If you want to mask the date and time when the view is not pulled down then the background probably needs to be applied to the VStack. If the content is short (as in your example), the background can be extended to the bottom of the screen simply by adding a large amount of negative bottom padding. Alternatively, the height for the background could be computed if the geometry of the screen is known, but negative padding is perhaps simpler (and fit for purpose).

Here is an elaborated example to show it all working:

struct ContentView: View { let headerHeight: CGFloat = 50 let scrollThreshold: CGFloat = 10 @State private var isInteractingFromRest = false @State private var isHeaderShowing = false private var dateAndTime: some View { // ... } private var storyContent: some View { // ... } var body: some View { NavigationStack { ZStack(alignment: .top) { dateAndTime ScrollView { VStack { storyContent } .frame(maxWidth: .infinity, alignment: .leading) .background { Color.blue .padding(.bottom, -2000) } .padding(.top, isHeaderShowing ? headerHeight : 0) } .onScrollPhaseChange { _, newPhase, context in if newPhase == .interacting { let geo = context.geometry let scrollOffset = geo.contentOffset.y + geo.contentInsets.top isInteractingFromRest = abs(scrollOffset) < 1 } else { isInteractingFromRest = false } } .onScrollGeometryChange(for: Bool.self) { geo in let result: Bool if isInteractingFromRest { let scrollOffset = geo.contentOffset.y + geo.contentInsets.top result = scrollOffset < (isHeaderShowing ? scrollThreshold : -scrollThreshold) } else { result = isHeaderShowing } return result } action: { _, newVal in if newVal != isHeaderShowing { withAnimation { isHeaderShowing = newVal } } } .toolbar { ToolbarItemGroup(placement: .topBarTrailing) { Button("Edit") {} Button("Delete", systemImage: "trash") {} } } } .background(.red.opacity(0.5)) } } }

Animation

Benzy Neez's user avatar

2 Comments

Thanks for the detailed example! Unfortunately, I tried implementing this exactly as you did and it did not work like your example did. I've added a short clip of my preview and how it responds as well as my updated code.

2026-02-25T20:55:41.13Z+00:00

The logic for switching the visibility might have been a bit flawed, I've made a correction to the answer. Re. not working for you, did you try copy/pasting the code in the answer? The only bits missing are the implementations of dateAndTime and storyContent, which can be kept simple. Otherwise, were you were planning to update the question with the changes you tried?

2026-02-25T21:43:15.397Z+00:00

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.

Read Entire Article