4. Connect your queries to your UI


Now that your app can execute queries against a GraphQL server, you can reflect the results of those queries in your UI.

Display the list of launches

Now let's add properties to display the results of the LaunchListQuery you built in the previous tutorial step.

At the top of LaunchesViewController.swift, add a new property to store the launches that the query returns:

Swift
LaunchesViewController.swift
1var launches = [LaunchListQuery.Data.Launch.Launch]()

Why the long name? Each query returns its own nested object structure to ensure that when you use the result of a particular query, you can't ask for a property that isn't present. Because this screen will be populated by the results of the LaunchListQuery, you need to display subtypes of that particular query.

Next, add an enum that helps handle dealing with sections (we'll add more items to the enum later):

Swift
LaunchesViewController.swift
1enum ListSection: Int, CaseIterable {
2  case launches
3}

Fill in required methods

Now we can update the various UITableViewDataSource methods to use the result of our query.

For numberOfSections(in:), you can use the allCases property from CaseIterable to provide the appropriate number of sections:

Swift
LaunchesViewController.swift
1override func numberOfSections(in tableView: UITableView) -> Int {
2  return ListSection.allCases.count
3}

For tableView(_:numberOfRowsInSection:), you can try instantiating a ListSection enum object. If it doesn't work, that's an invalid section, and if it does, you can switch directly on the result. In this case, you'll want to return the count of launches:

Swift
LaunchesViewController.swift
1override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
2  guard let listSection = ListSection(rawValue: section) else {
3    assertionFailure("Invalid section")
4    return 0
5  }
6        
7  switch listSection {
8  case .launches:
9    return self.launches.count
10  }
11}

For tableView(_:cellForRowAt:), you can use the existing cell dequeueing mechanism, the same section check as in tableView(_:numberOfRowsInSection), and then configure the cell based on what section it's in.

For this initial section, grab a launch out of the launches array at the index of indexPath.row, and update the textLabel to display the launch site:

Swift
LaunchesViewController.swift
1override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
2  let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
3
4  guard let listSection = ListSection(rawValue: indexPath.section) else {
5    assertionFailure("Invalid section")
6    return cell
7  }
8    
9  switch listSection {
10  case .launches:
11    let launch = self.launches[indexPath.row]
12    cell.textLabel?.text = launch.site
13  }
14    
15  return cell
16}

Your table view has all the information it needs to populate itself when the launches array has contents. Now it's time to actually get those contents from the server.

First, add a method to load the launches. You'll use a setup similar to the one you used to set this up in the AppDelegate earlier.

However, you need to make sure that a call doesn't try to call back and use elements that are no longer there, so you'll check to make sure that the LaunchesViewController hasn't been deallocated out from under you by passing in [weak self] and unwrapping self before proceeding with updating the UI.

Replace the TODO in loadLaunches with the following:

Swift
LaunchesViewController.swift
1private func loadLaunches() {
2  Network.shared.apollo
3    .fetch(query: LaunchListQuery()) { [weak self] result in
4    
5    guard let self = self else {
6      return
7    }
8
9    defer {
10      self.tableView.reloadData()
11    }
12            
13    switch result {
14    case .success(let graphQLResult):
15      // TODO
16    case .failure(let error):
17      // From `UIViewController+Alert.swift`
18      self.showAlert(title: "Network Error",
19                     message: error.localizedDescription)
20    }
21  }
22}

GraphQLResult has both a data property and an errors property. This is because GraphQL allows partial data to be returned if it's non-null.

In the example we're working with now, we could theoretically obtain a list of launches, and then an error stating that a launch with a particular ID could not be retrieved.

This is why when you get a GraphQLResult, you generally want to check both the data property (to display any results you got from the server) and the errors property (to try to handle any errors you received from the server).

Replace the // TODO in the code above with the following code to handle both data and errors:

Swift
LaunchesViewController.swift
1if let launchConnection = graphQLResult.data?.launches {
2  self.launches.append(contentsOf: launchConnection.launches.compactMap { $0 })
3}
4        
5if let errors = graphQLResult.errors {
6  let message = errors
7        .map { $0.localizedDescription }
8        .joined(separator: "\n")
9  self.showAlert(title: "GraphQL Error(s)",
10                 message: message)    
11}

Finally, you'd normally need to actually call the method you just added to kick off the call to the network when the view is first loaded. Take a look at your viewDidLoad and note that it's already set up to call loadLaunches:

Swift
LaunchesViewController.swift
1override func viewDidLoad() {
2  super.viewDidLoad()
3  self.loadLaunches()
4}

Build and run the application. After the query completes, a list of launch sites appears:

List of launch sites

However, if you attempt to tap one of the rows, the app displays the detail view controller with the placeholder text you can see in the storyboard, instead of any actual information about the launch:

Placeholder detail content

