18

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()
  }
}
shingo.nakanishi
  • 2,045
  • 2
  • 21
  • 55

5 Answers5

20

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.

George
  • 25,988
  • 10
  • 79
  • 133
  • @Sajjon From their original question – George Feb 14 '22 at 12:32
  • Thanks for the answer it helped me too. One things troubles me. I can see that the ViewOffsetKey is updated on user drag gesture but I cannot understand what triggers the pref key update exactly. does scrollView's drag triggers the background redraw hence update the pref key ? – FitzChill Apr 28 '22 at 09:20
  • 1
    @FitzChill The `GeometryReader` does the important work. When you scroll, the frame will move. – George Apr 28 '22 at 09:21
  • 1
    @FitzChill I think it’s useful in SwiftUI to think of every frame as it all being recomputed entirely. In reality it’s not and SwiftUI has smart optimisations, but you could think of it as “the background is drawn for every frame when scrolling”. – George Apr 28 '22 at 09:23
  • That is what I ended up thinking. – FitzChill Apr 28 '22 at 09:24
13

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.

CouchDeveloper
  • 18,174
  • 3
  • 45
  • 67
1

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
                        }

                }
        }
    }
}
ahg
  • 31
  • 1
  • 2
0

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)
            }
......
ingconti
  • 10,876
  • 3
  • 61
  • 48
-1

2023 One-Liner

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.

mathematics-and-caffeine
  • 1,664
  • 2
  • 15
  • 19