SwiftUI pagination for List object
Asked Answered
G

4

7

I've implemented a List with a search bar in SwiftUI. Now I want to implement paging for this list. When the user scrolls to the bottom of the list, new elements should be loaded. My problem is, how can I detect that the user scrolled to the end? When this happens I want to load new elements, append them and show them to the user.

My code looks like this:

import Foundation
import SwiftUI

struct MyList: View {
    @EnvironmentObject var webService: GetRequestsWebService

    @ObservedObject var viewModelMyList: MyListViewModel

    @State private var query = ""

    var body: some View {

        let binding = Binding<String>(
            get: { self.query },
            set: { self.query = $0; self.textFieldChanged($0) }
        )

        return NavigationView {
            // how to detect here when end of the list is reached by scrolling?
            List {
                // searchbar here inside the list element
                TextField("Search...", text: binding) {
                    self.fetchResults()
                }

                ForEach(viewModelMyList.items, id: \.id) { item in
                    MyRow(itemToProcess: item)
                }
            } 
            .navigationBarTitle("Title")
        }.onAppear(perform: fetchResults)

    }

    private func textFieldChanged(_ text: String) {        
        text.isEmpty ? viewModelMyList.fetchResultsThrottelt(for: nil) : viewModelMyList.fetchResultsThrottelt(for: text)
    }

    private func fetchResults() {
        query.isEmpty ? viewModelMyList.fetchResults(for: nil) : viewModelMyList.fetchResults(for: query)
    }
}

Also a little bit special this case, because the list contains the search bar. I would be thankful for any advice because with this :).

Giarla answered 22/1, 2020 at 21:18 Comment(0)
A
9

Add a .onAppear() to the MyRow and have it call the viewModel with the item that just appears. You can then check if its equal to the last item in the list or if its n items away from the end of the list and trigger your pagination.

Alyshaalysia answered 22/1, 2020 at 21:28 Comment(0)
A
25

As you have already a List with an artificial row for the search bar, you can simply add another view to the list which will trigger another fetch when it appears on screen (using onAppear() as suggested by Josh). By doing this you do not have to do any "complicated" calculations to know whether a row is the last row... the artificial row is always the last one!

I already used this in one of my projects and I've never seen this element on the screen, as the loading was triggered so quickly before it appeared on the screen. (You surely can use a transparent/invisible element, or perhaps even use a spinner ;-))

List {
    TextField("Search...", text: binding) {
        /* ... */
    }

    ForEach(viewModelMyList.items, id: \.id) { item in
        // ...
    }

    if self.viewModelMyList.hasMoreRows {
        Text("Fetching more...")
                .onAppear(perform: {
                    self.viewModelMyList.fetchMore()
                })
    }
}
Alric answered 24/1, 2020 at 17:21 Comment(4)
Great :+1, thank you pd95! This is also a very nice solution you proposed here. When doing it like this also a small information can be displayed to the user that sth. is going on there and data is loading. Thanks a lot for this.Giarla
this is strange, as the onAppear is called only once and not after the update, so i get only 1 pageLeak
Great suggestion! But it does not work if after loading in the first set of data, the last artificial row is still showing. The on appear will never get triggered again since the cell was never hidden and re-drawnRedmond
@Redmond I've seen such issues when hasMoreRows is not changing between two calls of fetchMore(). This is why I set hasMoreRows to false while loading more data. After the result is available, I set it again according to the remaining items to fetch. ==> This makes SwiftUI remove and re-add the "Fetching more…" Text view as necessary.Alric
A
9

Add a .onAppear() to the MyRow and have it call the viewModel with the item that just appears. You can then check if its equal to the last item in the list or if its n items away from the end of the list and trigger your pagination.

Alyshaalysia answered 22/1, 2020 at 21:28 Comment(0)
M
1

This one worked for me:

You can add pagination with two different approaches to your List: Last item approach and Threshold item approach.

That's way this package adds two functions to RandomAccessCollection:

isLastItem

Use this function to check if the item in the current List item iteration is the last item of your collection.

isThresholdItem

With this function you can find out if the item of the current List item iteration is the item at your defined threshold. Pass an offset (distance to the last item) to the function so the threshold item can be determined.

import SwiftUI

