'In SwiftUI, where are the control events, i.e. scrollViewDidScroll to detect the bottom of list data

In SwiftUI, does anyone know where are the control events such as scrollViewDidScroll to detect when a user reaches the bottom of a list causing an event to retrieve additional chunks of data? Or is there a new way to do this?

Seems like UIRefreshControl() is not there either...



Solution 1:[1]

You can to check that the latest element is appeared inside onAppear.

struct ContentView: View {
    @State var items = Array(1...30)

    var body: some View {
        List {
            ForEach(items, id: \.self) { item in
                Text("\(item)")
                .onAppear {
                    if let last == self.items.last {
                        print("last item")
                        self.items += last+1...last+30
                    }
                }
            }
        }
    }
}

Solution 2:[2]

Plenty of features are missing from SwiftUI - it doesn't seem to be possible at the moment.

But here's a workaround.

TL;DR skip directly at the bottom of the answer

An interesting finding whilst doing some comparisons between ScrollView and List:

struct ContentView: View {

    var body: some View {

        ScrollView {
            ForEach(1...100) { item in
                Text("\(item)")
            }
            Rectangle()
                .onAppear { print("Reached end of scroll view")  }
        }
    }

}

I appended a Rectangle at the end of 100 Text items inside a ScrollView, with a print in onDidAppear.

It fired when the ScrollView appeared, even if it showed the first 20 items.

All views inside a Scrollview are rendered immediately, even if they are offscreen.

I tried the same with List, and the behaviour is different.

struct ContentView: View {

    var body: some View {

        List {
            ForEach(1...100) { item in
                Text("\(item)")
            }
            Rectangle()
                .onAppear { print("Reached end of scroll view")  }
        }
    }

}

The print gets executed only when the bottom of the List is reached!

So this is a temporary solution, until SwiftUI API gets better.

Use a List and place a "fake" view at the end of it, and put fetching logic inside onAppear { }

Solution 3:[3]

In case you need more precise info on how for the scrollView or list has been scrolled, you could use the following extension as a workaround:

extension View {

    func onFrameChange(_ frameHandler: @escaping (CGRect)->(), 
                    enabled isEnabled: Bool = true) -> some View {

        guard isEnabled else { return AnyView(self) }

        return AnyView(self.background(GeometryReader { (geometry: GeometryProxy) in

            Color.clear.beforeReturn {

                frameHandler(geometry.frame(in: .global))
            }
        }))
    }

    private func beforeReturn(_ onBeforeReturn: ()->()) -> Self {
        onBeforeReturn()
        return self
    }
}

The way you can leverage the changed frame like this:

struct ContentView: View {

    var body: some View {

        ScrollView {

            ForEach(0..<100) { number in

                Text("\(number)").onFrameChange({ (frame) in

                    print("Origin is now \(frame.origin)")

                }, enabled: number == 0)
            }
        }
    }
}

The onFrameChange closure will be called while scrolling. Using a different color than clear might result in better performance.

edit: I've improved the code a little bit by getting the frame outside of the beforeReturn closure. This helps in the cases where the geometryProxy is not available within that closure.

Solution 4:[4]

I tried the answer for this question and was getting the error Pattern matching in a condition requires the 'case' keyword like @C.Aglar .

I changed the code to check if the item that appears is the last of the list, it'll print/execute the clause. This condition will be true once you scroll and reach the last element of the list.

struct ContentView: View {
    @State var items = Array(1...30)

    var body: some View {
        List {
            ForEach(items, id: \.self) { item in
                Text("\(item)")
                .onAppear {
                    if item == self.items.last {
                        print("last item")
                        fetchStuff()
                    }
                }
            }
        }
    }
}

Solution 5:[5]

The OnAppear workaround works fine on a LazyVStack nested inside of a ScrollView, e.g.:

ScrollView {
   LazyVStack (alignment: .leading) {
      TextField("comida", text: $controller.searchedText)
      
      switch controller.dataStatus {
         case DataRequestStatus.notYetRequested:
            typeSomethingView
         case DataRequestStatus.done:
            bunchOfItems
         case DataRequestStatus.waiting:
            loadingView
         case DataRequestStatus.error:
            errorView
      }
      
      bottomInvisibleView
         .onAppear {
            controller.loadNextPage()
         }
   }
   .padding()
}

The LazyVStack is, well, lazy, and so only create the bottom when it's almost on the screen

Solution 6:[6]

I've extracted the LazyVStack plus invisible view in a view modifier for ScrollView that can be used like:

ScrollView {
  Text("Some long long text")
}.onScrolledToBottom {
  ...
}

The implementation:

extension ScrollView {
    func onScrolledToBottom(perform action: @escaping() -> Void) -> some View {
        return ScrollView<LazyVStack> {
            LazyVStack {
                self.content
                Rectangle().size(.zero).onAppear {
                    action()
                }
            }
        }
    }
}

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2
Solution 3 Nic Wanavit
Solution 4 pawello2222
Solution 5 lipemesq
Solution 6 kanobius