NEW: Join my free 100 Days of SwiftUI challenge today! >>

Building a primary list of items

Paul Hudson    @twostraws   

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 ready 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 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 SwiftUI 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 have two options. 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 before allResorts wasn’t called yet.

We want 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 have yours to hand 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 extension we can now 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 from our flag source image, and also a different aspect ratio, but we can fix that by using resizable(), scaledToFit(), 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(destination: Text(resort.name)) {
            Image(resort.country)
                .resizable()
                .scaledToFill()
                .frame(width: 40, height: 25)
                .clipShape(
                    RoundedRectangle(cornerRadius: 5)
                )
                .overlay(
                    RoundedRectangle(cornerRadius: 5)
                        .stroke(Color.black, lineWidth: 1)
                )

            VStack(alignment: .leading) {
                Text(resort.name)
                    .font(.headline)
                Text("\(resort.runs) runs")
                    .foregroundColor(.secondary)
            }
        }
    }
    .navigationBarTitle("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’ll see the screen goes blank. This happens because SwiftUI wants to show a detail view there, but we haven’t created one yet – let’s fix that next.

LEARN SWIFTUI FOR FREE I have a massive, free SwiftUI video collection on YouTube teaching you how to build complete apps with SwiftUI – check it out!

BUY OUR BOOKS
Buy Pro Swift Buy Swift Design Patterns Buy Testing Swift Buy Hacking with iOS Buy Swift Coding Challenges Buy Swift on Sundays Volume One Buy Server-Side Swift (Vapor Edition) Buy Advanced iOS Volume One Buy Advanced iOS Volume Two Buy Advanced iOS Volume Three Buy Hacking with watchOS Buy Hacking with tvOS Buy Hacking with macOS Buy Dive Into SpriteKit Buy Swift in Sixty Seconds Buy Objective-C for Swift Developers Buy Server-Side Swift (Kitura Edition) Buy Beyond Code

Was this page useful? Let us know!

Average rating: 5.0/5