12.
Creating a Simple iPhone App, Part 1
Written by Tim Condon
In the previous chapters, you created an API and interacted with it using RESTed. However, users expect something a bit nicer to use TIL! The next two chapters show you how to build a simple iOS app that interacts with the API. In this chapter, you’ll learn how to create different models and get models from the database.
At the end of the two chapters, you’ll have an iOS application that can do everything you’ve learned up to this point. It will look similar to the following:
Getting started
To kick things off, download the materials for this chapter. In Terminal, go the directory where you downloaded the materials and type:
cd TILApp
swift run
This builds and runs the TIL application that the iOS app will talk to. You can use your existing TIL app if you like.
Note: This requires that your Docker container for the database is running. See Chapter 6, “Configuring a Database”, for instructions.
Next, open the TILiOS project. TILiOS contains a skeleton application that interacts with the TIL API. It’s a tab bar application with three tabs:
- Acronyms: view all acronyms, view details about an acronym and add acronyms.
- Users: view all users and create users.
- Categories: view all categories and create categories.
The project contains several empty table view controllers ready for you to configure to display data from the TIL API.
Look at the Models group in the project; it provides three model classes:
Acronym
User
Category
You may recognize the models — these match the models found API application! This shows how powerful using the same language for both client and server can be. It’s even possible to create a separate module both projects use so you don’t have to duplicate code. Because of the way Fluent represents parent-child relationships, the Acronym
is slightly different. You can solve this with a DTO like CreateAcronymData
, which the project also includes.
Viewing the acronyms
The first tab’s table displays all the acronyms. Create a new Swift file in the Utilities group called ResourceRequest.swift. Open the file and create a type to manage making resource requests:
// 1
struct ResourceRequest<ResourceType>
where ResourceType: Codable {
// 2
let baseURL = "http://localhost:8080/api/"
let resourceURL: URL
// 3
init(resourcePath: String) {
guard let resourceURL = URL(string: baseURL) else {
fatalError("Failed to convert baseURL to a URL")
}
self.resourceURL =
resourceURL.appendingPathComponent(resourcePath)
}
}
Here’s what this does:
- Define a generic
ResourceRequest
type whose generic parameter must conform toCodable
. - Set the base URL for the API. This uses
localhost
for now. Note that this requires you to disable ATS (App Transport Security) in the app’s Info.plist. This is already set up for you in the sample project. - Initialize the URL for the particular resource.
Next, you need a way to fetch all instances of a particular resource type. Add the following method after init(resourcePath:)
:
// 1
func getAll(
completion: @escaping
(Result<[ResourceType], ResourceRequestError>) -> Void
) {
// 2
let dataTask = URLSession.shared
.dataTask(with: resourceURL) { data, _, _ in
// 3
guard let jsonData = data else {
completion(.failure(.noData))
return
}
do {
// 4
let resources = try JSONDecoder()
.decode(
[ResourceType].self,
from: jsonData)
// 5
completion(.success(resources))
} catch {
// 6
completion(.failure(.decodingError))
}
}
// 7
dataTask.resume()
}
Here’s what this does:
- Define a function to get all values of the resource type from the API. This takes a completion closure as a parameter which uses Swift’s
Result
type. - Create a data task with the resource URL.
- Ensure the response returns some data. Otherwise, call the
completion(_:)
closure with the appropriate.failure
case. - Decode the response data into an array of
ResourceType
s. - Call the
completion(_:)
closure with the.success
case and return the array ofResourceType
s. - Catch any errors and return the correct failure case.
- Start the
dataTask
.
Open AcronymsTableViewController.swift and add the following under // MARK: - Properties
:
// 1
var acronyms: [Acronym] = []
// 2
let acronymsRequest =
ResourceRequest<Acronym>(resourcePath: "acronyms")
Here’s what this does:
- Declare an array of acronyms. These are the acronyms the table displays.
- Create a
ResourceRequest
for acronyms.
Getting the acronyms
Whenever the view appears on screen, the table view controller calls refresh(_:)
. Replace the implementation of refresh(_:)
with the following:
// 1
acronymsRequest.getAll { [weak self] acronymResult in
// 2
DispatchQueue.main.async {
sender?.endRefreshing()
}
switch acronymResult {
// 3
case .failure:
ErrorPresenter.showError(
message: "There was an error getting the acronyms",
on: self)
// 4
case .success(let acronyms):
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.acronyms = acronyms
self.tableView.reloadData()
}
}
}
Here’s what this does:
- Call
getAll(completion:)
to get all the acronyms. This returns a result in the completion closure. - As the request is complete, call
endRefreshing()
on the refresh control. - If the fetch fails, use the
ErrorPresenter
utility to display an alert controller with an appropriate error message. - If the fetch succeeds, update the
acronyms
array from the result and reload the table.
Displaying acronyms
Still in AcronymsTableViewController.swift, update tableView(_:numberOfRowsInSection:)
to return the correct number of acronyms by replacing return 1
with the following:
return acronyms.count
Next, update tableView(_:cellForRowAt:)
to display the acronyms in the table. Add the following before return cell
:
let acronym = acronyms[indexPath.row]
cell.textLabel?.text = acronym.short
cell.detailTextLabel?.text = acronym.long
This sets the title and subtitle text to the acronym short and long properties for each cell.
Build and run and you’ll see your table populated with acronyms from the database:
Viewing the users
Viewing all the users follows a similar pattern. Most of the view controller is already set up. Open UsersTableViewController.swift and under:
var users: [User] = []
add the following:
let usersRequest =
ResourceRequest<User>(resourcePath: "users")
This creates a ResourceRequest
to get the users from the API. Next, replace the implementation of refresh(_:)
with the following:
// 1
usersRequest.getAll { [weak self] result in
// 2
DispatchQueue.main.async {
sender?.endRefreshing()
}
switch result {
// 3
case .failure:
ErrorPresenter.showError(
message: "There was an error getting the users",
on: self)
// 4
case .success(let users):
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.users = users
self.tableView.reloadData()
}
}
}
Here’s what this does:
- Call
getAll(completion:)
to get all the users. This returns a result in the completion closure. - As the request is complete, call
endRefreshing()
on the refresh control. - If the fetch fails, use the
ErrorPresenter
utility to display an alert view with an appropriate error message. - If the fetch succeeds, update the
users
array from the result and reload the table.
Build and run. Go to the Users tab and you’ll see the table populated with users from your database:
Viewing the categories
Follow a similar pattern to view all the categories. Open CategoriesTableViewController.swift and under:
var categories: [Category] = []
add the following:
let categoriesRequest =
ResourceRequest<Category>(resourcePath: "categories")
This sets up a ResourceRequest
to get the categories from the API. Next, replace the implementation of refresh(_:)
with the following:
// 1
categoriesRequest.getAll { [weak self] result in
// 2
DispatchQueue.main.async {
sender?.endRefreshing()
}
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):
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.categories = categories
self.tableView.reloadData()
}
}
}
Here’s what this does:
- Call
getAll(completion:)
to get all the categories. This returns a result in the completion closure. - As the request is complete, call
endRefreshing()
on the refresh control. - If the fetch fails, use the
ErrorPresenter
utility to display an alert view with an appropriate error message. - If the fetch succeeds, update the
categories
array from the result and reload the table.
Build and run. Go to the Categories tab and you’ll see the table populated with categories from the TIL application:
Creating users
In the TIL API, you must have a user to create acronyms, so set up that flow first. Open ResourceRequest.swift and add a new method at the bottom of ResourceRequest
to save a model:
// 1
func save<CreateType>(
_ saveData: CreateType,
completion: @escaping
(Result<ResourceType, ResourceRequestError>) -> Void
) where CreateType: Codable {
do {
// 2
var urlRequest = URLRequest(url: resourceURL)
// 3
urlRequest.httpMethod = "POST"
// 4
urlRequest.addValue(
"application/json",
forHTTPHeaderField: "Content-Type")
// 5
urlRequest.httpBody =
try JSONEncoder().encode(saveData)
// 6
let dataTask = URLSession.shared
.dataTask(with: urlRequest) { data, response, _ in
// 7
guard
let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200,
let jsonData = data
else {
completion(.failure(.noData))
return
}
do {
// 8
let resource = try JSONDecoder()
.decode(ResourceType.self, from: jsonData)
completion(.success(resource))
} catch {
// 9
completion(.failure(.decodingError))
}
}
// 10
dataTask.resume()
// 11
} catch {
completion(.failure(.encodingError))
}
}
Here’s what the new method does:
- Declare a method
save(_:completion:)
that takes a genericCodable
type to save and a completion handler that takes the save result. This uses a generic type instead ofResourceRequest
because the saveAcronym
API usesCreateAcronymData
instead ofAcronym
. - Create a
URLRequest
for the save request. - Set the HTTP method for the request to POST.
- Set the Content-Type header for the request to application/json so the API knows there’s JSON data to decode.
- Set the request body as the encoded save data.
- Create a data task with the request.
- Ensure there’s an HTTP response. Check the response status is 200 OK, the code returned by the API upon a successful save. Ensure there’s data in the response body.
- Decode the response body into the resource type. Call the completion handler with a success result.
- Catch a decode error and call the completion handler with a failure result.
- Start the data task.
- Catch any encoding errors from
try JSONEncoder().encode(resourceToSave)
and call the completion handler with a failure result.
Next, open CreateUserTableViewController.swift and replace the implementation of save(_:)
with the following:
// 1
guard
let name = nameTextField.text,
!name.isEmpty
else {
ErrorPresenter
.showError(message: "You must specify a name", on: self)
return
}
// 2
guard
let username = usernameTextField.text,
!username.isEmpty
else {
ErrorPresenter.showError(
message: "You must specify a username",
on: self)
return
}
// 3
let user = User(name: name, username: username)
// 4
ResourceRequest<User>(resourcePath: "users")
.save(user) { [weak self] result in
switch result {
// 5
case .failure:
let message = "There was a problem saving the user"
ErrorPresenter.showError(message: message, on: self)
// 6
case .success:
DispatchQueue.main.async { [weak self] in
self?.navigationController?
.popViewController(animated: true)
}
}
}
Here’s what this does:
- Ensure the name text field contains a non-empty string.
- Ensure the username text field contains a non-empty string.
- Create a new user from the provided data.
- Create a
ResourceRequest
forUser
and callsave(_:completion:)
. - If the save fails, display an error message.
- If the save succeeds, return to the previous view: the users table.
Build and run. Go to the Users tab and tap the + button to open the Create User screen. Fill in the two fields and tap Save.
If the save succeeds, the screen closes and the new user appears in the table:
Creating acronyms
Now that you have the ability to create users, it’s time to implement creating acronyms. After all, what good is an acronym dictionary app if you can’t add to it.
Selecting users
When you create an acronym with the API, you must provide a user ID. Asking a user to remember and input a UUID isn’t a good user experience! The iOS app should allow a user to select a user by name.
Open CreateAcronymTableViewController.swift and create a new method under viewDidLoad()
to populate the User cell in the create acronym form with a default user:
func populateUsers() {
// 1
let usersRequest =
ResourceRequest<User>(resourcePath: "users")
usersRequest.getAll { [weak self] result in
switch result {
// 2
case .failure:
let message = "There was an error getting the users"
ErrorPresenter
.showError(message: message, on: self) { _ in
self?.navigationController?
.popViewController(animated: true)
}
// 3
case .success(let users):
DispatchQueue.main.async { [weak self] in
self?.userLabel.text = users[0].name
}
self?.selectedUser = users[0]
}
}
}
Here’s what this does:
- Get all users from the API.
- Show an error if the request fails. Return from the create acronym view when the user dismisses the alert controller. This uses the
dismissAction
onshowError(message:on:dismissAction:)
. - If the request succeeds, set the user field to the first user’s name and update
selectedUser
.
At the end of viewDidLoad()
add the following:
populateUsers()
Your app’s user can tap the USER cell to select a different user for creating an acronym. This gesture opens the Select A User screen.
Open SelectUserTableViewController.swift. Under:
var users: [User] = []
add the following:
var selectedUser: User
This property holds the selected user. Next, in init?(coder:selectedUser:)
assign the provided user to the new property before super.init(coder: coder)
:
self.selectedUser = selectedUser
Next, add the following implementation to loadData()
so the table displays the users when the view loads:
// 1
let usersRequest =
ResourceRequest<User>(resourcePath: "users")
usersRequest.getAll { [weak self] result in
switch result {
// 2
case .failure:
let message = "There was an error getting the users"
ErrorPresenter
.showError(message: message, on: self) { _ in
self?.navigationController?
.popViewController(animated: true)
}
// 3
case .success(let users):
self?.users = users
DispatchQueue.main.async { [weak self] in
self?.tableView.reloadData()
}
}
}
Here’s what this does:
- Get all the users from the API.
- If the request fails, show an error message. Return to the previous view once a user taps dismiss on the alert.
- If the request succeeds, save the users and reload the table data.
In tableView(_:cellForRowAt:)
before return cell
add the following:
if user.name == selectedUser.name {
cell.accessoryType = .checkmark
} else {
cell.accessoryType = .none
}
This compares the current cell against the currently selected user. If they are the same, set a checkmark on that cell.
SelectUserTableViewController
uses an unwind segue to navigate back to the CreateAcronymTableViewController
when a user taps a cell.
Add the following implementation of prepare(for:)
in SelectUserTableViewController
to set the selected user for the segue:
// 1
if segue.identifier == "UnwindSelectUserSegue" {
// 2
guard
let cell = sender as? UITableViewCell,
let indexPath = tableView.indexPath(for: cell)
else {
return
}
// 3
selectedUser = users[indexPath.row]
}
Here’s what this does:
- Verify this is the expected segue.
- Get the index path of the cell that triggered the segue.
- Update
selectedUser
to the user for the tapped cell.
The unwind segue calls updateSelectedUser(_:)
in CreateAcronymTableViewController
. Open CreateAcronymTableViewController.swift and add the following implementation to the updateSelectedUser(_:)
:
// 1
guard let controller = segue.source
as? SelectUserTableViewController
else {
return
}
// 2
selectedUser = controller.selectedUser
userLabel.text = selectedUser?.name
Here’s what this does:
- Ensure the segue came from
SelectUserTableViewController
. - Update
selectedUser
with the new value and update the user label.
Finally, replace the implementation for makeSelectUserViewController(_:)
with the following:
guard let user = selectedUser else {
return nil
}
return SelectUserTableViewController(
coder: coder,
selectedUser: user)
This ensures we have a selected user and creates a SelectUserTableViewController
with that user. When a user taps the user field, the app uses the @IBSegueAction
to create the select user screen.
Build and run. In the Acronyms tab, tap + to bring up the Create An Acronym view. Tap the user row and the application opens the Select A User view, allowing you to select a user.
When you tap a user, that user is then set on the Create An Acronym page:
Saving acronyms
Now that you can successfully select a user, it’s time to implement saving the new acronym to the database. Replace the implementation of save(_:)
in CreateAcronymTableViewController.swift with the following:
// 1
guard
let shortText = acronymShortTextField.text,
!shortText.isEmpty
else {
ErrorPresenter.showError(
message: "You must specify an acronym!",
on: self)
return
}
guard
let longText = acronymLongTextField.text,
!longText.isEmpty
else {
ErrorPresenter.showError(
message: "You must specify a meaning!",
on: self)
return
}
guard let userID = selectedUser?.id else {
let message = "You must have a user to create an acronym!"
ErrorPresenter.showError(message: message, on: self)
return
}
// 2
let acronym = Acronym(
short: shortText,
long: longText,
userID: userID)
let acronymSaveData = acronym.toCreateData()
// 3
ResourceRequest<Acronym>(resourcePath: "acronyms")
.save(acronymSaveData) { [weak self] result in
switch result {
// 4
case .failure:
let message = "There was a problem saving the acronym"
ErrorPresenter.showError(message: message, on: self)
// 5
case .success:
DispatchQueue.main.async { [weak self] in
self?.navigationController?
.popViewController(animated: true)
}
}
}
Here are the steps to save the acronym:
- Ensure the user has filled in the acronym and meaning. Check the selected user is not
nil
and the user has a valid ID. - Create a new
Acronym
from the supplied data. Convert the acronym toCreateAcronymData
using thetoCreateData()
helper method. - Create a
ResourceRequest
forAcronym
and callsave(_:)
using the create data. - If the save request fails, show an error message.
- If the save request succeeds, return to the previous view: the acronyms table.
Build and run. On the Acronyms tab, tap +. Fill in the fields to create an acronym and tap Save.
The saved acronym appears in the table:
Where to go from here?
In this chapter, you learned how to interact with the API from an iOS application. You saw how to create different models and retrieve them from the API. You also learned how to manage the required relationships in a user-friendly way.
The next chapter builds upon this to view details about a single acronym. You’ll also learn how to implement the rest of the CRUD operations. Finally, you’ll see how to set up relationships between categories and acronyms.