UPGRADE YOUR SKILLS: Learn advanced Swift and SwiftUI on Hacking with Swift+! >>

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 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:

  • 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:

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…

Hacking with Swift is sponsored by RevenueCat

SPONSORED Take the pain out of configuring and testing your paywalls. RevenueCat's Paywalls allow you to remotely configure your entire paywall view without any code changes or app updates.

Learn more here

Sponsor Hacking with Swift and reach the world's largest Swift community!

BUY OUR BOOKS
Buy Pro Swift Buy Pro SwiftUI 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 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 Beyond Code

Was this page useful? Let us know!

Average rating: 4.6/5

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.