← Blog

Infinite Scrolling List for Paginated Results from GraphQL with SwiftUI

iOS
SwiftUI
published on:

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 implements GraphQL’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

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

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. loadIssues uses the ProjectIssuesQuery which includes the information necessary to navigate the pagination:

ProjectIssuesQuery.graphql

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

View

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

ProjectIssuesView.swift

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

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.