Kai Zheng
Kai Zheng

Reputation: 8130

Get the current scroll position of a SwiftUI ScrollView

With the new ScrollViewReader, it seems possible to set the scroll offset programmatically.

But I was wondering if it is also possible to get the current scroll position?

It seems like the ScrollViewProxy only comes with the scrollTo method, allowing us to set the offset.

Thanks!

Upvotes: 61

Views: 47308

Answers (8)

Mojtaba Hosseini
Mojtaba Hosseini

Reputation: 119174

āœ… iOS 17

You can use .scrollPosition modifier on ScrollView with the .scrollTargetLayout on its content:

Demo

Demo

Demo Code
struct ContentView: View {
    @State var items: [String] = (1...100).map(String.init)
    @State var scrolledID: String? = "1"

    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(items, id: \.self, content: Text.init)
            }
            .scrollTargetLayout() // šŸ‘ˆ Apply this on the `content` of scroll view
        }
        .scrollPosition(id: $scrolledID) // šŸ‘ˆ Apply this on the `ScrollView` itself


        .overlay(alignment: .bottom) {
            Text("Scrolled ID: \(scrolledID!)").background()
        }
    }
}

Upvotes: 1

Henrik
Henrik

Reputation: 4034

I tried to get this working without GeometryReader as that has terrible performance. This workaround does not give the scroll position, but solves the use case where you want to scroll to the top if not at the top.

I use this to trigger a scroll to the top of not at the top and navigate back if at the top.

struct ControllableScrollView<Content>: View where Content: View {
    @Binding var shouldReset: Bool
    @Binding var hasScrolled: Bool?

    let content: () -> Content

    var body: some View {
        ScrollViewReader { scrollProxy in
            ScrollView {
                VStack(spacing: 0) {
                    Color.clear
                        .id(false)
                    content()
                        .id(true)
                }
                .scrollTargetLayout()
            }
            .onChange(of: shouldReset) {
                withAnimation { scrollProxy.scrollTo(false, anchor: .top) }
            }
            .scrollPosition(id: $hasScrolled)
        }
    }
}

Upvotes: 0

Asperi
Asperi

Reputation: 257563

It was possible to read it and before. Here is a solution based on view preferences.

struct DemoScrollViewOffsetView: View {
    @State private var offset = CGFloat.zero
    var body: some View {
        ScrollView {
            VStack {
                ForEach(0..<100) { i in
                    Text("Item \(i)").padding()
                }
            }.background(GeometryReader {
                Color.clear.preference(key: ViewOffsetKey.self,
                    value: -$0.frame(in: .named("scroll")).origin.y)
            })
            .onPreferenceChange(ViewOffsetKey.self) { print("offset >> \($0)") }
        }.coordinateSpace(name: "scroll")
    }
}

struct ViewOffsetKey: PreferenceKey {
    typealias Value = CGFloat
    static var defaultValue = CGFloat.zero
    static func reduce(value: inout Value, nextValue: () -> Value) {
        value += nextValue()
    }
}

backup

Upvotes: 92

Giuseppe Mazzilli
Giuseppe Mazzilli

Reputation: 490

Looking previous examples u can reach same result without using of PreferenceKeys.

I've updated code here to make it more flexible.

Updated example on 2023.10.26

import SwiftUI

struct PositionReadableScrollView<Content>: View where Content: View {
    let axes: Axis.Set = .vertical
    let content: () -> Content
    let onScroll: (CGFloat) -> Void
    
    var body: some View {
        ScrollView(axes) {
            content()
                .background(
                    GeometryReader { proxy in
                        let position = (
                            axes == .vertical ?
                            proxy.frame(in: .named("scrollID")).origin.y :
                            proxy.frame(in: .named("scrollID")).origin.x
                        )
                        
                        Color.clear
                            .onChange(of: position) { position, _ in
                                onScroll(position)
                            }
                    }
                )
        }
        .coordinateSpace(.named("scrollID"))
    }
}

How to use it

import SwiftUI

struct ContentView: View {
    @State private var scrollID: CoordinateSpace = .local
    
    var body: some View {
        PositionReadableScrollView {
            ForEach(0..<100) { item in
                Text("\(item)")
            }
        } onScroll: { position in
            /// Do your checks here.
            debugPrint(position)
        }
    }
}

struct ContentView_PreviewProviders: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Upvotes: 4

RopeySim
RopeySim

Reputation: 511

I'm a bit late to the party, but I had this problem today, so I thought I'd answer. This gist includes code that will do what you need.

https://gist.github.com/rsalesas/313e6aefc098f2b3357ae485da507fc4

        ScrollView {
            ScrollViewReader { proxy in
                content()
            }
            .onScrolled { point in
                print("Point: \(point)")
            }
        }
        .trackScrolling()

It provides extensions to get called when a ScrollView is scrolled. First use .trackScrolling on the ScrollView, then put a ScrollViewReader inside. On the ScrollViewReader use the .onScrolled extension to receive the events (one argument, a UnitPoint).

You do need to turn on scrolling tracking, I couldn't find another way to do it. Why this isn't supported...

Upvotes: 2

Adrien
Adrien

Reputation: 1917

