Day 97
Day 97 êŽë š
Project 19, part 2
Today weâre going to implement the first half of our program, which means weâll get a list of ski resorts, a detail view to show more information, and a NavigationView
that can show them side by side. That by itself shouldnât present a problem for you, but along the way youâll also learn about loading static example data from the bundle, controlling how NavigationView
should show primary and secondary views on iPhone, and even how to format lists of strings more neatly.
Although you already know so much of what is needed to make this code work, weâre still here on day 97 introducing new things to learn. I hope youâre not discouraged by that â learning is an important skill, and in programming as well as many other industries you can find yourself constantly trying new things throughout your whole career. That might feel hard at times, but as the Spanish painter Pablo Picasso once said, âI am always doing that which I cannot do, in order that I may learn how to do it.â
So, keep learning and be proud to say youâre still learning â itâs an important skill to have!
Today you have four topics to work through, in which youâll learn about build our primary and secondary views, show them side by side in a NavigationView
, learn an improved way to format lists, and more.
Building a primary list of items
Building a primary list of items
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 NavigationView
, 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 Identifiable
. The actual data itself is mostly just strings andCodable 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, Identifiable {
let id: String
let name: String
let country: String
let description: String
let imageCredit: String
let price: Int
let size: Int
let snowDepth: Int
let elevation: Int
let runs: Int
let 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()
guard let loaded = try? decoder.decode(T.self, from: data) else {
fatalError("Failed to decode \(file) from bundle.")
}
return loaded
}
}
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 NavigationView
with a List
inside it, showing all our resorts. In each row weâre going to show:
- A 40x25 flag of which country the resort is in.
- The name of the resort.
- How many runs it has.
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:
NavigationView {
List(resorts) { resort in
NavigationLink {
Text(resort.name)
} label: {
Image(resort.country)
.resizable()
.scaledToFill()
.frame(width: 40, height: 25)
.clipShape(
RoundedRectangle(cornerRadius: 5)
)
.overlay(
RoundedRectangle(cornerRadius: 5)
.stroke(.black, lineWidth: 1)
)
VStack(alignment: .leading) {
Text(resort.name)
.font(.headline)
Text("\(resort.runs) runs")
.foregroundColor(.secondary)
}
}
}
.navigationTitle("Resorts")
}
Go ahead and run the app now and you should see it looks good enough, but if you rotate your iPhone to landscape you might see the screen is almost blank depending on which device youâre using â an iPhone 13 Pro Max will be almost empty, whereas a regular iPhone 13 Pro wonât.
This happens because SwiftUI wants to show a detail view there, but we havenât created one yet â letâs fix that next.
Making NavigationView work in landscape
Making NavigationView work in landscape
When we use a NavigationView
, by default SwiftUI expects us to provide both a primary view and a secondary detail view that can be shown side by side, with the primary view shown on the left and the secondary on the right. This isnât required â you can force the push/pop NavigationLink
behavior if you want by using the navigationViewStyle()
modifier â but in this project we actually want the two-view behavior so we arenât going to use that.
On landscape iPhones that are big enough â iPhone 13 Pro Max, for example â SwiftUIâs default behavior is to show the secondary view, and provide the primary view as a slide over. Itâs always been there, but you might not have realized until recently: try sliding from the left edge of your screen to reveal the ContentView
we just made. If you tap rows in there youâll see the text behind ContentView
change as the result of our NavigationLink
, and if you tap on the text behind you can dismiss the ContentView
slide over.
Now, there is a problem here, and itâs the same problem youâve had all along: itâs not immediately obvious to the user that they need to slide from the left to reveal the list of options. In UIKit this can be fixed easily, but SwiftUI doesnât give us an alternative right now so weâre going to work around the problem: weâll create a second view to show on the right by default, and use that to help the user discover the left-hand list.
First, create a new SwiftUI view called WelcomeView
, then give it this code:
struct WelcomeView: View {
var body: some View {
VStack {
Text("Welcome to SnowSeeker!")
.font(.largeTitle)
Text("Please select a resort from the left-hand menu; swipe from the left edge to show it.")
.foregroundColor(.secondary)
}
}
}
Thatâs all just static text; it will only be shown when the app first launches, because as soon as the user taps any of our navigation links it will get replaced with whatever they were navigating to.
To put that into ContentView
so the two parts of our UI can be used side by side, all we need to do is add a second view to our NavigationView
like this:
NavigationView {
List(resorts) { resort in
// all the previous list code
}
.navigationTitle("Resorts")
WelcomeView()
}
Thatâs enough for SwiftUI to understand exactly what we want. Try running the app on several different devices, both in portrait and landscape, to see how SwiftUI responds â on an iPhone 13 Pro youâll see ContentView
in both portrait and landscape, but on an iPhone 13 Pro Max youâll see ContentView
in portrait and WelcomeView
in landscape. If youâre using an iPad, you might see several different things depending on the device orientation and whether the app has all the screen to itself as opposed to using split screen.
Although UIKit lets us control whether the primary view should be shown on iPad portrait, this is not yet possible in SwiftUI. However, we can stop iPhones from using the slide over approach if thatâs what you want â try it first and see what you think. If you want it gone, add this extension to your project:
extension View {
@ViewBuilder func phoneOnlyStackNavigationView() -> some View {
if UIDevice.current.userInterfaceIdiom == .phone {
self.navigationViewStyle(.stack)
} else {
self
}
}
}
That uses Appleâs UIDevice
class to detect whether we are currently running on a phone or a tablet, and if itâs a phone enables the simpler StackNavigationViewStyle
approach. We need to use the @ViewBuilder
attribute here because the two returned view types are different.
Once you have that extension, simply add the .phoneOnlyStackNavigationView()
modifier to your NavigationView
so that iPads retain their default behavior whilst iPhones always use stack navigation. Again, give it a try and see what you think â itâs your app, and itâs important you like how it works.
Tip: Iâm not going to be using this modifier in my own project because I prefer to use Appleâs default behavior where possible, but donât let that stop you from making your own choice!
Creating a secondary view for NavigationView
Creating a secondary view for NavigationView
Right now our NavigationLink
directs the user to some sample text, which is fine for prototyping but obviously not good enough for our actual project. Weâre going to replace that with a new ResortView
that shows a picture from the resort, some description text, and a list of facilities.
Important: Like I said earlier, the content in my example JSON is mostly fictional, and this includes the photos â these are just generic ski photos taken from Unsplash. Unsplash photos can be used commercially or non-commercially without attribution, but Iâve included the photo credit in the JSON so you can add it later on. As for the text, this is taken from Wikipedia. If you intend to use the text in your own shipping projects, itâs important 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.
To start with, our ResortView
layout is going to be pretty simple â not much more than a scroll view, a VStack
, an Image
, and some Text
. The only interesting part is that weâre going to show the resortâs facilities as a single text view using resort.facilities.joined(separator: ", ")
to get a single string.
Create a new SwiftUI view called ResortView
, and give it this code to start with:
struct ResortView: View {
let resort: Resort
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Image(decorative: resort.id)
.resizable()
.scaledToFit()
Group {
Text(resort.description)
.padding(.vertical)
Text("Facilities")
.font(.headline)
Text(resort.facilities.joined(separator: ", "))
.padding(.vertical)
}
.padding(.horizontal)
}
}
.navigationTitle("\(resort.name), \(resort.country)")
.navigationBarTitleDisplayMode(.inline)
}
}
Youâll also need to update ResortView_Previews
to pass in an example resort for Xcodeâs preview window:
struct ResortView_Previews: PreviewProvider {
static var previews: some View {
ResortView(resort: Resort.example)
}
}
And now we can update the navigation link in ContentView
to point to our actual view, like this:
NavigationLink {
ResortView(resort: resort)
} label: {
Thereâs nothing terribly interesting in our code so far, but thatâs going to change now because I want to add more details to this screen â how big the resort is, roughly how much it costs, how high it is, and how deep the snow is.
We could just put all that into a single HStack
in ResortView
, but that restricts what we can do in the future. So instead weâre going to group them into two views: one for resort information (price and size) and one for ski information (elevation and snow depth).
The ski view is the easier of the two to implement, so weâll start there: create a new SwiftUI view called SkiDetailsView
and give it this code:
struct SkiDetailsView: View {
let resort: Resort
var body: some View {
Group {
VStack {
Text("Elevation")
.font(.caption.bold())
Text("\(resort.elevation)m")
.font(.title3)
}
VStack {
Text("Snow")
.font(.caption.bold())
Text("\(resort.snowDepth)cm")
.font(.title3)
}
}
.frame(maxWidth: .infinity)
}
}
struct SkiDetailsView_Previews: PreviewProvider {
static var previews: some View {
SkiDetailsView(resort: Resort.example)
}
}
Giving the Group
view a maximum frame width of .infinity
doesnât actually affect the group itself, because it has no impact on layout. However, it does get passed down to its child views, which means they will automatically spread out horizontally.
As for the resort details, this is a little trickier because of two things:
- The size of a resort is stored as a value from 1 to 3, but really we want to use âSmallâ, âAverageâ, and âLargeâ instead.
- The price is stored as a value from 1 to 3, but weâre going to replace that with $, $$, or $$$.
As always, itâs a good idea to get calculations out of your SwiftUI layouts so itâs nice and clear, so weâre going to create two computed properties: size
and price
.
Start by creating a new SwiftUI view called ResortDetailsView
, and give it this property:
let resort: Resort
As with ResortView
, youâll need to update the preview struct to use some example data:
struct ResortDetailsView_Previews: PreviewProvider {
static var previews: some View {
ResortDetailsView(resort: Resort.example)
}
}
When it comes to getting the size of the resort we could just add this property to ResortDetailsView
:
var size: String {
["Small", "Average", "Large"][resort.size - 1]
}
That works, but it would cause a crash if an invalid value was used, and itâs also a bit too cryptic for my liking. Instead, itâs safer and clearer to use a switch
block like this:
var size: String {
switch resort.size {
case 1:
return "Small"
case 2:
return "Average"
default:
return "Large"
}
}
As for the price
property, we can leverage the same repeating/count initializer we used to create example cards in project 17: String(repeating:count:)
creates a new string by repeating a substring a certain number of times.
So, please add this second computed property to ResortDetailsView
:
var price: String {
String(repeating: "$", count: resort.price)
}
Now what remains in the body
property is simple, because we just use the two computed properties we wrote:
var body: some View {
Group {
VStack {
Text("Size")
.font(.caption.bold())
Text(size)
.font(.title3)
}
VStack {
Text("Price")
.font(.caption.bold())
Text(price)
.font(.title3)
}
}
.frame(maxWidth: .infinity)
}
Again, giving the whole Group
an infinite maximum width means these views will spread out horizontally just like those from the previous view.
That completes our two mini views, so we can now drop them into ResortView
â put this just before the group in ResortView
:
HStack {
ResortDetailsView(resort: resort)
SkiDetailsView(resort: resort)
}
.padding(.vertical)
.background(Color.primary.opacity(0.1))
Weâre going to add to that some more in a moment, but first I want to make one small tweak: using joined(separator:)
does an okay job of converting a string array into a single string, but weâre not here to write okay code â weâre here to write great code.
Previously weâve used the format
parameter of Text
to control the way numbers are formatted, but thereâs a format for string arrays too. This is similar to using joined(separator:)
, but rather than sending back âA, B, Câ like we have right now, we get back âA, B, and Câ â itâs more natural to read.
Replace the current facilities text view with this:
Text(resort.facilities, format: .list(type: .and))
.padding(.vertical)
Notice how the .and
type is there? Thatâs because you can also use .or
to get âA, B, or Câ if thatâs what you want.
Anyway, itâs a tiny change but I think itâs much better!
Searching for data in a List
Searching for data in a List
Before our List
view is done, weâre going to add a SwiftUI modifier that makes our userâs experience a whole lot better without too much work: searchable()
. Adding this will allow users to filter the list of resorts weâre showing, making it easy to find the exact thing theyâre looking for.
This takes only four steps, starting with a new @State
property in ContentView
to store the text the user is searching for:
@State private var searchText = ""
Second, we can bind that to our List
in ContentView
by adding this directly below the existing navigationTitle()
modifier:
.searchable(text: $searchText, prompt: "Search for a resort")
Third, we need a computed property that will handle the filtering of our data. If our new searchText
property is empty then we can just send back all the resorts we loaded, otherwise weâll use localizedCaseInsensitiveContains()
to filter the array based on their search criteria:
var filteredResorts: [Resort] {
if searchText.isEmpty {
return resorts
} else {
return resorts.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}
}
And the final step is to use filteredResorts
as the data source for our list, like this:
List(filteredResorts) { resort in
And with that weâre done! If you run the app again youâll see you can drag the resort list gently down to see the search box, and entering something in there will filter the list straight away. Honestly, searchable()
is one of the biggest âbang for buckâ features in SwiftUI â itâs such an important feature for users, and took us only a few minutes to implement!