SwiftUI ScrollView offset

Searching online for "SwiftUI ScrollView offset" yields to plenty of discussions on how to control the scroll position of a ScrollView:
with iOS 14 SwiftUI has gained ScrollViewReader, covered here, which makes the old introspection tricks obsolete.

Does it mean that we no longer need the ScrollView offset?
In this article, let's explore how to obtain the offset and some of its uses.

ScrollView offset 101

Similarly to UIScrollView, ScrollView is composed of two layers:

  • the frame layer, used to position the ScrollView itself in the view hierarchy
  • the content layer, where all the ScrollView content is placed

If we look at a vertical scroll view, which we will use in this article, the offset represents the gap between the smallest value for the y-coordinate of the frame layer with the smallest value for the y-coordinate of the content layer.

Getting the offset

This is SwiftUI's ScrollView initializer definition:

public struct ScrollView<Content: View>: View {
  public init(
    _ axes: Axis.Set = .vertical, 
    showsIndicators: Bool = true, 
    @ViewBuilder content: () -> Content
  )
}

Besides a content view builder, there's not much we can play with. Let's create a simple ScrollView with a few Texts in it:

ScrollView {
  Text("A")
  Text("B")
  Text("C")
}

The offset will be the same as the offset of the first element in the content, Text("A"):
how do we get this element offset?

Once again, we need to go back to SwiftUI's Swiss army knife, a.k.a. GeometryReader, along with a new PreferenceKey.

First, let's define the preference key:

/// Contains the gap between the smallest value for the y-coordinate of 
/// the frame layer and the content layer.
private struct OffsetPreferenceKey: PreferenceKey {
  static var defaultValue: CGFloat = .zero
  static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {}
}

Second, let's add our geometry reader as the background of our element of interest:

ScrollView {
  Text("A")
    .background {
      GeometryReader { proxy in
        Color.clear
          .preference(
            key: OffsetPreferenceKey.self,
            value: proxy.frame(in: .local).minY
          )
      }
    }
  Text("B")
  Text("C")
}

The geometry reader, as we've seen in Sharing layout information in SwiftUI, is used to share information of our element in the view hierarchy:
we're using it to extract the smallest value for the y-coordinate of our view, which matches our offset definition.

This is great. However, it doesn't work:
we're querying GeometryProxy for a frame in the local coordinate space, which is the space proposed to our background view.

In short, the minY of Color.clear is, and always be, zero in the local coordinates.

Asking for the frame in the .global coordinate space, which is the space from the device screen point of view, is a non-starter: our ScrollViews can be placed anywhere in the view hierarchy, the .global coordinate space won't help us here.

What happens if we put the GeometryReader just above Text("A")?

ScrollView {
  GeometryReader { proxy in
    Color.clear
      .preference(
        key: OffsetPreferenceKey.self,
        value: proxy.frame(in: .local).minY
      )
  }
  Text("A")
  Text("B")
  Text("C")
}

This might seem to be more promising. However, it still wouldn't work:
in this case, the .local coordinate space is the ScrollView's content layer. Instead, we need the frame according to our ScrollView's frame layer.

In order to get the GeometryProxy frame according to our ScrollView's frame layer, we need to define a new coordinate space on the ScrollView, and refer to that within our GeometryReader:

ScrollView {
  Text("A")
    .background(
      GeometryReader { proxy in
        Color.clear
          .preference(
            key: OffsetPreferenceKey.self,
            value: proxy.frame(in: .named("frameLayer")).minY // 👈🏻 
          )
      }
    )
  Text("B")
  Text("C")
}
.coordinateSpace(name: "frameLayer") // 👈🏻 

This works because ScrollView exposes the frame layer from the outside, great! Now the correct ScrollView offset is available in the view hierarchy.

Creating a ScrollViewOffset View

Before proceeding, it would be great if we could generalize this approach to work with any content, making it easy to get the offset when needed.

There's a challenge: ScrollView takes in a content view builder, which makes it impossible from our side to get the first element of that content.

We could apply a .background modifier to the whole content. However, this doesn't take into account the possibility that the content itself could be a Group:
in that case, the modifier would be applied to each group element, which is not what we wanted.

