In this app we’re going to load two different kinds of JSON into Swift structs: one for astronauts, and one for missions. Making this happen in a way that is easy to maintain and doesn’t clutter our code takes some thinking, but we’ll work towards it step by step.
First, drag in the two JSON files for this project. These are available in the GitHub repository for this book, under “project8-files” – look for astronauts.json and missions.json, then drag them into your project navigator. While we’re adding assets, you should also copy all the images into your asset catalog – these are in the “Images” subfolder. The images of astronauts and mission badges were all created by NASA, so under Title 17, Chapter 1, Section 105 of the US Code they are available for us to use under a public domain license.
If you look in astronauts.json, you’ll see each astronaut is defined by three fields: an ID (“grissom”, “white”, “chaffee”, etc), their name (“Virgil I. "Gus" Grissom”, etc), and a short description that has been copied from Wikipedia. If you intend to use the text in your own shipping projects, it’s important that you give credit to Wikipedia and its authors and make it clear that the work is licensed under CC-BY-SA available from here: https://creativecommons.org/licenses/by-sa/3.0.
Let’s convert that astronaut data into a Swift struct now – press Cmd+N to make a new file, choose Swift file, then name it Astronaut.swift. Give it this code:
struct Astronaut: Codable, Identifiable {
let id: String
let name: String
let description: String
}
As you can see, I’ve made that conform to Codable
so we can create instances of this struct straight from JSON, but also Identifiable
so we can use arrays of astronauts inside ForEach
and more – that id
field will do just fine.
Next we want to convert astronauts.json into a dictionary of Astronaut
instances, which means we need to use Bundle
to find the path to the file, load that into an instance of Data
, and pass it through a JSONDecoder
. Previously we put this into a method on ContentView
, but here I’d like to show you a better way: we’re going to write an extension on Bundle
to do it all in one centralized place.
Create another new Swift file, this time called Bundle-Decodable.swift. This will mostly use code you’ve seen before, but there’s one small difference: previously we used String(contentsOf:)
to load files into a string, but because Codable
uses Data
we are instead going to use Data(contentsOf:)
. It works in just the same way as String(contentsOf:)
: give it a file URL to load, and it either returns its contents or throws an error.
Add this to Bundle-Decodable.swift now:
extension Bundle {
func decode(_ file: String) -> [String: Astronaut] {
guard let url = self.url(forResource: file, withExtension: nil) else {
fatalError("Failed to locate \(file) in bundle.")
}
guard let data = try? Data(contentsOf: url) else {
fatalError("Failed to load \(file) from bundle.")
}
let decoder = JSONDecoder()
guard let loaded = try? decoder.decode([String: Astronaut].self, from: data) else {
fatalError("Failed to decode \(file) from bundle.")
}
return loaded
}
}
We'll come back to that in a moment, but as you can see it makes liberal use of fatalError()
: if the file can’t be found, loaded, or decoded the app will crash. As before, though, this will never actually happen unless you’ve made a mistake, for example if you forgot to copy the JSON file into your project.
Now, you might wonder why we used an extension here rather than a method, but the reason is about to become clear as we load that JSON into our content view. Add this property to the ContentView
struct now:
let astronauts = Bundle.main.decode("astronauts.json")
Yes, that’s all it takes. Sure, all we’ve done is just moved code out of ContentView
and into an extension, but there’s nothing wrong with that – anything we can do to help our views stay small and focused is a good thing.
If you want to double check that your JSON is loaded correctly, modify the default body
property to this:
Text(String(astronauts.count))
That should display 32 rather than “Hello World”.
Before we're done, I want to go back to our little extension and look at it a little more closely. The code we have is perfectly fine for this app, but if you want to use it in the future I'd recommend adding some extra code to help you diagnose problems.
Replace the second part of the method with this:
let decoder = JSONDecoder()
do {
return try decoder.decode([String: Astronaut].self, from: data)
} catch DecodingError.keyNotFound(let key, let context) {
fatalError("Failed to decode \(file) from bundle due to missing key '\(key.stringValue)' – \(context.debugDescription)")
} catch DecodingError.typeMismatch(_, let context) {
fatalError("Failed to decode \(file) from bundle due to type mismatch – \(context.debugDescription)")
} catch DecodingError.valueNotFound(let type, let context) {
fatalError("Failed to decode \(file) from bundle due to missing \(type) value – \(context.debugDescription)")
} catch DecodingError.dataCorrupted(_) {
fatalError("Failed to decode \(file) from bundle because it appears to be invalid JSON.")
} catch {
fatalError("Failed to decode \(file) from bundle: \(error.localizedDescription)")
}
It's not a big change, but it means the method will now tell you what went wrong with decoding – it's great for times when your Swift code and JSON file don't quite match up!
SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure and A/B test your entire paywall UI without any code changes or app updates.
Sponsor Hacking with Swift and reach the world's largest Swift community!
Link copied to your pasteboard.