SwiftUI Sync Horizontal and Vertical ScrollViews with Dynamic Content

I’m building a SwiftUI view with two synchronized scrollable areas:

  1. A horizontal ScrollView that displays a list of sections.
  2. A vertical ScrollView that displays content corresponding to these sections.

Problem:

The implementation works when each section has a uniform number of items. However, when sections contain varying numbers of items, the synchronization breaks, and the vertical ScrollView often scrolls to the wrong section. Here’s an example of my code:

struct ContentView: View {
    // Sample data
    private let sections = (1...10).map { sectionIndex in
        SectionData(
            name: "Section (sectionIndex)",
            items: (1...(Int.random(in: 80...150))).map { "Item ($0)" }
        )
    }
    
    @State private var selectedSection: String? = nil
    @State private var currentVisibleSection: String? = nil
    
    var body: some View {
        VStack(spacing: 0) {
            // Horizontal Selector
            ScrollView(.horizontal, showsIndicators: false) {
                HStack(spacing: 10) {
                    ForEach(sections) { section in
                        Button(action: {
                            selectedSection = section.name
                        }) {
                            Text(section.name)
                                .font(.headline)
                                .padding(.horizontal, 10)
                                .padding(.vertical, 5)
                                .background(
                                    RoundedRectangle(cornerRadius: 10)
                                        .fill(currentVisibleSection == section.name ? Color.blue : Color.gray.opacity(0.2))
                                )
                                .foregroundColor(currentVisibleSection == section.name ? .white : .primary)
                        }
                    }
                }
                .padding()
            }
            .background(Color(UIColor.systemGroupedBackground))
            
            // Vertical Scrollable Content
            ScrollViewReader { proxy in
                ScrollView(.vertical, showsIndicators: false) {
                    LazyVStack(spacing: 20) {
                        ForEach(sections) { section in
                            VStack(alignment: .leading, spacing: 10) {
                                // Section Header
                                SectionHeader(name: section.name)
                                    .id(section.name) // Each section has a unique ID
                                
                                // Section Content
                                LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 3), spacing: 10) {
                                    ForEach(section.items, id: .self) { item in
                                        Text(item)
                                            .frame(height: 100)
                                            .frame(maxWidth: .infinity)
                                            .background(Color.blue.opacity(0.2))
                                            .cornerRadius(8)
                                    }
                                }
                            }
                            .background(
                                GeometryReader { geo in
                                    Color.clear.preference(
                                        key: VisibleSectionPreferenceKey.self,
                                        value: [section.name: calculateVisibleHeight(geo)]
                                    )
                                }
                            )
                        }
                    }
                    .onPreferenceChange(VisibleSectionPreferenceKey.self) { visibleSections in
                        updateLargestVisibleSection(visibleSections)
                    }
                    .onChange(of: selectedSection) { sectionName in
                        guard let sectionName else { return }
                        withAnimation {
                            proxy.scrollTo(sectionName, anchor: .top)
                        }
                    }
                }
            }
        }
    }
    
    // Update the largest visible section
    private func updateLargestVisibleSection(_ visibleSections: [String: CGFloat]) {
        if let largestVisibleSection = visibleSections.max(by: { $0.value < $1.value })?.key {
            currentVisibleSection = largestVisibleSection
        }
    }
    
    // Calculate the visible height of a section
    private func calculateVisibleHeight(_ geometry: GeometryProxy) -> CGFloat {
        let frame = geometry.frame(in: .global)
        let screenHeight = UIScreen.main.bounds.height
        return max(0, min(frame.maxY, screenHeight) - max(frame.minY, 0))
    }
}

// PreferenceKey to track visible sections
private struct VisibleSectionPreferenceKey: PreferenceKey {
    static var defaultValue: [String: CGFloat] = [:]
    
    static func reduce(value: inout [String: CGFloat], nextValue: () -> [String: CGFloat]) {
        value.merge(nextValue(), uniquingKeysWith: max)
    }
}

// Supporting Views and Models
struct SectionHeader: View {
    let name: String
    