A solution is moving the geometry reader above the ScrollView's content, and then hide it with a negative padding on the actual content:

struct ScrollViewOffset<Content: View>: View {
  let content: () -> Content

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

  var body: some View {
    ScrollView {
      offsetReader
      content()
        .padding(.top, -8) // 👈🏻 places the real content as if our `offsetReader` was not there.
    }
    .coordinateSpace(name: "frameLayer")
  }

  var offsetReader: some View {
    GeometryReader { proxy in
      Color.clear
        .preference(
          key: OffsetPreferenceKey.self,
          value: proxy.frame(in: .named("frameLayer")).minY
        )
    }
    .frame(height: 0) // 👈🏻 make sure that the reader doesn't affect the content height
  }
}

Similarly to our readSize modifier, we can make ScrollViewOffset ask for a callback to be called every time the offset is changed as well:

struct ScrollViewOffset<Content: View>: View {
  let content: () -> Content
  let onOffsetChange: (CGFloat) -> Void

  init(
    @ViewBuilder content: @escaping () -> Content,
    onOffsetChange: @escaping (CGFloat) -> Void
  ) {
    self.content = content
    self.onOffsetChange = onOffsetChange
  }

  var body: some View {
    ScrollView {
      ...
    }
    .coordinateSpace(name: "frameLayer")
    .onPreferenceChange(OffsetPreferenceKey.self, perform: onOffsetChange)
  }

  var offsetReader: some View {
    ...
  }
}

Going back to our example, this is how this new view can be used:

ScrollViewOffset {
  Text("A")
  Text("B")
  Text("C")
} onOffsetChange: { offset in
  print("New ScrollView offset: \(offset)") 
}

The final gist can be found here.

Uses

Now that we have this new powerful information, it's really up to us what to do with it.

Probably the most common use is around changing the color of the top safe area when scrolling:

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

  var body: some View {
    ZStack {
      scrollView
      statusBarView
    }
  }

  var scrollView: some View {
    ScrollViewOffset {
      LazyVStack {
        ForEach(0..<100) { index in
          Text("\(index)")
        }
      }
    } onOffsetChange: {
      scrollOffset = $0
    }
  }

  var statusBarView: some View {
    GeometryReader { geometry in
      Color.red
        .opacity(opacity)
        .frame(height: geometry.safeAreaInsets.top, alignment: .top)
        .edgesIgnoringSafeArea(.top)
    }
  }

  var opacity: Double {
    switch scrollOffset {
    case -100...0:
      return Double(-scrollOffset) / 100.0
    case ...(-100):
      return 1
    default:
      return 0
    }
  }
}

The limit is our imagination, here's a view that changes the background color based on the scroll position:

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

  var body: some View {
    ZStack {
      backgroundColor
      scrollView
    }
  }

  var backgroundColor: some View {
    Color(
      //         This number determines how fast the color changes 👇🏻
      hue: Double(abs(scrollOffset.truncatingRemainder(dividingBy: 3500))) / 3500,
      saturation: 1,
      brightness: 1
    )
    .ignoresSafeArea()
  }

  var scrollView: some View {
    ScrollViewOffset {
      LazyVStack(spacing: 8) {
        ForEach(0..<100) { index in
          Text("\(index)")
            .font(.title)
        }
      }
    } onOffsetChange: {
      scrollOffset = $0
    }
  }
}

iOS 13 vs iOS 14

All we've seen above works great on iOS 14. However, in iOS 13, the initial offset is different.

In iOS 13, the offset considers the top safe area:
for example, the initial offset of a ScrollViewOffset embedded in a NavigationView with a large title is 140 points. The same view in iOS 14 will have the initial (correct) offset value of 0 points.

You've been warned!

Conclusions

Thanks to ScrollViewReader, we no longer need to access the ScrollView offset for most of the use cases: for the rest, GeometryReader has our back.

Do you have any other use for the scroll View offset? Let me know!

Thank you for reading, and stay tuned for more SwiftUI articles!

⭑⭑⭑⭑⭑

Further Reading

Explore SwiftUI

Browse all