2

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.

1 Answer 1

1

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

Animation

Sign up to request clarification or add additional context in comments.

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.