Reputation: 8130
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
Reputation: 119174
You can use .scrollPosition
modifier on ScrollView with the .scrollTargetLayout
on its content:
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
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
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()
}
}
Upvotes: 92
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
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
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
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
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