Search code examples
swiftuiscrollviewscrollviewreader

ScrollViewReader, how to read the current row?


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

Solution

  • UPDATE

    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.

    ORIGINAL

    So you want something like this:

    A scroll view showing number rows. Row 50 is at the top. The title above the scroll view says "Current row = 50". I scroll the view and the title changes in real time to always say the row number of the top visible row.

    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 Anchors 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 Anchors.

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