    var body: some View {
        Text(name)
            .font(.headline)
            .padding()
            .frame(maxWidth: .infinity, alignment: .leading)
            .background(Color.gray.opacity(0.2))
    }
}

struct SectionData: Identifiable {
    var id: String { name }
    let name: String
    let items: [String]
}

  1. LazyVStack: Works well for performance, but synchronization breaks when sections contain varying numbers of items.
  2. VStack: Fixes synchronization issues but introduces poor performance with large data sets since all content is eagerly loaded into memory.
  • Additionally, interacting with lazy subviews (like LazyVGrid) within a VStack causes scroll jumps, breaking the user experience.
  1. onPreferenceChange: Used a custom PreferenceKey to track visible sections, but this approach becomes unreliable with lazy-loaded sections and dynamic item counts.

In your example you have a ScrollView that contains a LazyVStack. Inside the LazyVStack you have a number of nested LazyVGrid.

Lazy containers work in coordination with a ScrollView, but it seems that this coordination does not work well when there are nested lazy containers. I noticed that after scrolling to a target section, the ScrollView would suddenly take a jump. I suspect this is happening as some of the lazy containers are discarding their contents, without coordinating properly with the ScrollView.

I would suggest there are two ways to solve, depending on whether the number of items in the sections is finite or not:

  • If the number of items in a section is finite (as in your example), the data can be flattened to give one large collection representing all rows.
  • If the number of items in a section is not finite, a tab view might be a better approach.

Here is how the flat-data approach could work for your example.

  1. Prepare the data model

I would suggest using a numeric id for your section data. This is more convenient as a base that can be used for building the ids of the data rows. It is also useful to have a computed property for the number of items and a function for accessing a particular item.

struct SectionData: Identifiable {
    var id: Int
    let name: String
    let items: [String]

    var nItems: Int {
        items.count
    }

    func item(at: Int) -> String {
        items[at]
    }
}
  1. Create a data type to encapsulate the data for one row. There are basically just two types of row: header rows and item rows.
struct RowData: Identifiable {
    let section: SectionData
    let nItems: Int
    let firstItemIndex: Int

    static private let maxItemsPerSection = 1000

    // Initializer for a header row
    init(section: SectionData) {
        self.section = section
        self.nItems = 0
        self.firstItemIndex = -1
    }

    // Initializer for a row of items
    init(section: SectionData, nItems: Int, firstItemIndex: Int) {
        self.section = section
        self.nItems = nItems
        self.firstItemIndex = firstItemIndex
    }

    var sectionId: Int {
        section.id
    }

    var id: Int {

        // Header rows inherit the id of the section
        nItems == 0 ? sectionId : (sectionId * Self.maxItemsPerSection) + firstItemIndex
    }

    static func rowId2SectionId(rowId: Int) -> Int {
        rowId < maxItemsPerSection ? rowId : rowId / maxItemsPerSection
    }
}

Each row instance has a handle on the section that provides the data, but the actual data is not extracted from the section at this stage.

Even though SectionData is a struct, my understanding is that there is a good chance that the Swift compiler will optimize the way that data is passed around and may choose to pass by reference. However, if you wanted to be certain, you could change SectionData to be a class.

  1. Create the flat collection in init
private let nItemsPerRow = 3
private let flatData: [RowData]
init() {
    var flatData = [RowData]()
    for section in sections {

        // Add a row entry for the section header
        flatData.append(RowData(section: section))

        // Add rows for showing the items
        let nRows = Int((Double(section.nItems) / Double(nItemsPerRow)).rounded(.up))
        for rowNum in 0..<nRows {
            let firstItemIndex = rowNum * nItemsPerRow
            let nItems = min(nItemsPerRow, section.nItems - firstItemIndex)
            flatData.append(RowData(section: section, nItems: nItems, firstItemIndex: firstItemIndex))
        }
    }
    self.flatData = flatData
}
  1. I would suggest using .scrollTargetLayout and .scrollPosition, instead of a ScrollViewReader

