I'm looking for a tidy way to identify the current visible top row from a ScrollView. I would like the NavigationTitle to display the current row number. Is there a way to do it with ScrollViewReader or must I do something with GeometryReader? In the code below I would like myRowNumber to update as the user scrolls up and down. Thanks in advance.
struct ContentView: View {
let myArray: [Int] = [Int](1...100)
@State private var myRowNumber: Int = 50
var body: some View {
NavigationView {
ScrollView {
LazyVStack{
ScrollViewReader { proxy in
ForEach(myArray, id: \.self) { index in
Text("Row \(index)").id(index).font(.title)
}
.onAppear {
proxy.scrollTo(50, anchor: .top)
}
}
}
}
.navigationTitle("Current row = \(myRowNumber)")
}
}
}
If your deployment target is iOS 17 (or macOS 14, etc.) or later, you can use the scrollPosition(id:)
modifier to track the top visible row of the scroll view.
So you want something like this:
SwiftUI doesn't offer a direct way to read the top row, so we have to compute it using other tools.
We need to know each row's position relative to the top of the scroll view. That means two things: getting an Anchor
for each row, and computing the y coordinate of that Anchor
relative to the top of the ScrollView
.
We can collect the Anchor
s using the anchorPreference
modifier, but first we need to create a PreferenceKey
type to manage the collection.
struct AnchorsKey: PreferenceKey {
// Each key is a row index. The corresponding value is the
// .center anchor of that row.
typealias Value = [Int: Anchor<CGPoint>]
static var defaultValue: Value { [:] }
static func reduce(value: inout Value, nextValue: () -> Value) {
value.merge(nextValue()) { $1 }
}
}
To turn an Anchor<CGPoint>
into an actual CGPoint
, we need a GeometryProxy
. Assuming we have a proxy, we want to pick the row with the smallest y coordinate from those rows with a y coordinate of at least zero.
private func topRow(of anchors: AnchorsKey.Value, in proxy: GeometryProxy) -> Int? {
var yBest = CGFloat.infinity
var answer: Int? = nil
for (row, anchor) in anchors {
let y = proxy[anchor].y
guard y >= 0, y < yBest else { continue }
answer = row
yBest = y
}
return answer
}
Now we need to wrap a GeometryReader
around the ScrollView
to get a GeometryProxy
, and use a .overlayPreferenceValue
modifier inside the GeometryReader
to get access to the collected Anchor
s.
struct ContentView: View {
let myArray: [Int] = [Int](1...100)
@State private var myRowNumber: Int = 50
var body: some View {
NavigationView {
GeometryReader { proxy in
ScrollView {
LazyVStack{
ScrollViewReader { proxy in
ForEach(myArray, id: \.self) { index in
Text("Row \(index)").id(index).font(.title)
.anchorPreference(
key: AnchorsKey.self,
value: .center
) { [index: $0] }
}
.onAppear {
proxy.scrollTo(50, anchor: .top)
}
}
}
}
.overlayPreferenceValue(AnchorsKey.self) { anchors in
let i = topRow(of: anchors, in: proxy) ?? -1
Color.clear
.navigationTitle("Current row = \(i)")
}
}
}
}
}