The most popular answer (@Asperi's) has a limitation: The scroll offset can be used in a function .onPreferenceChange(ViewOffsetKey.self) { print("offset >> \($0)") } which is convenient for triggering an event based on that offset. But what if the content of the ScrollView depends on this offset (for example if it has to display it). So we need this function to update a @State. The problem then is that each time this offset changes, the @State is updated and the body is re-evaluated. This causes a slow display.

We could instead wrap the content of the ScrollView directly in the GeometryReader so that this content can depend on its position directly (without using a State or even a PreferenceKey).

GeometryReader { geometry in
   content(geometry.frame(in: .named(spaceName)).origin)
}

where content is (CGPoint) -> some View

We could take advantage of this to observe when the offset stops being updated, and reproduce the didEndDragging behavior of UIScrollView

GeometryReader { geometry in
   content(geometry.frame(in: .named(spaceName)).origin)
      .onChange(of: geometry.frame(in: .named(spaceName)).origin, 
                perform: offsetObserver.send)
      .onReceive(offsetObserver.debounce(for: 0.2, 
                 scheduler: DispatchQueue.main), 
                 perform: didEndScrolling)
}

where offsetObserver = PassthroughSubject<CGPoint, Never>()

In the end, this gives :

struct _ScrollViewWithOffset<Content: View>: View {
    
    private let axis: Axis.Set
    private let content: (CGPoint) -> Content
    private let didEndScrolling: (CGPoint) -> Void
    private let offsetObserver = PassthroughSubject<CGPoint, Never>()
    private let spaceName = "scrollView"
    
    init(axis: Axis.Set = .vertical,
         content: @escaping (CGPoint) -> Content,
         didEndScrolling: @escaping (CGPoint) -> Void = { _ in }) {
        self.axis = axis
        self.content = content
        self.didEndScrolling = didEndScrolling
    }
    
    var body: some View {
        ScrollView(axis) {
            GeometryReader { geometry in
                content(geometry.frame(in: .named(spaceName)).origin)
                    .onChange(of: geometry.frame(in: .named(spaceName)).origin, perform: offsetObserver.send)
                    .onReceive(offsetObserver.debounce(for: 0.2, scheduler: DispatchQueue.main), perform: didEndScrolling)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
            }
        }
        .coordinateSpace(name: spaceName)
    }
}

Note: the only problem I see is that the GeometryReader takes all the available width and height. This is not always desirable (especially for a horizontal ScrollView). One must then determine the size of the content to reflect it on the ScrollView.

struct ScrollViewWithOffset<Content: View>: View {
    @State private var height: CGFloat?
    @State private var width: CGFloat?
    let axis: Axis.Set
    let content: (CGPoint) -> Content
    let didEndScrolling: (CGPoint) -> Void
    
    var body: some View {
        _ScrollViewWithOffset(axis: axis) { offset in
            content(offset)
                .fixedSize()
                .overlay(GeometryReader { geo in
                    Color.clear
                        .onAppear {
                            height = geo.size.height
                            width = geo.size.width
                        }
                })
        } didEndScrolling: {
            didEndScrolling($0)
        }
        .frame(width: axis == .vertical ? width : nil,
               height: axis == .horizontal ? height : nil)
    }
}

This will work in most cases (unless the content size changes, which I don't think is desirable). And finally you can use it like that :

struct ScrollViewWithOffsetForPreviews: View {
    @State private var cpt = 0
    let axis: Axis.Set
    var body: some View {
        NavigationView {
            ScrollViewWithOffset(axis: axis) { offset in
                VStack {
                    Color.pink
                        .frame(width: 100, height: 100)
                    Text(offset.x.description)
                    Text(offset.y.description)
                    Text(cpt.description)
                }
            } didEndScrolling: { _ in
                cpt += 1
            }
            .background(Color.mint)
            .navigationTitle(axis == .vertical ? "Vertical" : "Horizontal")
        }
    }
}

Upvotes: 7

Kai Zheng
Kai Zheng

Reputation: 8130

I found a version without using PreferenceKey. The idea is simple - by returning Color from GeometryReader, we can set scrollOffset directly inside background modifier.

struct DemoScrollViewOffsetView: View {
    @State private var offset = CGFloat.zero
    var body: some View {
        ScrollView {
            VStack {
                ForEach(0..<100) { i in
                    Text("Item \(i)").padding()
                }
            }.background(GeometryReader { proxy -> Color in
                DispatchQueue.main.async {
                    offset = -proxy.frame(in: .named("scroll")).origin.y
                }
                return Color.clear
            })
        }.coordinateSpace(name: "scroll")
    }
}

Upvotes: 30

Ceylo
Ceylo

Reputation: 412

I had a similar need but with List instead of ScrollView, and wanted to know wether items in the lists are visible or not (List preloads views not yet visible, so onAppear()/onDisappear() are not suitable).

After a bit of "beautification" I ended up with this usage:

struct ContentView: View {
    var body: some View {
        GeometryReader { geometry in
            List(0..<100) { i in
                Text("Item \(i)")
                    .onItemFrameChanged(listGeometry: geometry) { (frame: CGRect?) in
                        print("rect of item \(i): \(String(describing: frame)))")
                    }
            }
            .trackListFrame()
        }
    }
}

which is backed by this Swift package: https://github.com/Ceylo/ListItemTracking

Upvotes: 6

Related Questions