The binding supplied to .scrollPosition will be updated automatically as scrolling happens. By adding a setter observer to the underlying state variable, a previous selection can be saved to a separate state variable too.

@State private var lastSelectedRow: Int = 0
@State private var selectedRow: Int? {
    willSet {
        if let selectedRow, lastSelectedRow != selectedRow {
            lastSelectedRow = selectedRow
        }
    }
}
ScrollView(.vertical, showsIndicators: false) {
    LazyVStack(spacing: 10) {
        //...
    }
    .scrollTargetLayout()
}
.scrollPosition(id: $selectedRow, anchor: .top)
.scrollTargetBehavior(.viewAligned)
.onAppear {
    selectedRow = sections.first?.id
}
  1. Add a computed property for the id of the currently-visible section

The id for the section that is currently in view can be derived from the id of the currently-selected row. This value is used for highlighting the corresponding selection button.

private var currentSectionId: Int {
    RowData.rowId2SectionId(rowId: selectedRow ?? lastSelectedRow)
}
  1. Display the rows

There is no longer any need to use a GeometryReader, nor a PreferenceKey.

ForEach(flatData) { rowData in
    if rowData.nItems == 0 {

        // Section Header
        SectionHeader(name: rowData.section.name)
    } else {

        // Row Content
        HStack {
            ForEach(0..<rowData.nItems, id: .self) { i in
                Text(rowData.section.item(at: rowData.firstItemIndex + i))
                    .frame(height: 100)
                    .frame(maxWidth: .infinity)
                    .background(Color.blue.opacity(0.2))
                    .cornerRadius(8)
            }
            let nEmptyPositions = nItemsPerRow - rowData.nItems
            ForEach(0..<nEmptyPositions, id: .self) { _ in
                Color.clear.frame(height: 1)
            }
        }
    }
}

You will notice that an HStack is being used for each row of data.

  • Since the cells all use maxWidth: .infinity, the columns are sure to align.

  • Alternatively, you could consider using a custom Layout that uses percentages or weights for the column widths. An example of such a layout can be found in this answer.

  • If instead the widths of the columns need to be dynamic, you could consider using a technique like the one shown in this answer for determining the dynamic widths.

  1. Update the buttons to set the scroll target

Since the header rows have a different height to the data rows, scrolling to a different section will not always arrive at exactly the right position. It helps to re-apply the selection just before the animation completes. The button action can include a Task to perform this.

Button {
    withAnimation(.easeInOut(duration: 0.3)) {
        selectedRow = section.id
    }
    Task { @MainActor in

        // Re-apply the selection when the animation is nearing completion
        try? await Task.sleep(for: .seconds(0.27))
        selectedRow = nil
        try? await Task.sleep(for: .milliseconds(20))
        selectedRow = section.id
    }
} label: {
    let isSelected = currentSectionId == section.id
    Text(section.name)
        .font(.headline)
        .padding(.horizontal, 10)
        .padding(.vertical, 5)
        .background(
            RoundedRectangle(cornerRadius: 10)
                .fill(isSelected ? .blue : .gray.opacity(0.2))
        )
        .foregroundStyle(isSelected ? .white : .primary)
}

Putting it all together, here is the fully updated example:

struct ContentView: View {
    
    // Sample data
    private let sections = (1...10).map { sectionIndex in
        SectionData(
            id: sectionIndex,
            name: "Section (sectionIndex)",
            items: (1...(Int.random(in: 80...150))).map { "Item ($0)" }
        )
    }
    private let nItemsPerRow = 3
    private let flatData: [RowData]
    @State private var lastSelectedRow: Int = 0
    @State private var selectedRow: Int? {
        willSet {
            if let selectedRow, lastSelectedRow != selectedRow {
                lastSelectedRow = selectedRow
            }
        }
    }
    
    private var currentSectionId: Int {
        RowData.rowId2SectionId(rowId: selectedRow ?? lastSelectedRow)
    }
    
