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:
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):
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:
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:
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:
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:
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:
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
:
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:
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:
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 IBOutlet
s:
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:
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 ifconfigureView
is called beforeviewDidLoad
.
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:
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:
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:
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:
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":
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:
1cell.imageView?.image = nil
2cell.textLabel?.text = nil
3cell.detailTextLabel?.text = nil
Note: In a custom
UITableViewCell
, you'd do this by overridingprepareForReuse
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:
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:
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.