Home iOS & Swift Books Server-Side Swift with Vapor

13
Creating a Simple iPhone App, Part 2 Written by Tim Condon

In the previous chapter, you created an iPhone application that can create users and acronyms. In this chapter, you’ll expand the app to include viewing details about a single acronym. You’ll also learn how to perform the final CRUD operations: edit and delete. Finally, you’ll learn how to add acronyms to categories.

Note: This chapter expects you have a TIL Vapor application running. It also expects you’ve completed the iOS app from the previous chapter. If not, grab the starter projects and pick up from there. See Chapter 12, “Creating a Simple iPhone App, Part 1”, for details on how to run the Vapor application.

Getting started

In the previous chapter, you learned how to view all the acronyms in a table. Now, you want to show all the information about a single acronym when a user taps a table cell. The starter project contains the necessary plumbing; you simply need to implement the details.

Open AcronymsTableViewController.swift. Replace the implementation for makeAcronymsDetailTableViewController(_:) with the following:

// 1
guard let indexPath = tableView.indexPathForSelectedRow else {
  return nil
}
// 2
let acronym = acronyms[indexPath.row]
// 3
return AcronymDetailTableViewController(
  coder: coder, 
  acronym: acronym)

You run this code when a user taps an acronym. The code does the following:

  1. Ensure that there’s a selected index path.
  2. Get the acronym corresponding to the tapped row.
  3. Create an AcronymDetailTableViewController using the selected acronym.

Create a new Swift file called AcronymRequest.swift in the Utilities group. Open the new file and create a new type to represent an acronym resource request:

struct AcronymRequest {
  let resource: URL

  init(acronymID: UUID) {
    let resourceString =
      "http://localhost:8080/api/acronyms/\(acronymID)"
    guard let resourceURL = URL(string: resourceString) else {
      fatalError("Unable to createURL")
    }
    self.resource = resourceURL
  }
}

This sets the resource property to the URL for that acronym. At the bottom of AcronymRequest, add a method to get the acronym’s user:

func getUser(
  completion: @escaping (
    Result<User, ResourceRequestError>
  ) -> Void
) {
  // 1
  let url = resource.appendingPathComponent("user")

  // 2
  let dataTask = URLSession.shared
    .dataTask(with: url) { data, _, _ in
      // 3
      guard let jsonData = data else {
        completion(.failure(.noData))
        return
      }
      do {
      // 4
        let user = try JSONDecoder()
          .decode(User.self, from: jsonData)
        completion(.success(user))
      } catch {
        // 5
        completion(.failure(.decodingError))
      }
    }
  // 6
  dataTask.resume()
}

Here’s what this does:

  1. Create the URL to get the acronym’s user.
  2. Create a data task using the shared URLSession.
  3. Check the response contains a body, otherwise fail with the appropriate error.
  4. Decode the response body into a User object and call the completion handler with the success result.
  5. Catch any decoding errors and call the completion handler with the failure result.
  6. Start the network task.

Next, below getUser(completion:), add the following method to get the acronym’s categories:

func getCategories(
  completion: @escaping (
    Result<[Category], ResourceRequestError>
  ) -> Void
) {
  let url = resource.appendingPathComponent("categories")
  let dataTask = URLSession.shared
    .dataTask(with: url) { data, _, _ in
      guard let jsonData = data else {
        completion(.failure(.noData))
        return
      }
      do {
        let categories = try JSONDecoder()
          .decode([Category].self, from: jsonData)
        completion(.success(categories))
      } catch {
        completion(.failure(.decodingError))
      }
    }
  dataTask.resume()
}

This works exactly like the other request methods in the project, decoding the response body into [Category].

Open AcronymDetailTableViewController.swift and add the following implementation to getAcronymData():

// 1
guard let id = acronym.id else {
  return
}

// 2
let acronymDetailRequester = AcronymRequest(acronymID: id)
// 3
acronymDetailRequester.getUser { [weak self] result in
  switch result {
  case .success(let user):
    self?.user = user
  case .failure:
    let message =
      "There was an error getting the acronym’s user"
    ErrorPresenter.showError(message: message, on: self)
  }
}

// 4
acronymDetailRequester.getCategories { [weak self] result in
  switch result {
  case .success(let categories):
    self?.categories = categories
  case .failure:
    let message =
      "There was an error getting the acronym’s categories"
    ErrorPresenter.showError(message: message, on: self)
  }
}