    init() {
        var flatData = [RowData]()
        for section in sections {
            
            // Add a row entry for the section header
            flatData.append(RowData(section: section))
            
            // Add rows for showing the items
            let nRows = Int((Double(section.nItems) / Double(nItemsPerRow)).rounded(.up))
            for rowNum in 0..<nRows {
                let firstItemIndex = rowNum * nItemsPerRow
                let nItems = min(nItemsPerRow, section.nItems - firstItemIndex)
                flatData.append(RowData(section: section, nItems: nItems, firstItemIndex: firstItemIndex))
            }
        }
        self.flatData = flatData
    }
    
    var body: some View {
        VStack(spacing: 0) {
            
            // Horizontal Selector
            sectionSelector
            
            // Vertical Scrollable Content
            ScrollView(.vertical, showsIndicators: false) {
                LazyVStack(spacing: 10) {
                    ForEach(flatData) { rowData in
                        if rowData.nItems == 0 {
                            
                            // Section Header
                            SectionHeader(name: rowData.section.name)
                        } else {
                            
                            // Row Content
                            HStack {
                                ForEach(0..<rowData.nItems, id: .self) { i in
                                    Text(rowData.section.item(at: rowData.firstItemIndex + i))
                                        .frame(height: 100)
                                        .frame(maxWidth: .infinity)
                                        .background(Color.blue.opacity(0.2))
                                        .cornerRadius(8)
                                }
                                let nEmptyPositions = nItemsPerRow - rowData.nItems
                                ForEach(0..<nEmptyPositions, id: .self) { _ in
                                    Color.clear.frame(height: 1)
                                }
                            }
                        }
                    }
                }
                .scrollTargetLayout()
            }
            .scrollPosition(id: $selectedRow, anchor: .top)
            .scrollTargetBehavior(.viewAligned)
            .onAppear {
                selectedRow = sections.first?.id
            }
        }
    }
    
    private var sectionSelector: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            HStack(spacing: 10) {
                ForEach(sections) { section in
                    Button {
                        withAnimation(.easeInOut(duration: 0.3)) {
                            selectedRow = section.id
                        }
                        Task { @MainActor in
                            
                            // Re-apply the selection when the animation is nearing completion
                            try? await Task.sleep(for: .seconds(0.27))
                            selectedRow = nil
                            try? await Task.sleep(for: .milliseconds(20))
                            selectedRow = section.id
                        }
                    } label: {
                        let isSelected = currentSectionId == section.id
                        Text(section.name)
                            .font(.headline)
                            .padding(.horizontal, 10)
                            .padding(.vertical, 5)
                            .background(
                                RoundedRectangle(cornerRadius: 10)
                                    .fill(isSelected ? .blue : .gray.opacity(0.2))
                            )
                            .foregroundStyle(isSelected ? .white : .primary)
                    }
                }
            }
            .padding()
            .animation(.easeInOut, value: currentSectionId)
        }
        .background(Color(.systemGroupedBackground))
    }
}

// Supporting Views and Models
struct SectionHeader: View {
    let name: String
    
    var body: some View {
        Text(name)
            .font(.headline)
            .padding()
            .frame(maxWidth: .infinity, alignment: .leading)
            .background(Color.gray.opacity(0.2))
    }
}

struct SectionData: Identifiable {
    var id: Int
    let name: String
    let items: [String]
    
    var nItems: Int {
        items.count
    }
    
    func item(at: Int) -> String {
        items[at]
    }
}

struct RowData: Identifiable {
    let section: SectionData
    let nItems: Int
    let firstItemIndex: Int
    
    static private let maxItemsPerSection = 1000
    
    // Initializer for a header row
    init(section: SectionData) {
        self.section = section
        self.nItems = 0
        self.firstItemIndex = -1
    }
    
    // Initializer for a row of items
    init(section: SectionData, nItems: Int, firstItemIndex: Int) {
        self.section = section
        self.nItems = nItems
        self.firstItemIndex = firstItemIndex
    }
    
    var sectionId: Int {
        section.id
    }
    
