liman.io

liman.io

A infinite-scrolling list of GitLab issues in gitlapp

Infinite Scrolling List for Paginated Results from GraphQL with SwiftUI

SwiftUI

published on 2022-07-06

📚  This article is part of a series on SwiftUI:
  • Tackling `AttributeGraph precondition failure: setting value during update` using Realm in SwiftUI
  • Open Third-Party URLs in a SwiftUI App Using a Share Extension
  • Infinite Scrolling List for Paginated Results from GraphQL with SwiftUI
  • Synced Realm on iOS with SwiftUI using Sign-in with Apple for Authentication

Creating a list in SwiftUI is pretty simple using List. While hard-coded lists do not necessarily contain many items, we usually refer to other data sources, e.g., an API, to populate lists. This way, the items in the list can easily exceed the available vertical display space which automatically makes the list scrollable. However, the API used as the list’s data source may not return all items that satisfy the corresponding request. Rather, the returned results are paginated where only one of the pages is returned at a time. To enable the user to scroll through the list of all items seamlessly, we refer to the paradigm of infinite scrolling which is not provided by SwiftUI’s List, though.

GraphQL

Compared to REST, GraphQL is a typed, version-less language to query an API. The language’s specification does not define pagination but denotes the paradigm as a best practice. It is up to the API provider to decide whether they want to implement pagination in their API. For example, GitLab’s GraphQL API implementsGraphQL’s recommendation and returns 100 items per page by default. For Swift, there are different libraries to implement GraphQL on the server- and client-side. In our example, we will use the client-side library Apollo iOS to query paginated results from an API as the data source for a List.

Implementation

GitLab is a DevOps platform that supports teams from planning to production in software development. With so-called issues, the platform offers a way“to collaborate on ideas, solve problems, and plan work”. To retrieve these issues outside of GitLab, a REST and GraphQL API are available. Our goal is to use the GraphQL API to retrieve the issues of a project and display them as a List in SwiftUI using infinite scrolling. Before diving into our example, we have to install Apollo iOS. As detailed in Apollo’s documentation, we download the schema of GitLab’s GraphQL API, create a client, and set up a query to perform for our List.

View Model

After this, we arrive at the following view model that populates issues with paginated results from GitLab’s API:

ProjectIssuesViewModel.swift
import Apollo
import Foundation

class ProjectIssuesViewModel: ObservableObject {
    @Published var isLoading: Bool = true
    @Published var isLoadingMore: Bool = false
    @Published var issues: [Issue] = []
    
    private var activeRequest: Cancellable?
    private var lastConnection: ProjectIssuesQuery.Data.Project.Issue?
    
    func loadIssuesIfAny(profile: Profile, project: Project) -> Void {
        guard lastConnection != nil else {
            isLoading = true
            loadIssues(profile: profile, project: project, cursor: nil)
            
            return
        }
        
        if lastConnection?.pageInfo.hasNextPage ?? false {
            isLoadingMore = true
            loadIssues(profile: profile, project: project, cursor: self.lastConnection?.pageInfo.endCursor)
        }
    }
    
    private func loadIssues(profile: Profile, project: Project, cursor: String?) -> Void {
        let network = Network.getFor(profile: profile)
        let projectID = project.fullPath
        
        DispatchQueue.global().async {
            network.apollo.fetch(query: ProjectIssuesQuery(cursor: cursor, fullPath: projectID), cachePolicy: .fetchIgnoringCacheData) { result in
                switch result {
                case .success(let result):
                    if let issuesConnection = result.data?.project?.issues {
                        self.lastConnection = issuesConnection
                        
                        if cursor == nil {
                            self.issues = issuesConnection.issues!.map { issue in Issue(issue: issue!)}
                        } else {
                            self.issues.append(contentsOf: issuesConnection.issues!.map { issue in Issue(issue: issue!)})
                        }
                    } else if let errors = result.errors {
                        self.reportProjectIssuesError(error: errors[0])
                    } else {
                        self.reportProjectIssuesError(error: APIError.unknownError)
                    }
                case .failure(let error):
                    self.reportProjectIssuesError(error: error)
                }

                self.isLoading = false
                self.isLoadingMore = false
            }
        }
    }
    
