This episode is freely available thanks to the support of our subscribers

Subscribers get exclusive access to new and all previous subscriber-only episodes, video downloads, and 30% discount for team members. Become a Subscriber

We start building a Swift Talk app using SwiftUI.

00:06 In the past two episodes, we experimented with SwiftUI by building a simple currency converter. Today, we want to take SwiftUI further and start working on a Swift Talk app.

00:23 In this episode, we'll get the basic setup up and running by pulling together various components that we've also seen in the past few episodes, such as the tiny networking library and the Resource wrapper.

Adding Packages

00:53 We create a new Xcode project and, via Xcode's File menu, we add the tiny networking package by entering the repository URL and choosing the master branch.

01:22 We also add a package that provides two libraries with code that can be shared by our backend and frontend. The first one, Model, contains two Codable structs.

The other library, ViewHelpers, contains date formatters. Sharing this code between server and client is useful because it ensures a consistent way of presenting dates and durations, both on the website and in the app.

In the ContentView file, we can now import our three modules, TinyNetworking, Model, and ViewHelpers.

02:37 Besides the Swift packages, there are a few more things we'll need. We copy the Resource file from last week into the project, along with some static JSON data containing episodes and collections of episodes.

List of Collections

03:08 The first thing we can now try to do is loading in the collections. We create a file called Model.swift, which is where we can organize our data. There, we import the Model module, which gives us two types: CollectionView and EpisodeView.

The names of these types aren't perfect and they made more sense in the server context, because rather than UI views, they're actually JSON views on our data. Perhaps we could rename them in the future, but let's roll with it for now.

04:06 We use the tiny networking library to define an Endpoint from which the episode collections can be loaded:

import Foundation
import SwiftUI
import TinyNetworking
import Model

let allCollections = Endpoint<[CollectionView]>(json: .get, url: URL(string: "https://talk.objc.io/collections.json")!)

04:43 Now in the ContentView, we can create a Resource that takes the collections endpoint. We have to make the resource an ObjectBinding — if we forget to do so, we won't get any view updates when the resource changes.

05:08 Inside the body view, we display the collections in a list if they're loaded. Otherwise, we display a text describing the loading state:

import SwiftUI
import TinyNetworking
import Model
import ViewHelpers

struct ContentView : View {
    @ObjectBinding var collections = Resource(endpoint: allCollections)
    var body: some View {
        Group {
            if collections.value == nil {
                Text("Loading...")
            } else {
                List {
                    ForEach(collections.value!) { coll in
                        Text(coll.title)
                    }
                }
            }
        }
    }
}

05:39 This won't yet compile, because we haven't made collections identifiable yet. Last time, we did this by providing the key path to the identifying property on the array's elements. But we can actually conform CollectionView to the Identifiable protocol, which it does out of the box because it has a hashable id property:

extension CollectionView: Identifiable {}

06:18 We run the app and we see a list of collection titles.

06:28 A next step is pulling the list view out into a separate file. We add a simple property that holds an array of collections:

import SwiftUI
import Model
import ViewHelpers

struct CollectionsList : View {
    let collections: [CollectionView]
    var body: some View {
        List {
            ForEach(collections) { coll in
                Text(coll.title)
            }
        }
    }
}

07:52 In the ContentView, we can then pass the loaded collections to the CollectionsList:

struct ContentView : View {
    @ObjectBinding var collections = Resource(endpoint: allCollections)
    var body: some View {
        Group {
            if collections.value == nil {
                Text("Loading...")
            } else {
                CollectionsList(collections: collections.value!)
            }
        }
    }
}

08:07 We get a compiler error because the preview for the CollectionsList view is missing the collections argument. If we comment the preview out, we see that the rest of our code does compile, and the CollectionsList works as well. In order to make the preview work, we need to pass it some static data.

08:49 For this, we have the JSON files that we added to the project earlier. We write a simple function that loads a JSON file with a given name from the bundle and decodes its contents into a generic type. We can force-unwrap any optional because this function is only used for loading sample data, and a crash wouldn't matter in this case:

func sample<A: Codable>(name: String) -> A {
    let url = Bundle.main.url(forResource: name, withExtension: "json")!
    let data = try! Data(contentsOf: url)
    let decoder = JSONDecoder()
    decoder.dateDecodingStrategy = .secondsSince1970
    return try! decoder.decode(A.self, from: data)
}

10:37 And then we load the sample collections for the preview of CollectionsList:

#if DEBUG
struct CollectionsList_Previews : PreviewProvider {
    static var previews: some View {
        CollectionsList(collections: sample(name: "collections"))
    }
}
#endif

11:02 We open the canvas and see that the sample collections are loaded into the list view.

11:21 Having the preview working comes in handy for quickly styling the list view. We wrap the title in a VStack, and we add another Text containing a caption that shows the number of episodes in the collection. Then we align the VStack to its leading edge and we style the text labels.

And finally, we add the total duration of the collection to the caption, formatted using the shared ViewHelpers:

struct CollectionsList : View {
    let collections: [CollectionView]
    var body: some View {
        List {
            ForEach(collections) { coll in
                VStack(alignment: .leading) {
                    Text(coll.title)
                    Text("\(coll.episodes_count) episodes ᐧ \(TimeInterval(coll.total_duration).hoursAndMinutes)")
                        .font(.caption)
                        .color(.gray)
                }
            }
        }
    }
}

13:16 At some point, we'll want to show a detail view for a collection from the list, but before we can get to that, we have to set up the navigation view. We add a navigation title modifier to the list, and we wrap the preview of the list in a NavigationView in order to see the navigation bar and title:

struct CollectionsList : View {
    let collections: [CollectionView]
    var body: some View {
        List {
            // ...
        }.navigationBarTitle(Text("Collections"))
    }
}

