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:

Swift
LaunchesViewController.swift
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:

Swift
LaunchesViewController.swift
1import Apollo

Then, below lastConnection, add a variable to hang on to the most recent request:

Swift
LaunchesViewController.swift
1private var activeRequest: Cancellable?

Next, add a second case to your ListSection enum:

Swift
LaunchesViewController.swift
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:

Swift
LaunchesViewController.swift
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:

Swift
LaunchesViewController.swift
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:

GraphQL
LaunchList.graphql
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:

Swift
LaunchesViewController.swift
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:

Swift
LaunchesViewController.swift
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:

Swift
LaunchesViewController.swift
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:

Swift
LaunchesViewController.swift
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:)

Swift
LaunchesViewController.swift
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:

Screenshot with loading cell

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:

List with all launches loaded and no loading cell

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.

Feedback

Edit on GitHub

Forums