< Sharing @Observable objects through SwiftUI's environment | Presenting a default detail view on iPad > |
In this app we’re going to display two views side by side, just like Apple’s Mail and Notes apps. In SwiftUI this is done by placing two views into a NavigationSplitView
, then using a NavigationLink
in the primary view to control what’s visible in the secondary view.
So, we’re going to start off our project by building the primary view for our app, which will show a list of all ski resorts, along with which country they are from and how many ski runs it has – how many pistes you can ski down, sometimes called “trails” or just “slopes”.
I’ve provided some assets for this project in the GitHub repository for this book, so if you haven’t already downloaded them please do so now. You should drag resorts.json into your project navigator, then copy all the pictures into your asset catalog. You might notice that I’ve included 2x and 3x images for the countries, but only 2x pictures for the resorts. This is intentional: those flags are going to be used for both retina and Super Retina devices, but the resort pictures are designed to fill all the space on an iPad Pro – they are more than big enough for a Super Retina iPhone even at 2x resolution.
To get our list up and running quickly, we need to define a simple Resort
struct that can be loaded from our JSON. That means it needs to conform to Codable
, but to make it easier to use in SwiftUI we’ll also make it conform to both Hashable
and Identifiable
.
The actual data itself is mostly just strings and integers, but there’s also a string array called facilities
that describe what else there is on the resort – I should add that this data is mostly fictional, so don’t try to use it in a real app!
Create a new Swift file called Resort.swift, then give it this code:
struct Resort: Codable, Hashable, Identifiable {
var id: String
var name: String
var country: String
var description: String
var imageCredit: String
var price: Int
var size: Int
var snowDepth: Int
var elevation: Int
var runs: Int
var facilities: [String]
}
As usual, it’s a good idea to add an example value to your model so that it’s easier to show working data in your designs. This time, though, there are quite a few fields to work with and it’s helpful if they have real data, so I don’t really want to create one by hand.
Instead, we’re going to load an array of resorts from JSON stored in our app bundle, which means we can re-use the same code we wrote for project 8 – the Bundle-Decodable.swift extension. If you still have yours, you can drop it into your new project, but if not then create a new Swift file called Bundle-Decodable.swift and give it this code:
extension Bundle {
func decode<T: Decodable>(_ file: String) -> T {
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()
do {
return try decoder.decode(T.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)")
}
}
}
With that in place, we can add some properties to Resort
to store our example data, and there are two options here. The first option is to add two static properties: one to load all resorts into an array, and one to store the first item in that array, like this:
static let allResorts: [Resort] = Bundle.main.decode("resorts.json")
static let example = allResorts[0]
The second is to collapse all that down to a single line of code. This requires a little bit of gentle typecasting because our decode()
extension method needs to know what type of data it’s decoding:
static let example = (Bundle.main.decode("resorts.json") as [Resort])[0]
Of the two, I prefer the first option because it’s simpler and has a little more use if we wanted to show random examples rather than the same one again and again. In case you were curious, when we use static let
for properties, Swift automatically makes them lazy – they don’t get created until they are used. This means when we try to read Resort.example
Swift will be forced to create Resort.allResorts
first, then send back the first item in that array for Resort.example
. This means we can always be sure the two properties will be run in the correct order – there’s no chance of example
going missing because allResorts
wasn’t called yet.
Now that our simple Resort
struct is done, we can also use that same Bundle
extension to add a property to ContentView
that loads all our resorts into a single array:
let resorts: [Resort] = Bundle.main.decode("resorts.json")
For the body of our view, we’re going to use a NavigationSplitView
with a List
inside it, showing all our resorts. In each row we’re going to show:
40x25 is smaller than our flag source image, and also a different aspect ratio, but we can fix that by using resizable()
, scaledToFill()
, and a custom frame. To make it look a little better on the screen, we’ll use a custom clip shape and a stroked overlay.
When the row is tapped we’re going to push to a detail view showing more information about the resort, but we haven’t built that yet so instead we’ll just push to a temporary text view as a placeholder.
Replace your current body
property with this:
NavigationSplitView {
List(resorts) { resort in
NavigationLink(value: resort) {
HStack {
Image(resort.country)
.resizable()
.scaledToFill()
.frame(width: 40, height: 25)
.clipShape(
.rect(cornerRadius: 5)
)
.overlay(
RoundedRectangle(cornerRadius: 5)
.stroke(.black, lineWidth: 1)
)
VStack(alignment: .leading) {
Text(resort.name)
.font(.headline)
Text("\(resort.runs) runs")
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Resorts")
} detail: {
Text("Detail")
}
Go ahead and run the app now and you should see it looks good enough – make sure you try it in both portrait and landscape, on both iPhone and iPad.
Now let's try to fill in that detail view…
SAVE 50% All our books and bundles are half price for Black Friday, so you can take your Swift knowledge further without spending big! Get the Swift Power Pack to build your iOS career faster, get the Swift Platform Pack to builds apps for macOS, watchOS, and beyond, or get the Swift Plus Pack to learn advanced design patterns, testing skills, and more.
Link copied to your pasteboard.