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