22

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

Matteo Pacini
  • 21,796
  • 7
  • 67
  • 74
J. Edgell
  • 1,555
  • 3
  • 16
  • 24

6 Answers6

29

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

Matteo Pacini
  • 21,796
  • 7
  • 67
  • 74
  • 1
    A large thank you! It's a brilliant solution that I would not have thought of. Wish I could give you more than a single "Answered"! – J. Edgell Jun 14 '19 at 18:35
  • 2
    “This probably means that all views inside a Scrollview are rendered immediately” ... Use view debugger and you’ll see that’s precisely what is going on. This is fairly standard scroll view pattern, where you add everything (from which it can then confirm `contentSize` and present the appropriate scroll indicators). – Rob Jun 14 '19 at 19:01
  • This is so clever. SwiftUI really make you thing differently – Dimillian Jun 19 '19 at 16:18
  • 1
    You can add a gesture to a `ScrollView` and trigger upon dragging. For example, `.gesture(DragGesture(minimumDistance: length).onEnded{ _ in print"Done!" })` which might be useful here. – MoFlo Jun 20 '19 at 00:45
  • @MoFlo I tried your suggestion with a List, and the event never seemed to fire as I scrolled the list up & down. Guessing the List struct suppresses events like this some way. Is there a way to see this event regardless? – ConfusionTowers Oct 23 '19 at 16:37
  • if you need solution which works with List or ScrollView see https://stackoverflow.com/questions/60563668/swiftui-async-data-fetch/60569935#60569935 – user3441734 Mar 06 '20 at 20:28
  • many many Thank you from bottom of my heart cheers – Prabhdeep Singh Feb 11 '21 at 10:47
14

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
                    }
                }
            }
        }
    }
}
Victor Kushnerov
  • 3,706
  • 27
  • 56
9

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.

Nic Wanavit
  • 2,363
  • 5
  • 19
  • 31
Menno
  • 1,232
  • 12
  • 23
3

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()
                    }
                }
            }
        }
    }
}
pawello2222
  • 46,897
  • 22
  • 145
  • 209
Yisselda
  • 71
  • 4
2

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

lipemesq
  • 21
  • 1
2

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()
                }
            }
        }
    }
}
kanobius
  • 756
  • 9
  • 12