Skip to content

Chapter 10: Albums List Row View

Rick van Voorden edited this page Nov 8, 2021 · 1 revision

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 the print function. If you would like to experiment with custom UI to display an error to your customer, you could stub an error in requestImage (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.