Get the current scroll position of a SwiftUI ScrollView

Viewed 13695

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!

4 Answers

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

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

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

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

Related