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 load 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 them 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 compile, because we haven't yet made collections
identifiable. 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.