5. Paginate results
As mentioned earlier, the object returned from the LaunchListQuery
is a LaunchConnection
. This object has a list of launches, a pagination cursor, and a boolean to indicate whether more launches exist.
When using a cursor-based pagination system, it's important to remember that the cursor gives you a place where you can get all results after a certain spot, regardless of whether more items have been added in the interim.
You're going to use a second section in the TableView to allow your user to load more launches as long as they exist. But how will you know if they exist? First, you need to hang on to the most recently received LaunchConnection
object.
Add a variable to hold on to this object at the top of the LaunchesViewController.swift
file near your launches
variable:
1private var lastConnection: LaunchListQuery.Data.Launch?
Next, you're going to take advantage of a type from the Apollo library. Add the following to the top of the file:
1import Apollo
Then, below lastConnection
, add a variable to hang on to the most recent request:
1private var activeRequest: Cancellable?
Next, add a second case to your ListSection
enum:
1enum ListSection: Int, CaseIterable {
2 case launches
3 case loading
4}
This allows loading state to be displayed and selected in a separate section, keeping your launches
section tied to the launches
variable.
This will also cause a number of errors because you're no longer exhaustively handling all the cases in the enum - let's fix that.
In tableView(_:, numberOfRowsInSection:)
, add handling for the .loading
case, which returns 0
if there are no more launches to load:
1case .loading:
2 if self.lastConnection?.hasMore == false {
3 return 0
4 } else {
5 return 1
6 }
Remember here that if lastConnection
is nil, there are more launches to load, since we haven't even loaded a first connection.
Next, add handling for the .loading
case to tableView(_, cellForRowAt:)
, showing a different message based on whether there's an active request or not:
1case .loading:
2 if self.activeRequest == nil {
3 cell.textLabel?.text = "Tap to load more"
4 } else {
5 cell.textLabel?.text = "Loading..."
6 }
Next, you'll need to provide the cursor to your LaunchListQuery
. The good news is that the launches
API takes an optional after
parameter, which accepts a cursor.
To pass a variable into a GraphQL query, you need to use syntax that defines that variable using a $name
and its type. You can then pass the variable in as a parameter value to an API which takes a parameter.
What does this look like in practice? Go to LaunchList.graphql
and update just the first two lines to take and use the cursor as a parameter:
1query LaunchList($cursor:String) {
2 launches(after:$cursor) {
Build the application so the code generation picks up on this new parameter. You'll still see one error for a non-exhaustive switch, but this is something we'll fix shortly.
Next, go back to LaunchesViewController.swift
and update loadLaunches()
to be loadMoreLaunches(from cursor: String?)
, hanging on to the active request (and nil'ing it out when it completes), and updating the last received connection:
1private func loadMoreLaunches(from cursor: String?) {
2 self.activeRequest = Network.shared.apollo.fetch(query: LaunchListQuery(cursor: cursor)) { [weak self] result in
3 guard let self = self else {
4 return
5 }
6
7 self.activeRequest = nil
8 defer {
9 self.tableView.reloadData()
10 }
11
12 switch result {
13 case .success(let graphQLResult):
14 if let launchConnection = graphQLResult.data?.launches {
15 self.lastConnection = launchConnection
16 self.launches.append(contentsOf: launchConnection.launches.compactMap { $0 })
17 }
18
19 if let errors = graphQLResult.errors {
20 let message = errors
21 .map { $0.localizedDescription }
22 .joined(separator: "\n")
23 self.showAlert(title: "GraphQL Error(s)",
24 message: message)
25 }
26 case .failure(let error):
27 self.showAlert(title: "Network Error",
28 message: error.localizedDescription)
29 }
30 }
31}
Then, add a new method to figure out if new launches need to be loaded:
1private func loadMoreLaunchesIfTheyExist() {
2 guard let connection = self.lastConnection else {
3 // We don't have stored launch details, load from scratch
4 self.loadMoreLaunches(from: nil)
5 return
6 }
7
8 guard connection.hasMore else {
9 // No more launches to fetch
10 return
11 }
12
13 self.loadMoreLaunches(from: connection.cursor)
14}
Update viewDidLoad
to use this new method rather than calling loadMoreLaunches(from:)
directly:
1override func viewDidLoad() {
2 super.viewDidLoad()
3 self.loadMoreLaunchesIfTheyExist()
4}
Next, you need to add some handling when the cell is tapped. Normally that's handled by prepare(for segue:)
, but because you're going to be reloading things in the current view controller, you won't want the segue to perform at all.
Luckily, you can use UIViewController
's shouldPerformSegue(withIdentifier:sender:)
method to say, "In this case, don't perform this segue, and take these other actions instead."
This method was already overridden in the starter project. Update the code within it to perform the segue for anything in the .launches
section and not perform it (instead loading more launches if needed) for the .loading
section. Replace the TODO
and everything below it with:
1 guard let listSection = ListSection(rawValue: selectedIndexPath.section) else {
2 assertionFailure("Invalid section")
3 return false
4}
5
6switch listSection {
7 case .launches:
8 return true
9 case .loading:
10 self.tableView.deselectRow(at: selectedIndexPath, animated: true)
11
12 if self.activeRequest == nil {
13 self.loadMoreLaunchesIfTheyExist()
14 } // else, let the active request finish loading
15
16 self.tableView.reloadRows(at: [selectedIndexPath], with: .automatic)
17
18 // In either case, don't perform the segue
19 return false
20 }
21}
Finally, even though you've told the segue system that you don't need to perform the segue for anything in the .loading
case, the compiler still doesn't know that, and it requires you to handle the .loading
case in prepare(for segue:)
.
However, your code should theoretically never reach this point, so it's a good place to use an assertionFailure
if you ever hit it during development. This both satisfies the compiler and warns you loudly and quickly if your assumption that something is handled in shouldPerformSegue
is wrong.
Add the following to the switch
statement in prepare(for segue:)
1case .loading:
2 assertionFailure("Shouldn't have gotten here!")
Now, when you build and run and scroll down to the bottom of the list, you'll see a cell you can tap to load more rows:
When you tap that cell, the rows will load and then redisplay. If you tap it several times, it reaches a point where the loading cell is no longer displayed, and the last launch was SpaceX's original FalconSat launch from Kwajalien Atoll:
Congratulations, you've loaded all of the possible launches! But when you tap one, you still get the same boring detail page.
Next, you'll make the detail page a lot more interesting by taking the ID returned by one query and passing it to another.