#if DEBUG
struct CollectionsList_Previews : PreviewProvider {
    static var previews: some View {
        NavigationView {
            CollectionsList(collections: sample("collections"))
        }
    }
}
#endif

13:53 And we also need to wrap the collections list in a navigation view in ContentView in order to see the title when we run the app:

struct ContentView : View {
    @ObjectBinding var collections = Resource(endpoint: allCollections)
    var body: some View {
        Group {
            if collections.value == nil {
                Text("Loading...")
            } else {
                NavigationView {
                    CollectionsList(collections: collections.value!)
                }
            }
        }
    }
}

Collection Details

14:21 With the navigation view in place, we can easily add a detail view for a single collection. And there, we can load the collection's artwork by creating another Endpoint wrapped in a Resource.

14:37 We create a new SwiftUI view file for CollectionDetails, and we import the modules we need:

import SwiftUI
import TinyNetworking
import Model

15:04 We define a property for the CollectionView that we're presenting in this view, and we add a VStack containing a label with a large font for the collection's title. We also call the lineLimit modifier with nil in order to get a multiline label:

struct CollectionDetails : View {
    let collection: CollectionView
    var body: some View {
        VStack {
            Text(collection.title)
                .font(.largeTitle)
                .lineLimit(nil)
            Text(collection.description)
        }
    }
}

15:50 In the collections list, we wrap each list item in a navigation button that has a CollectionsDetails view as its destination:

struct CollectionsList : View {
    let collections: [CollectionView]
    var body: some View {
        List {
            ForEach(collections) { coll in
                NavigationButton(destination: CollectionDetails(collection: coll)) {
                    VStack(alignment: .leading) {
                        Text(coll.title)
                        Text("\(coll.episodes_count) episodes ᐧ \(TimeInterval(coll.total_duration).hoursAndMinutes)")
                            .font(.caption)
                            .color(.gray)
                    }
                }
            }
        }.navigationBarTitle(Text("Collections"))
    }
}

16:31 And like before with the collections list, we now need to provide sample data for the preview of a collection detail view. It makes sense to move the sample function into our model file, pull the sample collections out to a global variable, and use this variable in the previews of both the list and the detail view:


let allCollections = Endpoint<[CollectionView]>(json: .get, url: URL(string: "https://talk.objc.io/collections.json")!)

let sampleCollections: [CollectionView] = sample(name: "collections")

func sample<A: Codable>(name: String) -> A {
    // ...
}
#if DEBUG
struct CollectionsList_Previews : PreviewProvider {
    static var previews: some View {
        NavigationView {
            CollectionsList(collections: sampleCollections)
        }
    }
}
#endif
#if DEBUG
struct CollectionDetails_Previews : PreviewProvider {
    static var previews: some View {
        CollectionDetails(collection: sampleCollections[0])
    }
}
#endif

17:52 We run the app in the simulator and we try opening a detail view by selecting one of the collections. Then we notice that the collection's description label is still limited to showing a single line of text. We add the lineLimit(nil) modifier to fix that:

struct CollectionDetails : View {
    let collection: CollectionView
    var body: some View {
        VStack {
            Text(collection.title)
                .font(.largeTitle)
                .lineLimit(nil)
            Text(collection.description)
                .lineLimit(nil)
        }
    }
}

18:36 But that doesn't have an effect on the preview. This seems to be a bug, because in the simulator, the label now correctly spans as many lines as needed.

Loading the Artwork

18:51 To load the collection's artwork, we add an image resource to the collection detail view:

struct CollectionDetails : View {
    let collection: CollectionView
    let image: Resource<UIImage>
    // ...
}

19:07 Then we define an initializer in which we can construct that resource from the collection parameter. We first create an Endpoint with the collection's artwork URL, along with a parsing closure that constructs a UIImage from the loaded data:

struct ImageError: Error {}

struct CollectionDetails : View {
    let collection: CollectionView
    let image: Resource<UIImage>
    init(collection: CollectionView) {
        self.collection = collection
        let endpoint = Endpoint<UIImage>(.get, url: collection.artwork.png, expectedStatusCode: expected200to300) { data in
            guard let d = data, let i = UIImage(data: d) else {
                return .failure(ImageError())
            }
            return .success(i)
        }
        self.image = Resource(endpoint: endpoint)
    }
    // ...
}

21:07 Much of this code is reusable, so we'll pull it out later to be able to load other images as well.

21:15 Now we can include the image if it's loaded. We have to make the image resizable and set its aspect ratio to .fit, because we want the image to scale and fit inside the view. If we don't use any modifiers, then SwiftUI will always use the image's native size.

21:57 We now remember that the image resource needs to be ObjectBinding. Without it, the code still compiles and everything seems to work, but the view never gets rerendered when the image is loaded:

@ObjectBinding var image: Resource<UIImage>

22:10 Loading the image won't work in the preview, so we have to run the app in the simulator to test it. There, we see the collection's artwork showing up in the detail view.

22:26 There's a whole lot more to do. We should factor out the image loading code, we need to further style the detail view, and we'll want to show a list of the collection's episodes.

22:40 Next time, we'll make these improvements to our code in addition to introducing tabs, which give us more ways of browsing the same data while still reusing code as much as possible.

Resources

  • Sample Code

    Using Xcode 11 Beta 2

  • Episode Video

    Become a subscriber to download episode videos.

In Collection

See All Collections

Episode Details

Recent Episodes

See All

Unlock Full Access

Subscribe to Swift Talk

  • Watch All Episodes

    A new episode every week

  • icon-benefit-download Created with Sketch.

    Download Episodes

    Take Swift Talk with you when you're offline

  • Support Us

    With your help we can keep producing new episodes