Here’s the play by play:

  1. Ensure the acronym has a non-nil ID.

  2. Create an AcronymRequest to gather information.

  3. Get the acronym’s user. If the request succeeds, update the user property. Otherwise, display an appropriate error message.

  4. Get the acronym’s categories. If the request succeeds, update the categories property. Otherwise, display an appropriate error message.

The project displays acronym data in a table view with four sections. These are:

  • the acronym
  • its meaning
  • its user
  • its categories

Build and run. Tap an acronym in the Acronyms table and the application will show the detail view with all the information:

Editing acronyms

To edit an acronym, users tap the Edit button in the Acronym detail view. Open CreateAcronymTableViewController.swift. The acronym property exists to store the current acronym. If this property is set — by prepare(for:sender:) in AcronymDetailTableViewController.swift — then the user is editing the acronym. Otherwise, the user is creating a new acronym.

In viewDidLoad(), replace populateUsers() with:

if let acronym = acronym {
  acronymShortTextField.text = acronym.short
  acronymLongTextField.text = acronym.long
  userLabel.text = selectedUser?.name
  navigationItem.title = "Edit Acronym"
} else {
  populateUsers()
}

If the acronym is set, you’re in edit mode, so populate the display fields with the correct values and update the view’s title. If you’re in create mode, call populateUsers() as before.

To update an acronym, you make a PUT request to the acronym’s resource in the API. Open AcronymRequest.swift and add a method at the bottom of AcronymRequest to update an acronym:

func update(
  with updateData: CreateAcronymData,
  completion: @escaping (
    Result<Acronym, ResourceRequestError>
  ) -> Void
) {
  do {
    // 1
    var urlRequest = URLRequest(url: resource)
    urlRequest.httpMethod = "PUT"
    urlRequest.httpBody = try JSONEncoder().encode(updateData)
    urlRequest.addValue(
      "application/json",
      forHTTPHeaderField: "Content-Type")
    let dataTask = URLSession.shared
      .dataTask(with: urlRequest) { data, response, _ in
        // 2
        guard
          let httpResponse = response as? HTTPURLResponse,
          httpResponse.statusCode == 200,
          let jsonData = data
          else {
            completion(.failure(.noData))
            return
        }
        do {
        // 3
          let acronym = try JSONDecoder()
            .decode(Acronym.self, from: jsonData)
          completion(.success(acronym))
        } catch {
          completion(.failure(.decodingError))
        }
      }
    dataTask.resume()
  } catch {
    completion(.failure(.encodingError))
  }
}

This method works like other requests you’ve built. The differences are:

  1. Create and configure a URLRequest. The method must be PUT and the body contains the encoded CreateAcronymData. Set the correct header so the Vapor application knows the request contains JSON.
  2. Ensure the response is an HTTP response, the status code is 200 and the response has a body.
  3. Decode the response body into an Acronym and call the completion handler with a success result.

Return to CreateAcronymTableViewController.swift. Inside save(_:) after:

let acronymSaveData = acronym.toCreateData()

Replace the rest of the function with the following:

if self.acronym != nil {
  // update code goes here
} else {
  ResourceRequest<Acronym>(resourcePath: "acronyms")
    .save(acronymSaveData) { [weak self] result in
      switch result {
      case .failure:
        let message = "There was a problem saving the acronym"
        ErrorPresenter.showError(message: message, on: self)
      case .success:
        DispatchQueue.main.async { [weak self] in
          self?.navigationController?
            .popViewController(animated: true)
        }
      }
    }
}

This checks the class’s acronym property to see if it has been set. If the property is nil, then the user is saving a new acronym so the function performs the same save request as before.

Inside the if block after // update code goes here, add the following code to update an acronym:

// 1
guard let existingID = self.acronym?.id else {
  let message = "There was an error updating the acronym"
  ErrorPresenter.showError(message: message, on: self)
  return
}
// 2
AcronymRequest(acronymID: existingID)
  .update(with: acronymSaveData) { result in
    switch result {
    // 3
    case .failure:
      let message = "There was a problem saving the acronym"
      ErrorPresenter.showError(message: message, on: self)
    case .success(let updatedAcronym):
      self.acronym = updatedAcronym
      DispatchQueue.main.async { [weak self] in
        // 4
        self?.performSegue(
          withIdentifier: "UpdateAcronymDetails",
          sender: nil)
      }
    }
  }

Here’s what the update code does:

  1. Ensure the acronym has a valid ID.
  2. Create an AcronymRequest and call update(with:completion:).
  3. If the update fails, display an error message.
  4. If the update succeeds, store the updated acronym and trigger an unwind segue to the AcronymsDetailTableViewController.

