One of the most traditional ways of displaying data is through a table. Think about having to create one in a document, working with a spreadsheet, or (for the experienced dev) a database. That’s why the Table
component is a powerful tool in the SwiftUI framework. It doesn’t take much code to get data beautifully laid out in our app.
A little more here and there and we can quickly add on features and styles to enhance the UX. We’ll explore that in Part II
Table
does have one major drawback: full-functionality is available on macOS and iPadOS, only. When using Table
on iOS, it only shows the first column, nothing else. We’love discuss this more in Part II along with alternatives to consider.
But for now, let’s jump into building a Table
!
Setup
Project
Because Table
works best in macOS and iPadOS, start by opening Xcode, then selecting New Project -> macOS App -> UI set to SwiftUI. You can name the project anything you’d prefer. But since I’ll be pulling a list of Spells from an open Wizard World API, I’ll name mine “TableOfSpells”.
Data Source
As mentioned, we’ll be hitting a free and open Wizard World API (Swagger). Since we want to jump into using a Table
, here is the Model and APICaller we can use to retrieve our data:
struct Spell: Codable, Identifiable, Hashable {
let id, name, effect, type, light: String
let canBeVerbal: Bool?
let incantation, creator: String?
}
typealias Spells = [Spell]
class WizardWorldAPICaller {
static func fetchSpells() async throws -> Spells {
var fetchedData: Spells = []
let url = URL(string: "https://wizard-world-api.herokuapp.com/Spells")
let request = URLRequest(url: url!)
let (data, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response.http, httpResponse.isSuccessful {
fetchedData = try JSONDecoder().decode(Spells.self, from: data)
}
return fetchedData
}
}
extension URLResponse {
var http: HTTPURLResponse? {
return self as? HTTPURLResponse
}
}
extension HTTPURLResponse {
var isSuccessful: Bool {
return 200 ... 299 ~= statusCode
}
}
Quick breakdown of the above. WizardWorldAPICaller
is a simple class with a single static
function fetchSpells
. This async
function take the Spells API call, makes the request, and, if successful, returns the decoded data as an array of Spell
.
Spell
is our Model and maps to what the API provides us. We ensure we conform to a) Codable
for JSON decoding, b) Identifiable
for Table
to tell different spells apart from each other, and c) Hashable
for sorting comparisons.
Lastly, I created a typealias
called Spells
that gives us an alternative way of representing [Spell]
.
Creating a Table of Spells
In ContentView
, we’ll first add @State var spells: Spells?
. We’ll then modify body
, place a Group
, and create a conditional based on whether we have any spells loaded:
Group {
if let spells {
...
} else {
ContentUnavailableView("Casting the Fetch Spell!",
systemImage: "wand.and.stars")
}
}
With our control flow in place (including an enchanted empty state), it’s time to add our Table
for the loaded state. For now, we’ll set it up with a single column:
Table(spells) {
TableColumn("Name", value: \.name)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
Here, we declare our Table
, set the data to our stateful spells
property, and in the columns
closure, we declare a single column for the name of our spells. In TableColumn
, we provide a title for our column (“Name”), and then we connect the value of the column to a specific property on Spell
using a KeyPath
(“.name”).
Lastly, let’s create a task
to fetch our spells. Add the task
modifier to Group
. Wrapped in a do/catch
, we’ll call our static
call and set spells
to it.
.task {
do {
spells = try await WizardWorldAPICaller.fetchSpells()
} catch {
print("Error")
}
}
Run our app, and as soon as our data loads, we should see our spells:
Customizing Columns
It’s exciting to see the names of our spells loaded in our Table
, but it would be great to see all the other data about our spells. We could add many of them similarly to how we setup the name column (e.g. TableColumn("Effect", value: \.effect)
).
However, we have a couple of data points that are Optional
. And one of them could even be represented in a better aesthetic. Let’s start with “Incantation”.
TableColumn("Incantation"){ spell in
if let incantation = spell.incantation{
Text(incantation)
} else {
Text("-")
}
}
Because incantation
can be nil
, we can declare it’s TableColumn
by setting the title and then setting a content
closure. The closure provides the a representation of the current Spell
, and from there we are given the freedom to decide what should be shown. In this case, we do an if let
on incantation
. If it exists, we display it plainly. If it is nil
, we show “-“.
We have a similar situation with another, property: canBeVerbal
. We can implement it similarly to what we did with incantation
:
TableColumn("Verbal"){ spell in
if let verbal = spell.canBeVerbal {
Text(verbal.description)
} else {
Text("-")
}
}
Since this is a boolean value, though, it could be helpful to display this column differently. Instead of showing Text
, we can show a green checkmark if true, and a red x if false:
Image(systemName: verbal ?
"checkmark.circle.fill" :
"x.circle.fill")
.foregroundStyle(verbal ?
Color.green :
Color.red)
This gives what would otherwise be a trivial data point something easier for a user to pickup and distinguish by.
Let’s go ahead and add columns for all the data in Spell
:
Table(spells) {
TableColumn("Name", value: \.name)
TableColumn("Incantation"){ spell in
if let incantation = spell.incantation{
Text(incantation)
} else {
Text("-")
}
}
TableColumn("Effect", value: \.effect)
TableColumn("Verbal"){ spell in
if let verbal = spell.canBeVerbal {
Image(systemName: verbal ?
"checkmark.circle.fill" :
"x.circle.fill")
.foregroundStyle(verbal ?
Color.green :
Color.red)
} else {
Text("-")
}
}
TableColumn("Type", value: \.type)
TableColumn("Light", value: \.light)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
Now when we run our app, we see all the spells and their data across our Table
:
Coming Up in Part II
Now that we have our spells in a table, it would be great if we could provide added functionality. This way users (read: Wizards and Witches) can better study them.
In Part II, we’ll cover adding sorting, selection, and menus. We’ll also discuss the limitations with iOS and how we can handle them with alternatives.
You can find the code here!