I have a Scrollview
inside a NavigationStack
and want a sticky image on top. When scrolling down, the image should expand, it should also bleed into the navigation bar.
I've got it working with GeometryReader
, but it seems to be causing many re-renders and it's very noticeable that it doesn't run great.
struct TestView: View {
let coordinateSpaceName = "navstack"
@State var size: CGRect = .zero
var body: some View {
NavigationStack {
ScrollView {
Text("y: \(size.minY), height: \(size.height)")
.frame(height: 200)
.frame(maxWidth: .infinity)
.background(
Image(systemName: "star")
.resizable()
.scaledToFit()
.opacity(0.2)
.frame(height: max(0, size.minY + size.height))
// ^ set background size to parent height + distance to top
.offset(x: 0, y: -(size.minY / 2))
// ^ move up
)
.overlay( // read own size and distance to top
GeometryReader { proxy in
let offset = proxy.frame(in: .named(coordinateSpaceName)).minY
Color.clear
.preference(key: StickyHeaderPreferenceKey.self, value: CGRect(
x: 0,
y: offset,
width: 0,
height: proxy.size.height
))
})
.onPreferenceChange(StickyHeaderPreferenceKey.self) { value in
size = value // save new values
}
Color.gray.frame(height: 1000)
.opacity(0.3)
}
.navigationTitle("Navigation Title")
.navigationBarTitleDisplayMode(.inline)
}
.coordinateSpace(name: coordinateSpaceName)
}
}
struct StickyHeaderPreferenceKey: PreferenceKey {
static var defaultValue: CGRect = CGRect()
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
It looks/should look like this (apart from the "getting smaller when swiping up" part):
I noticed that the .background
of the first item in the NavigationStack
is going under the navigation bar just as I want it, I couldn't get it to work with a ScrollView
or List
though:
NavigationStack {
VStack (spacing: 0) {
Text("The background of this should bleed into the navigation bar, while being scrollable")
.frame(maxWidth: .infinity)
.frame(height: 200)
.background(
Image(systemName: "star.fill")
.resizable()
.scaledToFill()
.opacity(0.2)
)
ScrollView {
Color.gray.frame(height: 1000)
}
}
.navigationTitle("Navigation Title")
.navigationBarTitleDisplayMode(.inline)
}
Is there a simpler solution for this? Or any obvious issues I'm having here?
If I understand your requirements correctly:
NavigationStack
, the parent container is either a ScrollView
or a List
. In other words, the parent container supports scrolling.You actually have this working with the first block of code you provided, except that the size of the background also shrinks when scrolling up. However, there is no VStack
inside the ScrollView
to define the layout of the content.
In the last block of code that you provided (where you say it is not working), you have added a VStack
as the top-level container, but of course it is not scrollable. If I understand correctly, the Text
is a placeholder for the scrollable container you want to have. However, it is confusing to see the Text
inside the VStack
and followed by a ScrollView
. I assume this is not the actual arrangement you want to have.
Down to business
I think that all you need to do is put the VStack
inside the ScrollView
and attach the background to this. This way, the background scrolls with the content.
To stop it shrinking, you need to know the minimum height to constrain it to. This can be found by putting a GeometryReader
around the background.
I found it was also necessary to hide the background behind the navigation bar, otherwise it adds a Material
effect as soon as the content scrolls up behind it.
Is there a simpler solution for this?
I would also use a GeometryReader
in the background to measure the position of the scrolled content, but it can be done without using a PreferenceKey
. The global coordinate space can be used too, there is no need to name the coordinate space of the ScrollView
.
You are using a CGRect
as the state variable. It would probably be sufficient to use a CGFloat
that just stores maxY
. But I left it as CGRect
, in case I've misunderstood the requirements and you want to read other values from the frame info.
This works in the way described in the points above:
struct NewTestView: View {
@State private var size: CGRect = .zero
private var positionDetector: some View {
GeometryReader { proxy in
let frame = proxy.frame(in: .global)
Color.clear
// pre iOS 17: .onChange(of: frame) { newVal in
.onChange(of: frame) { oldVal, newVal in
size = newVal
}
}
}
private var star: some View {
GeometryReader { proxy in
let h = proxy.size.height
let minY = proxy.frame(in: .global).minY
Image(systemName: "star")
.resizable()
.scaledToFit()
.opacity(0.2)
.frame(height: max(h, size.maxY))
.frame(maxWidth: .infinity)
.background(Color.yellow)
.offset(y: min(0, -minY))
}
}
var body: some View {
NavigationStack {
ScrollView {
VStack {
Text("y: \(size.minY), height: \(size.height)")
.frame(height: 200)
.frame(minWidth: 300)
.border(.gray)
.frame(maxWidth: .infinity)
}
.background { positionDetector }
.background { star }
}
.navigationTitle("Navigation Title")
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
}
}
}