Next, open AcronymsDetailTableViewController.swift and add the following implementation to the end of prepare(for:sender:):

if segue.identifier == "EditAcronymSegue" {
  // 1.
  guard
    let destination = segue.destination
      as? CreateAcronymTableViewController else {
    return
  }

  // 2.
  destination.selectedUser = user
  destination.acronym = acronym
}

Here’s what this does:

  1. Ensure the destination is a CreateAcronymTableViewController.
  2. Set the selectedUser and acronym properties on the destination.

Next, add the following implementation to the unwind segue’s target, updateAcronymDetails(_:):

guard let controller = segue.source
  as? CreateAcronymTableViewController else {
  return
}

user = controller.selectedUser
if let acronym = controller.acronym {
  self.acronym = acronym
}

This captures the updated acronym, if set, and user, triggering an update to its own view.

Build and run. Tap an acronym to open the acronym detail view and tap Edit. Change the details and tap Save. The view will return to the acronyms details page with the updated values:

Deleting acronyms

The final CRUD operation to implement is D: delete. Open AcronymRequest.swift and add the following method after update(with:completion:):

func delete() {
  // 1
  var urlRequest = URLRequest(url: resource)
  urlRequest.httpMethod = "DELETE"
  // 2
  let dataTask = URLSession.shared.dataTask(with: urlRequest)
  dataTask.resume()
}

Here’s what delete() does:

  1. Create a URLRequest and set the HTTP method to DELETE.
  2. Create a data task for the request using the shared URLSession and send the request. This ignores the result of the request.

Open AcronymsTableViewController.swift. To enable deletion of a table row, add the following after tableView(_:cellForRowAt:):

override func tableView(
  _ tableView: UITableView,
  commit editingStyle: UITableViewCell.EditingStyle,
  forRowAt indexPath: IndexPath
) {
  if let id = acronyms[indexPath.row].id {
    // 1
    let acronymDetailRequester = AcronymRequest(acronymID: id)
    acronymDetailRequester.delete()
  }

  // 2
  acronyms.remove(at: indexPath.row)
  // 3
  tableView.deleteRows(at: [indexPath], with: .automatic)
}

This enables “swipe-to-delete” functionality on the table view. Here’s how it works:

  1. If the acronym has a valid ID, create an AcronymRequest for the acronym and call delete() to delete the acronym in the API.
  2. Remove the acronym from the local array of acronyms.
  3. Remove the acronym’s row from the table view.

Build and run. Swipe left on an acronym and the Delete button will appear. Tap Delete to remove the acronym.

If you pull-to-refresh the table view, the acronym doesn’t reappear as the application has deleted it in the API:

Creating categories

Setting up the create category table is like setting up the create users table. Open CreateCategoryTableViewController.swift and replace the implementation of save(_:) with:

// 1
guard
  let name = nameTextField.text,
  !name.isEmpty 
  else {
    ErrorPresenter.showError(
      message: "You must specify a name", on: self)
    return
}

// 2
let category = Category(name: name)
// 3
ResourceRequest<Category>(resourcePath: "categories")
  .save(category) { [weak self] result in
    switch result {
    // 5
    case .failure:
      let message = "There was a problem saving the category"
      ErrorPresenter.showError(message: message, on: self)
    // 6
    case .success:
      DispatchQueue.main.async { [weak self] in
        self?.navigationController?
          .popViewController(animated: true)
      }
    }
  }

This is just like the save(_:) method for saving a user. Build and run. On the Categories tab, tap the + button to open the Create Category screen. Fill in a name and tap Save. If the save is successful, the screen will close and the new category will appear in the table:

Adding acronyms to categories

The finish up, you must implement the ability to add acronyms to categories. Add a new table row section to the acronym detail view that contains a button to add the acronym to a category.

Open AcronymsDetailTableViewController.swift. Change the return statement in numberOfSections(in:) to:

return 5

In tableView(_:cellForRowAt:), add a new case to the switch before default:

// 1
case 4:
  cell.textLabel?.text = "Add To Category"

Next, add the following just before return cell:

// 2
if indexPath.section == 4 {
  cell.selectionStyle = .default
  cell.isUserInteractionEnabled = true
} else {
  cell.selectionStyle = .none
  cell.isUserInteractionEnabled = false
}

These steps:

  1. Set the table cell title to “Add To Category” if the cell is in the new section.
  2. If the cell is in the new section, enable selection on the cell, otherwise disable selection. This allows a user to select the new row but no others.

