47

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!

Islam
  • 3,654
  • 3
  • 30
  • 40
Kai Zheng
  • 6,640
  • 7
  • 43
  • 66
  • Possible duplicate of this question: https://stackoverflow.com/questions/59342384/how-to-detect-scroll-direction-programmatically-in-swiftui-scrollview – SwiftySteve Jan 08 '21 at 09:03

6 Answers6

76

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

Asperi
  • 228,894
  • 20
  • 464
  • 690
  • 5
    Is it possible to set it? – Ian Warburton Jul 25 '20 at 20:02
  • 8
    @Asperi - if I enclose `DemoScrollViewOffsetView` within `NavigationView{}`, I get the following warning message in the debug window when running the simulator: `Bound preference ViewOffsetKey tried to update multiple times per frame.` What is causing the warning/error and how can it be resolved? – Vee Sep 24 '20 at 01:09
  • I'm new to SwiftUI - this solution worked for me, but can you explain a little more about the `.background` modifier on the `VStack`? Is this just for the purposes of getting the scroll position? Thanks. – Gareth Lewis Mar 21 '21 at 10:16
  • @Asperi why have you declared offset var if it is not used? – Duck May 13 '21 at 13:10
  • @Vee I have the same problem. Has it been solved? – Simon Jun 30 '21 at 04:47
  • @Simon - no I never figured out the root cause of the warning. It's been a while but in my case I had nested ScrollView a that caused multiple warnings and moving them around reduced/eliminated the warning. Like I said it was a while ago so I don't have any examples to readily share with you. Good luck and please post if you figure it out! – Vee Jul 01 '21 at 05:44
  • @IanWarburton you can set it with the ScrollViewReader view, https://developer.apple.com/documentation/swiftui/scrollviewreader – Evan Aug 24 '21 at 23:44
  • 1
    @Asperi, If we want to identify bottom end of scroll then how we can get it ? – Sapana Ranipa Nov 22 '21 at 01:12
  • A word of warning. If you find this in your codebase after colleagues asked for help why their ScrollView heavily lags: Use this with caution. Have a look at the idea Adrien's answer gives you: Mutating a state this often leads to heavy body re-evaluations. For complex Views inside the ScrollView Xcodes SwiftUI Profiler shows body evaluations in a scale of thousands - for a view that's contained 3 times inside the Scrollview. Took me two days to debug. – Frederik Winkelsdorf Aug 04 '23 at 17:08
16

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")
    }
}
Kai Zheng
  • 6,640
  • 7
  • 43
  • 66
6

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")
        }
    }
}
Adrien
  • 1,579
  • 6
  • 25
4

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

Ceylo
  • 362
  • 2
  • 11
1

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

import SwiftUI

struct LazyVScrollView<Content: View>: View {
    @State private var rect: CGRect = .zero

    private let id = UUID()
    private let content: (CGRect) -> Content

    init(content: @escaping (CGRect) -> Content) {
        self.content = content
    }

    var body: some View {
        ScrollView {
            content(rect)
                .background {
                    GeometryReader {
                        Color.clear
                            .onChange(of: $0.frame(in: .named(id))) { newValue in
                                rect = newValue
                            }
                    }
                }
        }
        .coordinateSpace(name: id)
    }
}
1

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

RopeySim
  • 423
  • 4
  • 11