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:
.onScrollGeometryChangecan 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,
.onScrollPhaseChangecan be used. - There is a version of
.onScrollPhaseChangethat providesScrollPhaseChangeContextto 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.onScrollGeometryChangeis 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))
}
}
}