The starter project already contains the view controller for this new table view: AddToCategoryTableViewController.swift. The class defines three key properties:

  • categories: an array for all the categories retrieved from the API.
  • selectedCategories: the categories selected for the acronym.
  • acronym: the acronym to add to categories.

The class also contains an extension for the UITableViewDataSource methods. tableView(_:cellForRowAt:) sets the accessoryType on the cell if the category is in the selectedCategories array.

Open AddToCategoryTableViewController.swift and add the following implementation to loadData() to get all the categories from the API:

// 1
let categoriesRequest =
  ResourceRequest<Category>(resourcePath: "categories")
// 2
categoriesRequest.getAll { [weak self] result in
  switch result {
  // 3
  case .failure:
    let message =
      "There was an error getting the categories"
    ErrorPresenter.showError(message: message, on: self)
  // 4
  case .success(let categories):
    self?.categories = categories
    DispatchQueue.main.async { [weak self] in
      self?.tableView.reloadData()
    }
  }
}

Here’s what this does:

  1. Create a ResourceRequest for categories.
  2. Get all the categories from the API.
  3. If the fetch fails, show an error message.
  4. If the fetch succeeds, populate the categories array and reload the table data.

Open AcronymRequest.swift and add the following method after delete():

func add(
  category: Category,
  completion: @escaping (Result<Void, CategoryAddError>) -> Void
) {
  // 1
  guard let categoryID = category.id else {
    completion(.failure(.noID))
    return
  }
  // 2
  let url = resource
    .appendingPathComponent("categories")
    .appendingPathComponent("\(categoryID)")
  // 3
  var urlRequest = URLRequest(url: url)
  urlRequest.httpMethod = "POST"
  // 4
  let dataTask = URLSession.shared
    .dataTask(with: urlRequest) { _, response, _ in
      // 5
      guard
        let httpResponse = response as? HTTPURLResponse,
        httpResponse.statusCode == 201
        else {
          completion(.failure(.invalidResponse))
          return
      }
      // 6
      completion(.success(()))
    }
  dataTask.resume()
}

Here’s what this does:

  1. Ensure the category has a valid ID, otherwise call the completion handler with the failure case and appropriate error. This uses CategoryAddError which is part of the starter project.
  2. Build the URL for the request.
  3. Create a URLRequest and set the HTTP method to POST.
  4. Create a data task from the shared URLSession.
  5. Ensure the response is an HTTP response and the response status is 201 Created. Otherwise, call the completion handler with the right failure case.
  6. Call the completion handler with the success case.

Open AddToCategoryTableViewController.swift and add the following extension at the end of the file:

// MARK: - UITableViewDelegate
extension AddToCategoryTableViewController {
  override func tableView(
    _ tableView: UITableView,
    didSelectRowAt indexPath: IndexPath
  ) {
    // 1
    let category = categories[indexPath.row]
    // 2
    guard let acronymID = acronym.id else {
      let message = """
        There was an error adding the acronym
        to the category - the acronym has no ID
        """
      ErrorPresenter.showError(message: message, on: self)
      return
    }
    // 3
    let acronymRequest = AcronymRequest(acronymID: acronymID)
    acronymRequest
      .add(category: category) { [weak self] result in
        switch result {
        // 4
        case .success:
          DispatchQueue.main.async { [weak self] in
            self?.navigationController?
              .popViewController(animated: true)
          }
        // 5
        case .failure:
          let message = """
            There was an error adding the acronym
            to the category
            """
          ErrorPresenter.showError(message: message, on: self)
        }
      }
  }
}

Here’s what this function does:

  1. Get the category the user has selected.
  2. Ensure the acronym has a valid ID; otherwise, show an error message.
  3. Create an AcronymRequest to add the acronym to the category.
  4. If the request succeeds, return to the previous view.
  5. If the request fails, show an error message.

Finally, open AcronymDetailTableViewController.swift to set up AddToCategoryTableViewController. Change the implementation of makeAddToCategoryController(_:) to the following:

AddToCategoryTableViewController(
  coder: coder, 
  acronym: acronym, 
  selectedCategories: categories)

This returns an AddToCategoryTableViewController created with the current acronym and its categories.

Build and run. Tap an acronym and, in the detail view, a new row labeled Add To Category now appears. Tap this cell and the categories list appears with already selected categories marked.

Select a new category and the view closes. The acronym detail view will now have the new category in its list:

Where to go from here?

This chapter has shown you how to build an iOS application that interacts with the Vapor API. The application isn’t fully-featured, however, and you could improve it. For example, you could add a category information view that displays all the acronyms for a particular category.

The next section of the book shows you how to build another type of client: a website.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2021 Razeware LLC