To send that information through, you need to build out the LaunchesViewController's prepareForSegue method, and have a way for that method to pass the DetailViewController information about the launch.

Pass information to the detail view

Let's update the DetailViewController to be able to handle information about a launch.

Open DetailViewController.swift and note that there's a property below the list of IBOutlets:

Swift
DetailViewController.swift
1var launchID: GraphQLID? {
2  didSet {
3    self.loadLaunchDetails()
4  }
5}

This settable property allows the LaunchesViewController to pass along the identifier for the selected launch. The identifier will be used later to load more details about the launch.

For now, update the configureView() method to use this property (if it's there) to show the launch's identifier:

Swift
DetailViewController.swift
1func configureView() {
2  // Update the user interface for the detail item.
3  guard
4    let label = self.missionNameLabel,
5    let id = self.launchID else {
6      return
7  }
8
9  label.text = "Launch \(id)"
10  // TODO: Adjust UI based on whether a trip is booked or not
11}

Note: You're also unwrapping the missionNameLabel because even though it's an Implicitly Unwrapped Optional, it won't be present if configureView is called before viewDidLoad.

Next, back in LaunchesViewController.swift, update the prepareForSegue method to obtain the most recently selected row and pass its corresponding launch details to the detail view controller. Replace the TODO and below with the following:

Swift
LaunchesViewController.swift
1guard let selectedIndexPath = self.tableView.indexPathForSelectedRow else {
2  // Nothing is selected, nothing to do
3  return
4}
5    
6guard let listSection = ListSection(rawValue: selectedIndexPath.section) else {
7  assertionFailure("Invalid section")
8  return
9}
10    
11switch listSection {
12case .launches:
13  guard
14    let destination = segue.destination as? UINavigationController,
15    let detail = destination.topViewController as? DetailViewController else {
16      assertionFailure("Wrong kind of destination")
17      return
18  }
19
20  let launch = self.launches[selectedIndexPath.row]
21  detail.launchID = launch.id
22  self.detailViewController = detail
23}

Build and run, and tap on any of the launches. You'll now see the launch ID for the selected launch when you land on the page:

Empty screen with launch ID

The app is working! However, it doesn't provide much useful information. Let's fix that.

Add more info to the list view

Go back to LaunchList.graphql. Your query is already fetching most of the information you want to display, but it would be nice to display both the name of the mission and an image of the patch.

Looking at the schema in Sandbox Explorer , you can see that Launch has a property of mission, which allows you to get details of the mission. A mission has both a name and a missionPatch property, and the missionPatch can optionally take a parameter about what size something needs to be.

Because loading a table view with large images can impact performance, ask for the name and a SMALL mission patch. Update your query to look like the following:

GraphQL
LaunchList.graphql
1query LaunchList {
2  launches {
3    hasMore
4    cursor
5    launches {
6      id
7      site
8      mission {
9        name
10        missionPatch(size: SMALL)
11      }
12    }
13  }
14}

When you recompile, if you look in API.swift, you'll see a new nested type, Mission, with the two properties you requested.

Go back to LaunchesViewController.swift and add the following import of one of the libraries that was already in your project to the top of the file:

Swift
LaunchesViewController.swift
1import SDWebImage

You'll use this shortly to load an image based on a URL.

Next, open up your Asset your Asset Catalog, Assets.xcassets. You'll see an image named "Placeholder":

image in asset catalog

You'll use this image as a placeholder to show while the mission patch images are loading.

Now go back to LaunchesViewController.swift. In tableView(cellForRowAt:), once the cell is loaded, add the following code to help make sure that before the cell is configured, it clears out any stale data:

Swift
LaunchesViewController.swift
1cell.imageView?.image = nil
2cell.textLabel?.text = nil
3cell.detailTextLabel?.text = nil

Note: In a custom UITableViewCell, you'd do this by overriding prepareForReuse rather than resetting directly in the data source. However, since you're using a stock cell, you have to do it here.

Next, in the same method, go down to where you're setting up the cell based on the section. Update the code to use the launch mission name as the primary text label, the launch site as the detail text label, and to load the mission patch if it exists:

Swift
LaunchesViewController.swift
1switch listSection {
2case .launches:
3  let launch = self.launches[indexPath.row]
4  cell.textLabel?.text = launch.mission?.name
5  cell.detailTextLabel?.text = launch.site
6    
7  let placeholder = UIImage(named: "placeholder")!
8    
9  if let missionPatch = launch.mission?.missionPatch {
10    cell.imageView?.sd_setImage(with: URL(string: missionPatch)!, placeholderImage: placeholder)
11  } else {
12    cell.imageView?.image = placeholder
13  }
14}

Build and run the application, and you will see all the information for current launches populate:

Final launch list

If you scroll down, you'll see the list includes only about 20 launches. This is because the list of launches is paginated, and you've only fetched the first page.

Now it's time to learn how to use a cursor-based loading system to load the entire list of launches .