    private func reportProjectIssuesError(error: Error) -> Void {
        Log.error("Could not retrieve the project issues: \(error.localizedDescription, privacy: .public)")
    }
}

In particular, loadIssuesIfAny verifies whether any issues have been loaded already. If so, the function makes sure that next page of issues is loaded. In both cases, loadIssuesIfAny refers to loadIssues to retrieve the corresponding page from the API using a cursor. loadIssuesuses the ProjectIssuesQuery which includes the information necessary to navigate the pagination:

ProjectIssuesQuery.graphql
query ProjectIssues($cursor: String, $fullPath: ID!) {
  project(fullPath: $fullPath) {
    issues(after: $cursor, sort: UPDATED_DESC) {
      issues: nodes {
        author {
          avatarUrl
          name
        }
        id
        iid
        labels {
          labels: nodes {
            color
            id
            textColor
            title
          }
        }
        milestone {
          title
        }
        state
        title
        updatedAt
        userNotesCount
        webUrl
      }
      pageInfo {
        endCursor
        hasNextPage
      }
    }
  }
}

View

Using the view model, we can create the corresponding view with an infinite scrolling List:

ProjectIssuesView.swift
import RealmSwift
import SwiftUI

struct ProjectIssuesView: View {
    @ObservedRealmObject var profile: Profile
    let project: Project
    
    @StateObject private var viewModel = ProjectIssuesViewModel()
    
    var body: some View {
        if viewModel.isLoading {
            VStack(alignment: .center) {
                ProgressView()
                    .onAppear {
                        viewModel.loadIssuesIfAny(profile: profile, project: project, issueState: selectedIssueState, searchText: searchText)
                    }
            }
            .navigationBarTitleDisplayMode(.inline)
            .navigationBarTitle("Issues")
        } else if viewModel.issues.isEmpty {
            VStack(alignment: .center) {
                Image("Issues Illustration")
            }
            .navigationBarTitleDisplayMode(.inline)
            .navigationBarTitle("Issues")
        } else {
            ScrollViewReader { proxy in
                ZStack {
                    List {
                        ForEach(viewModel.issues) { issue in
                            IssuePreview(viewModel: viewModel, profile: profile, project: project, issue: issue)
                                .onAppear {
                                    if viewModel.issues.last == issue {
                                        viewModel.loadIssuesIfAny(profile: profile, project: project)
                                    }
                                }
                        }
                        if viewModel.isLoadingMore {
                            HStack {
                                Spacer()
                                ProgressView()
                                Spacer()
                            }
                            .padding(EdgeInsets(top: 10, leading: 0, bottom: 10, trailing: 0))
                        }
                    }
                    .listStyle(.plain)
                }
            }
            .navigationBarTitleDisplayMode(.inline)
            .navigationBarTitle("Issues")
        }
    }
}

As with a finite scrolling list, the infinite scrolling variant contains a ForEach that renders the currently loaded pages. In addition, the List also contains a ProgressView that serves as a loading indicator during the retrieval of additional pages. This loading indicator is visible as soon as viewModel.isLoadingMore is true. That is the case after the user reaches the end of the list and the last item of the currently loaded page appears. onAppear then calls loadIssuesIfAny to retrieve the next page of issues if any.

Demonstration

Below we can see the result of the above implementation running on iOS 15:

Infinite scrolling list on iOS 15 implemented with SwiftUI

Please note that this implementation contains more features than described above and looks slightly different as a consequence. That does not affect the presentation of the infinite scrolling paradigm, though.

Daniel Fürst © 2023 • Legal Notice • Privacy Policy