Reputation: 2807
I used the following code as a reference:
I think it's pretty close.
It seems like it could probably be solved by using origin.maxY
instead of origin.y
,
but origin.maxY
doesn't seem to be provided in GeometryReader
(strictly speaking: CGRect
).
How do I detect when User has reached the bottom of the ScrollView?
import SwiftUI
struct ContentView: View {
let spaceName = "scroll"
@State var scrollViewSize: CGSize = .zero
var body: some View {
ScrollView {
ChildSizeReader(size: $scrollViewSize) {
VStack {
ForEach(0..<100) { i in
Text("\(i)")
}
}
.background(
GeometryReader { proxy in
Color.clear.preference(
key: ViewOffsetKey.self,
value: -1 * proxy.frame(in: .named(spaceName)).origin.y
)
}
)
.onPreferenceChange(
ViewOffsetKey.self,
perform: { value in
print("offset: \(value)") // offset: 1270.3333333333333 when User has reached the bottom
print("height: \(scrollViewSize.height)") // height: 2033.3333333333333
if value == scrollViewSize.height {
print("User has reached the bottom of the ScrollView.")
} else {
print("not reached.")
}
}
)
}
}
.coordinateSpace(name: spaceName)
.onChange(
of: scrollViewSize,
perform: { value in
print(value)
}
)
}
}
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
struct ChildSizeReader<Content: View>: View {
@Binding var size: CGSize
let content: () -> Content
var body: some View {
ZStack {
content().background(
GeometryReader { proxy in
Color.clear.preference(
key: SizePreferenceKey.self,
value: proxy.size
)
}
)
}
.onPreferenceChange(SizePreferenceKey.self) { preferences in
self.size = preferences
}
}
}
struct SizePreferenceKey: PreferenceKey {
typealias Value = CGSize
static var defaultValue: Value = .zero
static func reduce(value _: inout Value, nextValue: () -> Value) {
_ = nextValue()
}
}
Upvotes: 22
Views: 17901
Reputation: 648
let isScrollAtBottom = (scrollView.contentOffset.y + scrollView.frame.height) >= scrollView.contentSize.height
Upvotes: -1
Reputation: 39
After some frustration understanding GeometryReader
CordinateSpace
i've come with this solution, i hope it helps
struct ContentView: View {
@State private var isLoading = false //simulate is loading
@State private var maxInt: Int = 30 //simulate Data
private let minThresh: CGFloat = -100
private var canLoadMore: Bool { //simulate end of list check
return maxInt < 100
}
var body: some View {
GeometryReader { scrollViewGeo in
ScrollView{
VStack(alignment: .leading){
ForEach(0..<maxInt, id: \.self){ v in
Text("\(v)")
Divider()
}
if isLoading {
Text("Loading ...")
.padding()
}
if !canLoadMore {
Text("End of list *")
.padding()
}
}
.background(GeometryReader {
Color.clear
.preference(key: ViewOffsetKey.self,
value: $0.frame(in: .named("container")).maxY)
})
.onPreferenceChange(ViewOffsetKey.self) { maxY in
if scrollViewGeo.size.height - maxY > minThresh {
if canLoadMore, !isLoading {
isLoading = true
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
//response
maxInt += 30
isLoading = false
}
}
}
}
}.coordinateSpace(name: "container")
.padding()
}
}
}
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
}
}
Upvotes: -1
Reputation: 2873
Adapting George's solution to use it in xcode 15 iOS 17 and the first time the sizes are obtained, if the content of the child, its height does not exceed the height of the parent, it means that all the content is visible, which has reached the end of scroll
.onChange(of: scrollViewSize, { oldValue, newValue in
//print("scrollViewSize: \(newValue)")
if newValue.height - wholeSize.height <= 0 {
hasScrolledToEnd = true
}
})
Upvotes: -1
Reputation: 89
I recently found a somewhat simple method to do this:
ScrollView {
LazyVStack {
ForEach(Array(items.enumerated), id: \.element) { index, i in
Rectangle()
.frame(width: 200, height: 100)
.foregroundColor(.green)
.overlay(Text("\(i)"))
.onAppear {
let isLast = index == items.count
// Do whatever you need checking isLast
}
}
}
}
}
Upvotes: 6
Reputation: 2169
ScrollView {
VStack() {
ForEach(viewModel.model.list) { myViewModel in
...
}
Color.clear
.frame(width: 0, height: 0, alignment: .bottom)
.onAppear {
viewModel.scrollAtBottom = true
}
.onDisappear {
viewModel.scrollAtBottom = false
}
}
}
You can do the same for scrollAtTop if needed.
Upvotes: -2
Reputation: 11646
I have a slightly different problem, as I wanted to detect when user have pulled a scrollView ( I used lazyGrid inside ScrollView).
I use a file private ( I cannot use var or state inside view...) and with an hack I extract values AND save out to detect initial position (typically 52) If You drag, You can see appearing message you can trigger reload from network. (not shown for clarity) (Maybe some other hysteresis is need on detection of ":pull to refresh..)
fileprivate var initialY : CGFloat? = nil
struct ContentView: View {
let someBreath = CGFloat(120)
func update(geometry : GeometryProxy)->CGFloat{
let midY = geometry.frame(in: .global).midY
//print(midY)
if initialY == nil{
// update only first time:
initialY = midY
}else{
if midY >= initialY! + someBreath{
print("need refresh")
}
}
return midY
}
var body: some View {
ScrollView {
// GeometryReader wants to return a View of this hierarchy, we return an orange box to show live there coords.
GeometryReader { (geometry : GeometryProxy) in
let midY = update(geometry: geometry)
Text("Top View \(midY)")
.frame(width: geometry.size.width, height: 50)
.background(Color.orange)
}
......
Upvotes: 0
Reputation: 19098
One really easy way to achieve this is the following:
struct ContentView: View {
let array: [String] = (0...100).map { "\($0)" }
let onEndOfList: () -> Void
var body: some View {
List {
ForEach(array, id: \.self) { element in
Text(element)
}
Color.clear
.frame(width: 0, height: 0, alignment: .bottom)
.onAppear {
onEndOfList()
}
}
}
}
Needless to add, that this basic idea can be enhanced and applied to a ScrollView or any other scrollable View.
Upvotes: 18
Reputation: 30341
Wrap your whole ScrollView
in your ChildSizeReader
, so you can get the height of the ScrollView
itself.
Because the offset starts at zero at the top, when at the bottom of the scroll view the end isn't at the top of the screen, but rather the bottom. This difference is the height of the scroll view. This means the ScrollView
starts at offset 0
and goes to total content height - scroll view height
.
Code:
struct ContentView: View {
let spaceName = "scroll"
@State var wholeSize: CGSize = .zero
@State var scrollViewSize: CGSize = .zero
var body: some View {
ChildSizeReader(size: $wholeSize) {
ScrollView {
ChildSizeReader(size: $scrollViewSize) {
VStack {
ForEach(0..<100) { i in
Text("\(i)")
}
}
.background(
GeometryReader { proxy in
Color.clear.preference(
key: ViewOffsetKey.self,
value: -1 * proxy.frame(in: .named(spaceName)).origin.y
)
}
)
.onPreferenceChange(
ViewOffsetKey.self,
perform: { value in
print("offset: \(value)") // offset: 1270.3333333333333 when User has reached the bottom
print("height: \(scrollViewSize.height)") // height: 2033.3333333333333
if value >= scrollViewSize.height - wholeSize.height {
print("User has reached the bottom of the ScrollView.")
} else {
print("not reached.")
}
}
)
}
}
.coordinateSpace(name: spaceName)
}
.onChange(
of: scrollViewSize,
perform: { value in
print(value)
}
)
}
}
Note your already existing scrollViewSize
variable is the content's size, not the scroll view's size.
Also notice that I changed the ==
to >=
- this is so you don't have to be exactly at the height, can be over-scrolled where it rubber-bands back.
Upvotes: 22