-
Notifications
You must be signed in to change notification settings - Fork 7
Chapter 10: Albums List Row View
We’re finally ready to implement some SwiftUI views. We’ve built our networking and model types while practicing TDD. We wrote our test code first, and we wrote app code to make our test code pass. We focused on building simple types that did simple things. We composed these simple types together and used dependency-injection to test these relationships.
While it is possible to “unit-test” views, a thorough discussion of the challenges and best-practices is outside the scope of this tutorial. For more information on this topic try “iOS Unit Testing by Example” by Jon Reid.[^1]
We will build three types for putting these views on screen. We will build one type for displaying one Album
instance. We will build one type for displaying a list of Album
instances. We will build one more type for displaying that list on app launch. We will not choose to write unit-tests as we build our views; we will still see how building our types by injecting dependencies at compile-time can help us move fast. If we don’t practice TDD, we can still see benefits from the design patterns that TDD helped to encourage.
Add a new Swift file named AlbumsListRowView.swift
. Add this file to your Albums
target (you do not need to include this in your test target). Let’s begin with the type we need to publish state to this view. Similar to our approach from previous chapters, we will define this interface with a protocol. Our view type will accept a generic parameter conforming to this protocol. We will inject a “stub” type as we iterate on our implementation; we will inject a production type on app launch.
// AlbumsListRowView.swift
import SwiftUI
@MainActor protocol AlbumsListRowViewModel : ObservableObject {
var artist: String { get }
var name: String { get }
var image: CGImage? { get }
init(album: Album)
func requestImage() async throws
}
extension AlbumsListRowModel : AlbumsListRowViewModel where ImageOperation == NetworkImageOperation<NetworkSession<URLSession>, NetworkImageHandler<NetworkDataHandler, NetworkImageSerialization<NetworkImageSource>>> {
}
Let’s define our view type. We will pass aa AlbumsListRowViewModel
type as a generic parameter. We will pass a AlbumsListRowViewModel
instance to an initializer (and persist that instance with an ObservedObject
variable).
// AlbumsListRowView.swift
struct AlbumsListRowView<ListRowViewModel : AlbumsListRowViewModel> : View {
@ObservedObject private var model: ListRowViewModel
init(model: ListRowViewModel) {
self.model = model
}
}
In our previous chapters, we would define a XCTestCase
type with test-double types we injected for testing. For a SwiftUI view, we can implement a PreviewProvider
type and inject “test-doubles” in those previews. Instead of building and running in the Simulator as we iterate through implementing our view, we can take advantage of the support for PreviewProvider
in Xcode.[^2] Let’s start by defining the type we need to stub (and publish) state.
// AlbumsListRowView.swift
struct AlbumsListRowView_Previews: PreviewProvider {
}
extension AlbumsListRowView_Previews {
private final class ListRowModel : AlbumsListRowViewModel {
let artist: String
let name: String
@Published private(set) var image: CGImage?
init(album: Album) {
self.artist = album.artist
self.name = album.name
}
func requestImage() async throws {
if let context = CGContext(
data: nil,
width: 256,
height: 256,
bitsPerComponent: 8,
bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) {
context.setFillColor(
red: 0.5,
green: 0.5,
blue: 0.5,
alpha: 1
)
context.fill(
CGRect(
x: 0,
y: 0,
width: 256,
height: 256
)
)
self.image = context.makeImage()
}
}
}
}
What’s happening in the ListRowModel
type? Let’s remember what we learned from building test-double types in our previous chapters. We already tested that our type to request the artwork for one Album
instance behaves correctly. We don’t need to test that again. For iterating on the implementation of our view, we don’t want to slow ourselves down with real network activity every time we change a little code. We want to be able to stub a network response (and implement our view on that stub). Our requestImage
method will just stub a gray image (and save that image to the instance variable). The dimensions (and color) of this image are arbitrary. You can experiment with different values as we build our view (and watch the effects live in Xcode Canvas).
Let’s define an array of Album
instances and implement the previews
type variable (with our ListRowModel
stub type injected at compile-time). The number of Album
instances (and properties of each Album
) are arbitrary. You can experiment with different instances (but we do want unique id
values so we remain consistent with the expectations of the List
type).
// AlbumsListRowView.swift
extension AlbumsListRowView_Previews {
static var albums: Array<Album> {
return [
Album(
id: "Rubber Soul",
artist: "Beatles",
name: "Rubber Soul",
image: "http://localhost/rubber-soul.jpeg"
),
Album(
id: "Pet Sounds",
artist: "Beach Boys",
name: "Pet Sounds",
image: "http://localhost/pet-sounds.jpeg"
),
]
}
}
extension AlbumsListRowView_Previews {
static var previews: some View {
List(
self.albums
) { album in
AlbumsListRowView<ListRowModel>(
model: ListRowModel(album: album)
)
}.listStyle(
.plain
)
}
}
Xcode Canvas fails to build. We need to finish implementing our AlbumsListRowView
type. Let’s start with a simple implementation just to see our Canvas compile correctly (and we can iterate on our implementation once we can see the updates live in Canvas).
// AlbumsListRowView.swift
extension AlbumsListRowView {
var body: some View {
Text("Hello, world!")
}
}
That works. Our Xcode Canvas is now displaying a list of views equal to the number of Album
instances we defined on our stub type. Let’s add a little work to display more state (and watch our updates live in Xcode Canvas).
// AlbumsListRowView.swift
extension AlbumsListRowView {
var body: some View {
Text(self.model.artist)
}
}
We can see that our view is correctly reading the artist
values set from our stub type (you can force Canvas to refresh with Command-Option-P). Let’s build a simple stack to display artist
and name
together.
// AlbumsListRowView.swift
extension AlbumsListRowView {
var body: some View {
VStack(
alignment: .leading,
spacing: 3
) {
Text(
self.model.artist
).foregroundColor(
.primary
).font(
.headline
)
Text(
self.model.name
).foregroundColor(
.secondary
).font(
.subheadline
)
}
}
}
We now see our VStack
correctly displaying artist
and name
from our Album
instances. Feel free to experiment with different font, spacing, or color values. Our next step will be to display the image
property from our Album
instances. We will start with something simple. While this tutorial is not intended to teach SwiftUI, you are welcome to experiment with more advanced layout to hide (or show) the image with a different technique.
extension AlbumsListRowView {
var body: some View {
HStack {
if let cgImage = self.model.image {
Image(
decorative: cgImage,
scale: 1.0,
orientation: .up
).resizable(
).aspectRatio(
contentMode: .fit
).frame(
width: 128,
height: 128,
alignment: .topLeading
)
}
VStack(
alignment: .leading,
spacing: 3
) {
Text(
self.model.artist
).foregroundColor(
.primary
).font(
.headline
)
Text(
self.model.name
).foregroundColor(
.secondary
).font(
.subheadline
)
}
}
}
}
We still see our artist
and name
properties, but our image
is missing. Let’s go back to look at our AlbumsListRowViewModel
type. We expect clients of this type to use the requestImage
method to asynchronously load the image
. In our ListRowModel
type, we just create a gray image (with no network request) and save this to our instance variable. SwiftUI views support Swift Concurrency with the task
instance method. We can implement task
to asynchronously load our image when this view appears. Since our type publishes its state with Combine, SwiftUI should correctly display the image once it becomes available.
// AlbumsListRowView.swift
extension AlbumsListRowView {
var body: some View {
HStack {
if let cgImage = self.model.image {
Image(
decorative: cgImage,
scale: 1.0,
orientation: .up
).resizable(
).aspectRatio(
contentMode: .fit
).frame(
width: 128,
height: 128,
alignment: .topLeading
)
}
VStack(
alignment: .leading,
spacing: 3
) {
Text(
self.model.artist
).foregroundColor(
.primary
).font(
.headline
)
Text(
self.model.name
).foregroundColor(
.secondary
).font(
.subheadline
)
}
}.task {
do {
try await self.model.requestImage()
} catch {
print(error)
}
}
}
}
Our layout now depends on an asynchronous task; make sure to enable Live Preview in Xcode Canvas.[^3] You will now see each album you defined along with plain gray artwork. We now have a custom SwiftUI view that makes correct use of a generic parameter (and instance variable) to display strings (synchronously) and an image (asynchronously).
While our
requestImage
has the option to throw an error, we only return a valid image. If the method did throw an error, we just pass the error to therequestImage
(similar to our previous chapters). Using the Xcode Canvas, you could verify your views are performing the correct behavior when that error is thrown.
While we didn’t write any unit-tests in this chapter, we also didn’t run any real network requests or image serializations. We injected a stub type to build our layout. If we inject a production type that have confidence behaves correctly, we can be confident our view will behave correctly.
[^1] Reid, Jon (2020). iOS Unit Testing by Example.
[^2]: Apple Inc. Previews in Xcode.
[^3]: Apple Inc. Creating Your App’s Interface with SwiftUI.