Reading time: 2 min

This recipe shows how to get current scroll offset of a SwiftUI ScrollView. The necessary functionality is wrapped in a new component, ObservableScrollView, which works exactly the same as ScrollView does, but also exposes its current scroll offset in a binding.

The end result looks like this:

preview

The recipe goes as follows:

  1. Put a GeometryReader in the ScrollView's content's background. This will allow you to track the content's frame and its changes. (The same trick with a GeometryReader in the background can also be used to measure SwiftUI views.)
  2. The frame's minY property is the current offset. Negate it to track the offset as a positive number.
  3. Safely propagate the new offset value via a PreferenceKey to the binding.
  4. For the sake of completeness, we'll include a ScrollViewProxy so that you can scroll ObservableScrollView programatically.

Here's the code:

// Simple preference that observes a CGFloat.
struct ScrollViewOffsetPreferenceKey: PreferenceKey {
  static var defaultValue = CGFloat.zero

  static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
    value += nextValue()
  }
}

// A ScrollView wrapper that tracks scroll offset changes.
struct ObservableScrollView<Content>: View where Content : View {
  @Namespace var scrollSpace

  @Binding var scrollOffset: CGFloat
  let content: (ScrollViewProxy) -> Content

  init(scrollOffset: Binding<CGFloat>,
       @ViewBuilder content: @escaping (ScrollViewProxy) -> Content) {
    _scrollOffset = scrollOffset
    self.content = content
  }

  var body: some View {
    ScrollView {
      ScrollViewReader { proxy in
        content(proxy)
          .background(GeometryReader { geo in
              let offset = -geo.frame(in: .named(scrollSpace)).minY
              Color.clear
                .preference(key: ScrollViewOffsetPreferenceKey.self,
                            value: offset)
          })
      }
    }
    .coordinateSpace(name: scrollSpace)
    .onPreferenceChange(ScrollViewOffsetPreferenceKey.self) { value in
      scrollOffset = value
    }
  }
}

Then, you can simple use it like this:

struct ScrollViewOffset: View {
  @State var scrollOffset = CGFloat.zero

  var body: some View {
    NavigationView {
      ObservableScrollView(scrollOffset: $scrollOffset) { proxy in
        LazyVStack(alignment: .leading) {
          ForEach(1..<50) { index in
            Text("Row \(index)")
          }
        }
      }
      .navigationBarTitle("Scroll offset: \(scrollOffset)", 
                          displayMode: .inline)
    }
  }
}

Next Post Previous Post