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

Viewed 12853

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

6 Answers

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 { }

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

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.

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

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

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