I’m building a SwiftUI view with two synchronized scrollable areas:
- A horizontal ScrollView that displays a list of sections.
- 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]
}
- LazyVStack: Works well for performance, but synchronization breaks when sections contain varying numbers of items.
- 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.
- 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.
- 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 aScrollViewReader
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
}
}