Search code examples
iosswiftswiftui

How do I detect when User has reached the bottom of the ScrollView?


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()
  }
}

Solution

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