    var id: Int {
        
        // Header rows inherit the id of the section
        nItems == 0 ? sectionId : (sectionId * Self.maxItemsPerSection) + firstItemIndex
    }
    
    static func rowId2SectionId(rowId: Int) -> Int {
        rowId < maxItemsPerSection ? rowId : rowId / maxItemsPerSection
    }
}

Trang chủ Giới thiệu Sinh nhật bé trai Sinh nhật bé gái Tổ chức sự kiện Biểu diễn giải trí Dịch vụ khác Trang trí tiệc cưới Tổ chức khai trương Tư vấn dịch vụ Thư viện ảnh Tin tức - sự kiện Liên hệ Chú hề sinh nhật Trang trí YEAR END PARTY công ty Trang trí tất niên cuối năm Trang trí tất niên xu hướng mới nhất Trang trí sinh nhật bé trai Hải Đăng Trang trí sinh nhật bé Khánh Vân Trang trí sinh nhật Bích Ngân Trang trí sinh nhật bé Thanh Trang Thuê ông già Noel phát quà Biểu diễn xiếc khỉ Xiếc quay đĩa Dịch vụ tổ chức sự kiện 5 sao Thông tin về chúng tôi Dịch vụ sinh nhật bé trai Dịch vụ sinh nhật bé gái Sự kiện trọn gói Các tiết mục giải trí Dịch vụ bổ trợ Tiệc cưới sang trọng Dịch vụ khai trương Tư vấn tổ chức sự kiện Hình ảnh sự kiện Cập nhật tin tức Liên hệ ngay Thuê chú hề chuyên nghiệp Tiệc tất niên cho công ty Trang trí tiệc cuối năm Tiệc tất niên độc đáo Sinh nhật bé Hải Đăng Sinh nhật đáng yêu bé Khánh Vân Sinh nhật sang trọng Bích Ngân Tiệc sinh nhật bé Thanh Trang Dịch vụ ông già Noel Xiếc thú vui nhộn Biểu diễn xiếc quay đĩa Dịch vụ tổ chức tiệc uy tín Khám phá dịch vụ của chúng tôi Tiệc sinh nhật cho bé trai Trang trí tiệc cho bé gái Gói sự kiện chuyên nghiệp Chương trình giải trí hấp dẫn Dịch vụ hỗ trợ sự kiện Trang trí tiệc cưới đẹp Khởi đầu thành công với khai trương Chuyên gia tư vấn sự kiện Xem ảnh các sự kiện đẹp Tin mới về sự kiện Kết nối với đội ngũ chuyên gia Chú hề vui nhộn cho tiệc sinh nhật Ý tưởng tiệc cuối năm Tất niên độc đáo Trang trí tiệc hiện đại Tổ chức sinh nhật cho Hải Đăng Sinh nhật độc quyền Khánh Vân Phong cách tiệc Bích Ngân Trang trí tiệc bé Thanh Trang Thuê dịch vụ ông già Noel chuyên nghiệp Xem xiếc khỉ đặc sắc Xiếc quay đĩa thú vị
Trang chủ Giới thiệu Sinh nhật bé trai Sinh nhật bé gái Tổ chức sự kiện Biểu diễn giải trí Dịch vụ khác Trang trí tiệc cưới Tổ chức khai trương Tư vấn dịch vụ Thư viện ảnh Tin tức - sự kiện Liên hệ Chú hề sinh nhật Trang trí YEAR END PARTY công ty Trang trí tất niên cuối năm Trang trí tất niên xu hướng mới nhất Trang trí sinh nhật bé trai Hải Đăng Trang trí sinh nhật bé Khánh Vân Trang trí sinh nhật Bích Ngân Trang trí sinh nhật bé Thanh Trang Thuê ông già Noel phát quà Biểu diễn xiếc khỉ Xiếc quay đĩa
Thiết kế website Thiết kế website Thiết kế website Cách kháng tài khoản quảng cáo Mua bán Fanpage Facebook Dịch vụ SEO Tổ chức sinh nhật