I'm trying to create sticky header with manual scroll to functionality (eg. categories bar allowing user to move to chosen section). I have issues with one part of that functionallity. While ScrollViewReader and proxy.scrollTo works as intended; it keeps scrolling under sticky header instead of below it. Image one shows how it works, image two shows what is expected. As you can see swiftUI basically ignores existence of header, treating it as separate Z layer.

I would love solution to work on iOS 15.

Code to reproduce example:

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            ScrollViewReader(content: { proxy in
                ScrollView {
                    LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
                        Image(systemName: "globe")
                            .imageScale(.large)
                            .foregroundStyle(.tint)
                            .onTapGesture {
                                proxy.scrollTo(sections[2].hashValue, anchor: .top)
                            }
                                                
                        Section {
                            NestedSectionsView()
                                .frame(maxWidth: .infinity)
                            
                        } header: {
                            Header().opacity(0.5)
                        }
                    }
                }
            })
        }
        .padding()
    }
}

#Preview {
    ContentView()
}

let sections = [
    SectionData(header: "First Section", items: Array(repeating: "Item 1", count: 5)),
    SectionData(header: "Second Section", items: Array(repeating: "Item 2", count: 5)),
    SectionData(header: "Third Section", items: Array(repeating: "Item 3", count: 10))
]


struct NestedSectionsView: View {

    
    var body: some View {
        LazyVStack(spacing: 10) {
            ForEach(sections, id: \.hashValue) { section in
                SectionView(section: section)
            }
        }
        .padding()
        .navigationBarTitle("Nested Sections")
    }
}

struct SectionData: Identifiable, Hashable {
    let id = UUID()
    let header: String
    let items: [String]
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}

struct SectionView: View {
    let section: SectionData
    
    var body: some View {
        VStack(alignment: .leading, spacing: 3) {
            Text(section.header)
                .font(.headline)
                .frame(height: 50)
                .frame(maxWidth: .infinity)
                .background(Color.green)
                .id(section.hashValue)
            
            ForEach(section.items, id: \.self) { item in
                Text(item)
                    .frame(height: 140)
                    .frame(maxWidth: .infinity)
                    .background(Color.red)
            }
        }
    }
}

struct Header: View {
    var body: some View {
        VStack {
            Image(systemName: "photo")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: UIScreen.main.bounds.width, height: 100)
                .padding()
            
            Text("Example Text")
                .font(.title)
                .foregroundColor(.black)
                .frame(width: UIScreen.main.bounds.width, height: 100, alignment: .center)
        }.background(Color.yellow)
    }
}

How does it work now (when tapping scroll to id of section 3) How does it work now

How does I would like it to work (scrolls so section title is visible, and not covered by sticky header) How I would like it to work

1

There are 1 best solutions below

1
On

The documentation for .scrollTo explains how the anchor works:

If anchor is nil, this method finds the container of the identified view, and scrolls the minimum amount to make the identified view wholly visible.

If anchor is non-nil, it defines the points in the identified view and the scroll view to align. For example, setting anchor to top aligns the top of the identified view to the top of the scroll view. Similarly, setting anchor to bottom aligns the bottom of the identified view to the bottom of the scroll view, and so on.

So when the anchor is .top as you have it, the top of the target view will be aligned with the top of the ScrollView. This is exactly what you are seeing -> working as designed.

What you want is for the target section to be below the top of the ScrollView by the height of the header. One way to achieve this is to change the scrollTo target to something that is at the end of the preceding section.

To see it working, try the following changes to your example:

1. Add an additional id to SectionData, to use as the scrollTo target

struct SectionData: Identifiable, Hashable {

    let bottomId = UUID()

    // all other content as before
}

2. Add a placeholder to the bottom of the background of each SectionView

While we're at it, we can fix the ForEach to prevent errors in the console about duplicate ids.

struct SectionView: View {
    let section: SectionData

    var body: some View {
        VStack(alignment: .leading, spacing: 3) {
            Text(section.header)
                // modifiers as before

            // ForEach(section.items, id: \.self) { item in
            ForEach(Array(section.items.enumerated()), id: \.offset) { offset, item in
                // content as before
            }
        }
        .background(alignment: .bottom) {
            Color.clear
                .frame(height: 240)
                .id(section.bottomId)
        }
    }
}

If you don't know the height that needs to be reserved then you could use a hidden copy of Header instead:

.background(alignment: .bottom) {
    Header()
        .hidden()
        .id(section.bottomId)
}

3. Change the scrollTo target to refer to the placeholder at the bottom of the preceding section

You might like to add some animation too:

.onTapGesture {
    // proxy.scrollTo(sections[2].hashValue, anchor: .top)
    withAnimation {
        proxy.scrollTo(sections[1].bottomId, anchor: .top)
    }
}

4. Change NestedSectionsView to use VStack instead of LazyVStack

If a LazyVStack is used then the placeholders that were added to the background in step 2 do not exist when a SectionView is out-of-view, so they can't be used for scrolling to. Changing to a VStack resolves this:

struct NestedSectionsView: View {

    var body: some View {
        // LazyVStack(spacing: 10) {
        VStack(spacing: 10) {

            // all other content as before

It now scrolls to the desired position:

Animation