Search code examples
swiftlistswiftuiscrollview

SwiftUI: header image that expands


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):

Expanding header image

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?


Solution

  • If I understand your requirements correctly:

    • Inside the NavigationStack, the parent container is either a ScrollView or a List. In other words, the parent container supports scrolling.
    • The background behind the container should extend to the top of the screen, that is, into the safe area.
    • When the scrollable container is pulled down, the background should appear pinned to both the top of the screen and the bottom of the content, so it should grow to fill the extra height.
    • When the scrollable container is scrolled up, the background should stay at its original size and move with the scrolled content.

    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)
            }
        }
    }
    

    Aniimation