Day 49
Day 49 êŽë š
Project 10, part 1
Youâve had a couple of days away from following projects, and I hope you used them to review what youâve learned, write your own code for a while, and reflect on what was said in yesterdayâs videos.
As the late, great Zig Ziglar said, âthere are two sure ways to fail: think and never do, or do and never think.â Well, today is very much back to being a âdoâ day: we have a new project to build, which in turns means some new techniques to learn.
In particular, weâre going to go even deeper into Codable
so you can get a feel for whatâs happening behind the scenes when Swift can synthesize functionality for us. This is another step towards demystifying Swift and SwiftUI â itâs great to be able to rely on our tools to do work for us, but itâs also important to understand what they are doing.
Today you have five topics to work through, in which youâll learn about custom Codable
implementations, URLSession
, the disabled()
modifier, and more.
Cupcake Corner: Introduction
Cupcake Corner: Introduction
In this project weâre going to build a multi-screen app for ordering cupcakes. This will use a couple of forms, which are old news for you, but youâre also going to learn how to make classes conform to Codable
when they have @Published
properties, how to send and receive the order data from the internet, how to validate forms, and more.
As we continue to dig deeper and deeper into Codable
, I hope youâll continue to be impressed by how flexible and safe it is. In particular, Iâd like you to keep in mind how very different it is from the much older UserDefaults
API â itâs so nice not having to worry about typing strings exactly correctly!
Anyway, we have lots to get through so letâs get started: create a new iOS app using the App template, and name it CupcakeCorner. If you havenât already downloaded the project files for this book, please fetch them now: twostraws/HackingWithSwift
As always weâre going to start with the new techniques youâll need for the projectâŠ
Adding Codable
conformance for @Published
properties
Adding Codable conformance for @Published properties
If all the properties of a type already conform to Codable
, then the type itself can conform to Codable
with no extra work â Swift will synthesize the code required to archive and unarchive your type as needed. However, this doesnât work when we use property wrappers such as @Published
, which means conforming to Codable
requires some extra work on our behalf.
To fix this, we need to implement Codable
conformance ourself. This will fix the @Published
encoding problem, but is also a valuable skill to have elsewhere too because it lets us control exactly what data is saved and how it happens.
First letâs create a simple type that recreates the problem. Add this class to ContentView.swift
:
class User: ObservableObject, Codable {
var name = "Paul Hudson"
}
That will compile just fine, because String
conforms to Codable
out of the box. However, if we make it @Published
then the code no longer compiles:
class User: ObservableObject, Codable {
@Published var name = "Paul Hudson"
}
The @Published
property wrapper isnât magic â the name property wrapper comes from the fact that our name
property is automatically wrapped inside another type that adds some additional functionality. In the case of @Published
thatâs a struct called Published
that can store any kind of value.
Previously we looked at how we can write generic methods that work with any kind of value, and the Published
struct takes that a step further: the whole type itself is generic, meaning that you canât make an instance of Published
all by itself, but instead make an instance of Published<String>
â a publishable object that contains a string.
If that sounds confusing, back up: itâs actually a fairly fundamental principle of Swift, and one youâve been working with for some time. Think about it â we canât say var names: Set
, can we? Swift doesnât allow it; Swift wants to know whatâs in the set. This is because Set
is also a generic type: you must make an instance of Set<String>
. The same is also true of arrays and dictionaries: we always make them have something specific inside.
Swift already has rules in place that say if an array contains Codable
types then the whole array is Codable
, and the same for dictionaries and sets. However, SwiftUI doesnât provide the same functionality for its Published
struct â it has no rule saying âif the published object is Codable
, then the published struct itself is also Codable
.â
As a result, we need to make the type conform ourselves: we need to tell Swift which properties should be loaded and saved, and how to do both of those actions.
None of those steps are terribly hard, so letâs just dive in with the first one: telling Swift which properties should be loaded and saved. This is done using an enum that conforms to a special protocol called CodingKey
, which means that every case in our enum is the name of a property we want to load and save. This enum is conventionally called CodingKeys
, with an S on the end, but you can call it something else if you want.
So, our first step is to create a CodingKeys
enum that conforms to CodingKey
, listing all the properties we want to archive and unarchive. Add this inside the User
class now:
enum CodingKeys: CodingKey {
case name
}
The next task is to create a custom initializer that will be given some sort of container, and use that to read values for all our properties. This will involve learning a few new things, but letâs look at the code first â add this initializer to User
now:
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = try container.decode(String.self, forKey: .name)
}
Even though that isnât much code, there are at least four new things in there.
First, this initializer is handed an instance of a new type called Decoder
. This contains all our data, but itâs down to us to figure out how to read it.
Second, anyone who subclasses our User
class must override this initializer with a custom implementation to make sure they add their own values. We mark this using the required
keyword: required init
. An alternative is to mark this class as final so that subclassing isnât allowed, in which case weâd write final class User
and drop the required
keyword entirely.
Third, inside the method we ask our Decoder
instance for a container matching all the coding keys we already set in our CodingKey
struct by writing decoder.container(keyedBy: CodingKeys.self)
. This means âthis data should have a container where the keys match whatever cases we have in our CodingKeys
enum. This is a throwing call, because itâs possible those keys donât exist.
Finally, we can read values directly from that container by referencing cases in our enum â container.decode(String.self, forKey: .name)
. This provides really strong safety in two ways: weâre making it clear we expect to read a string, so if name
gets changed to an integer the code will stop compiling; and weâre also using a case in our CodingKeys
enum rather than a string, so thereâs no chance of typos.
Thereâs one more task we need to complete before the User
class conforms to Codable
: weâve made an initializer so that Swift can decode data into this type, but now we need to tell Swift how to encode this type â how to archive it ready to write to JSON.
This step is pretty much the reverse of the initializer we just wrote: we get handed an Encoder
instance to write to, ask it to make a container using our CodingKeys
enum for keys, then write our values attached to each key.
Add this method to the User
class now:
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(name, forKey: .name)
}
And now our code compiles: Swift knows what data we want to write, knows how to convert some encoded data into our objectâs properties, and knows how to convert our objectâs properties into some encoded data.
I hope youâre able to see some real advantages here compared to the stringly typed API of UserDefaults
â itâs much harder to make a mistake with Codable
because we donât use strings, and it automatically checks our data types are correct.
Sending and receiving Codable
data with URLSession
and SwiftUI
Sending and receiving Codable data with URLSession and SwiftUI
iOS gives us built-in tools for sending and receiving data from the internet, and if we combine it with Codable
support then itâs possible to convert Swift objects to JSON for sending, then receive back JSON to be converted back to Swift objects. Even better, when the request completes we can immediately assign its data to properties in SwiftUI views, causing our user interface to update.
To demonstrate this we can load some example music JSON data from Appleâs iTunes API, and show it all in a SwiftUI List
. Appleâs data includes lots of information, but weâre going to whittle it down to just two types: a Result
will store a track ID, its name, and the album it belongs to, and a Response
will store an array of results.
So, start with this code:
struct Response: Codable {
var results: [Result]
}
struct Result: Codable {
var trackId: Int
var trackName: String
var collectionName: String
}
We can now write a simple ContentView
that shows an array of results:
struct ContentView: View {
@State private var results = [Result]()
var body: some View {
List(results, id: \.trackId) { item in
VStack(alignment: .leading) {
Text(item.trackName)
.font(.headline)
Text(item.collectionName)
}
}
}
}
That wonât show anything at first, because the results
array is empty. This is where our networking call comes in: weâre going to ask the iTunes API to send us a list of all the songs by Taylor Swift, then use JSONDecoder
to convert those results into an array of Result
instances.
However, doing this means you need to meet two important Swift keywords: async
and await
. You see, any iPhone capable of running SwiftUI can perform billions of operations every second â itâs so fast that it completes most work before we even realized it started it. On the flip side, networking â downloading data from the internet â might take several hundreds milliseconds or more to come, which is extremely slow for a computer thatâs used to doing literally a billion other things in that time.
Rather than forcing our entire progress to stop while the networking happens, Swift gives us the ability to say âthis work will take some time, so please wait for it to complete while the rest of the app carries on running as usual.â
This functionality â this ability to leave some code running while our main app code carries on working â is called an asynchronous function. A synchronous function is one that runs fully before returning a value as needed, but an asynchronous function is one that is able to go to sleep for a while, so that it can wait for some other work to complete before continuing. In our case, that means going to sleep while our networking code happens, so that the rest of our app doesnât freeze up for several seconds.
To make this easier to understand, letâs write it in a few stages. First, hereâs the basic method stub â please add this to the ContentView
struct:
func loadData() async {
}
Notice the new async
keyword in there â weâre telling Swift this function might want to go to sleep in order to complete its work.
We want that to be run as soon as our List
is shown, but we canât just use onAppear()
here because that doesnât know how to handle sleeping functions â it expects its function to be synchronous.
SwiftUI provides a different modifier for these kinds of tasks, giving it a particularly easy to remember name: task()
. This can call functions that might go to sleep for a while; all Swift asks us to do is mark those functions with a second keyword, await
, so weâre explicitly acknowledging that a sleep might happen.
Add this modifier to the List
now:
.task {
await loadData()
}
Tip: Think of await
as being like try
â weâre saying we understand a sleep might happen, in the same way try
says we acknowledge an error might be thrown.
Inside loadData()
we have three steps we need to complete:
- Creating the URL we want to read.
- Fetching the data for that URL.
- Decoding the result of that data into a
Response
struct.
Weâll add those step by step, starting with the URL. This needs to have a precise format: âitunes.apple.comâ followed by a series of parameters â you can find the full set of parameters if you do a web search for âiTunes Search APIâ. In our case weâll be using the search term âTaylor Swiftâ and the entity âsongâ, so add this to loadData()
now:
guard let url = URL(string: "https://itunes.apple.com/search?term=taylor+swift&entity=song") else {
print("Invalid URL")
return
}
Step 2 is to fetch the data from that URL, which is where our sleep is likely to happen. I say âlikelyâ because it might not â iOS will do a little caching of data, so if the URL is fetched twice back to back then the data will get sent back immediately rather than triggering a sleep.
Regardless, a sleep is possible here, and every time a sleep is possible we need to use the await
keyword with the code we want to run. Just as importantly, an error might also be thrown here â maybe the user isnât currently connected to the internet, for example.
So, we need to use both try
and await
at the same time. Please add this code directly after the previous code:
do {
let (data, _) = try await URLSession.shared.data(from: url)
// more code to come
} catch {
print("Invalid data")
}
That introduced three important things, so letâs break it down:
- Our work is being done by the
data(from:)
method, which takes a URL and returns theData
object at that URL. This method belongs to theURLSession
class, which you can create and configure by hand if you want, but you can also use a shared instance that comes with sensible defaults. - The return value from
data(from:)
is a tuple containing the data at the URL and some metadata describing how the request went. We donât use the metadata, but we do want the URLâs data, hence the underscore â we create a new local constant for the data, and toss the metadata away. - When using both
try
andawait
at the same time, we must writetry await
â usingawait try
is not allowed. Thereâs no special reason for this, but they had to pick one so they went with the one that reads more naturally.
So, if our download succeeds our data
constant will be set to whatever data was sent back from the URL, but if it fails for any reason our code prints âInvalid dataâ and does nothing else.
The last part of this method is to convert the Data
object into a Response
object using JSONDecoder
, then assign the array inside to our results
property. This is exactly what weâve used before, so this shouldnât be a surprise â add this last code in place of the // more code to come
comment now:
if let decodedResponse = try? JSONDecoder().decode(Response.self, from: data) {
results = decodedResponse.results
}
If you run the code now you should see a list of Taylor Swift songs appear after a short pause â it really isnât a lot of code given how well the end result works.
All this only handles downloading data. Later on in this project weâre going to look at how to adopt a slightly different approach so you can send Codable
data, but thatâs enough for now.
Loading an image from a remote server
Loading an image from a remote server
SwiftUIâs Image
view works great with images in your app bundle, but if you want to load a remote image from the internet you need to use AsyncI`mage instead. These are created using an image URL rather than a simple asset name, but SwiftUI takes care of all the rest for us â it downloads the image, caches the download, and displays it automatically.
So, the simplest image we can create looks like this:
AsyncImage(url: URL(string: "https://hws.dev/img/logo.png"))
I created that picture to be 1200 pixels high, but when it displays youâll see itâs much bigger. This gets straight to one of the fundamental complexities of using AsyncImage
: SwiftUI knows nothing about the image until our code is run and the image is downloaded, and so it isnât able to size it appropriately ahead of time.
If I were to include that 1200px image in my project, Iâd actually name it logo@3x.png, then also add an 800px image that was logo@2x.png. SwiftUI would then take care of loading the correct image for us, and making sure it appeared nice and sharp, and at the correct size too. As it is, SwiftUI loads that image as if it were designed to be shown at 1200 pixels high â it will be much bigger than our screen, and will look a bit blurry too.
To fix this, we can tell SwiftUI ahead of time that weâre trying to load a 3x scale image, like this:
AsyncImage(url: URL(string: "https://hws.dev/img/logo.png"), scale: 3)
When you run the code now youâll see the resulting image is a much more reasonable size.
And if you wanted to give it a precise size? Well, then you might start by trying this:
AsyncImage(url: URL(string: "https://hws.dev/img/logo.png"))
.frame(width: 200, height: 200)
That wonât work, but perhaps that wonât even surprise you because it wouldnât work with a regular Image
either. So you might try to make it resizable, like this:
AsyncImage(url: URL(string: "https://hws.dev/img/logo.png"))
.resizable()
.frame(width: 200, height: 200)
âŠexcept that wonât work either, and in fact itâs worse because now our code wonât even compile. You see, the modifiers weâre applying here donât apply directly to the image that SwiftUI downloads â they canât, because SwiftUI canât know how to apply them until it has actually fetched the image data.
Instead, weâre applying modifiers to a wrapper around the image, which is the AsyncImage
view. That will ultimately contain our finished image, but it will also contain a placeholder that gets used while the image is loading. You can actually see the placeholder just briefly when your app runs â that 200x200 gray square is it, and it will automatically go away once loading finishes.
To adjust our image, you need to use a more advanced form of AsyncImage
that passes us the final image view once itâs ready, which we can then customize as needed. As a bonus, this also gives us a second closure to customize the placeholder as needed.
For example, we could make the finished image view be both resizable and scaled to fit, and use Color.red
as the placeholder so itâs more obvious while youâre learning.
AsyncImage(url: URL(string: "https://hws.dev/img/logo.png")) { image in
image
.resizable()
.scaledToFit()
} placeholder: {
Color.red
}
.frame(width: 200, height: 200)
A resizable image and Color.red
both automatically take up all available space, which means the frame()
modifier actually works now.
The placeholder view can be whatever you want. For example, if you replace Color.red
with ProgressView()
â just that â then youâll get a little spinner activity indicator instead of a solid color.
If you want complete control over your remote image, thereâs a third way of creating AsyncImage
that tells us whether the image was loaded, hit an error, or hasnât finished yet. This is particularly useful for times when you want to show a dedicated view when the download fails â if the URL doesnât exist, or the user was offline, etc.
Hereâs how that looks:
AsyncImage(url: URL(string: "https://hws.dev/img/bad.png")) { phase in
if let image = phase.image {
image
.resizable()
.scaledToFit()
} else if phase.error != nil {
Text("There was an error loading the image.")
} else {
ProgressView()
}
}
.frame(width: 200, height: 200)
So, that will show our image if it can, an error message if the download failed for any reason, or a spinning activity indicator while the download is still in progress.
Validating and disabling forms
Validating and disabling forms
SwiftUIâs Form
view lets us store user input in a really fast and convenient way, but sometimes itâs important to go a step further â to check that input to make sure itâs valid before we proceed.
Well, we have a modifier just for that purpose: disabled()
. This takes a condition to check, and if the condition is true then whatever itâs attached to wonât respond to user input â buttons canât be tapped, sliders canât be dragged, and so on. You can use simple properties here, but any condition will do: reading a computed property, calling a method, and so on,
To demonstrate this, hereâs a form that accepts a username and email address:
struct ContentView: View {
@State private var username = ""
@State private var email = ""
var body: some View {
Form {
Section {
TextField("Username", text: $username)
TextField("Email", text: $email)
}
Section {
Button("Create account") {
print("Creating accountâŠ")
}
}
}
}
}
In this example, we donât want users to create an account unless both fields have been filled in, so we can disable the form section containing the Create Account button by adding the disabled()
modifier like this:
Section {
Button("Create account") {
print("Creating accountâŠ")
}
}
.disabled(username.isEmpty || email.isEmpty)
That means âthis section is disabled if username is empty or email is empty,â which is exactly what we want.
You might find that itâs worth spinning out your conditions into a separate computed property, such as this:
var disableForm: Bool {
username.count < 5 || email.count < 5
}
Now you can just reference that in your modifier:
.disabled(disableForm)
Regardless of how you do it, I hope you try running the app and seeing how SwiftUI handles a disabled button â when our test fails the buttonâs text goes gray, but as soon as the test passes the button lights up blue.
That brings us to the end of the overview for this project, so please put ContentView.swift
back to its original state so we can begin building the main project.