extension RandomAccessCollection where Self.Element: Identifiable {
    public func isLastItem<Item: Identifiable>(_ item: Item) -> Bool {
        guard !isEmpty else {
            return false
        }
        
        guard let itemIndex = lastIndex(where: { AnyHashable($0.id) == AnyHashable(item.id) }) else {
            return false
        }
        
        let distance = self.distance(from: itemIndex, to: endIndex)
        return distance == 1
    }
    
    public func isThresholdItem<Item: Identifiable>(
        offset: Int,
        item: Item
    ) -> Bool {
        guard !isEmpty else {
            return false
        }
        
        guard let itemIndex = lastIndex(where: { AnyHashable($0.id) == AnyHashable(item.id) }) else {
            return false
        }
        
        let distance = self.distance(from: itemIndex, to: endIndex)
        let offset = offset < count ? offset : count - 1
        return offset == (distance - 1)
    }
}

Examples

Last item approach:

struct ListPaginationExampleView: View {
    @State private var items: [String] = Array(0...24).map { "Item \($0)" }
    @State private var isLoading: Bool = false
    @State private var page: Int = 0
    private let pageSize: Int = 25
    
    var body: some View {
        NavigationView {
            List(items) { item in
                VStack(alignment: .leading) {
                    Text(item)
                    
                    if self.isLoading && self.items.isLastItem(item) {
                        Divider()
                        Text("Loading ...")
                            .padding(.vertical)
                    }
                }.onAppear {
                    self.listItemAppears(item)
                }
            }
            .navigationBarTitle("List of items")
            .navigationBarItems(trailing: Text("Page index: \(page)"))
        }
    }
}

extension ListPaginationExampleView {
    private func listItemAppears<Item: Identifiable>(_ item: Item) {
        if items.isLastItem(item) {
            isLoading = true
            
            /*
                Simulated async behaviour:
                Creates items for the next page and
                appends them to the list after a short delay
             */
            DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 3) {
                self.page += 1
                let moreItems = self.getMoreItems(forPage: self.page, pageSize: self.pageSize)
                self.items.append(contentsOf: moreItems)
                
                self.isLoading = false
            }
        }
    }
}

Threshold item approach:

struct ListPaginationThresholdExampleView: View {
    @State private var items: [String] = Array(0...24).map { "Item \($0)" }
    @State private var isLoading: Bool = false
    @State private var page: Int = 0
    private let pageSize: Int = 25
    private let offset: Int = 10
    
    var body: some View {
        NavigationView {
            List(items) { item in
                VStack(alignment: .leading) {
                    Text(item)
                    
                    if self.isLoading && self.items.isLastItem(item) {
                        Divider()
                        Text("Loading ...")
                            .padding(.vertical)
                    }
                }.onAppear {
                    self.listItemAppears(item)
                }
            }
            .navigationBarTitle("List of items")
            .navigationBarItems(trailing: Text("Page index: \(page)"))
        }
    }
}

extension ListPaginationThresholdExampleView {
    private func listItemAppears<Item: Identifiable>(_ item: Item) {
        if items.isThresholdItem(offset: offset,
                                 item: item) {
            isLoading = true
            
            /*
                Simulated async behaviour:
                Creates items for the next page and
                appends them to the list after a short delay
             */
            DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.5) {
                self.page += 1
                let moreItems = self.getMoreItems(forPage: self.page, pageSize: self.pageSize)
                self.items.append(contentsOf: moreItems)
                
                self.isLoading = false
            }
        }
    }
}

String Extension:

/*
    If you want to display an array of strings
    in the List view you have to specify a key path,
    so each string can be uniquely identified.
    With this extension you don't have to do that anymore.
 */
extension String: Identifiable {
    public var id: String {
        return self
    }
}

Christian Elies, code reference

Myriam answered 20/8, 2020 at 16:49 Comment(0)
E
1
List {

 LazyVSTack {

  TextField("Search...", text: binding) {

    /* ... */

  }

  ForEach(viewModelMyList.items, id: \.id) { item in

    // ...

  }

  if self.viewModelMyList.hasMoreRows {

    Text("Fetching more...")

            .onAppear(perform: {

                self.viewModelMyList.fetchMore()

            })

   }

  }

When using LazyVStack, it loads views only when they appear on the screen, causing onAppear to trigger each time the ProgressView enters the visible area. Otherwise, onAppear is triggered just once when the ProgressView is added to the view hierarchy.

Encourage answered 11/9 at 9:57 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.