Etsy Logo

Code as Craft

Improving the iOS Listing Screen with Generics main image

Improving the iOS Listing Screen with Generics

  image

iOS Listing Screen Snapshot

We launched Etsy’s iOS app way back in 2011, so we have a long and fruitful history in the App Store. The listing screen, where buyers go to explore Etsy’s wealth of unique handmade items, is naturally right at the center of the app experience. (It currently captures at least 16% of all traffic on the app.) As the mobile landscape has evolved, we’ve evolved the listing screen to keep pace, as a priority for the app’s continued success.

A common experience with long-lived codebases is that performance can become an issue–older code may not be architected to take full advantage of platform optimizations and new features. Developer productivity can suffer, too. It’s harder to onboard developers to code that’s been modified and extended repeatedly over many years, and harder to validate and test changes.

When the Etsy app was first written, screens were relatively simple things with limited responsibilities. It made sense to code them more or less monolithically. But as we looked at performance, and considered our engineers’ pain points, we began to recognize that it was time to move past the iOS listing screen’s legacy architecture. Last year we decided to roll up our sleeves and make it happen.

Refactoring with the collection view

In our legacy implementation, the larger and more complex the listing screen got, the more its performance suffered: assembling a single huge view forces the app to perform a lot of layout computations upfront, including some that could be better deferred. A much less costly and more flexible approach would involve rethinking the screen as a collection of independent views to be handed off to a layout manager, which would only compute the views as needed.

We decided to refactor our code around the collection view, an abstract representation of a reusable cell that relies on an associated collection view layout object to make decisions about when and how layout will happen. Generally, you expect a collection view to map to only one type of cell, but sections of our listing screen are composed of different data and different visual representations. And while that doesn’t make our use of a collection view problematic, still as the snippet below suggests, if you’re handling disparate cell types things could get messy:

let cellType = CellType(indexPath)

switch cellType {
   case .type1:
     let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier0, for: indexPath) as?  MyCellType0
     cell.viewModel = getViewModelFor(indexPath)
     return cell
   case .type2:   
     let cell = collectionView.dequeueReusableCell(withReuseIdentifier: reuseIdentifier1, for: indexPath) as?  MyCellType1
     cell.viewModel = getViewModelFor(indexPath)
     return cell
   ..
   // And so one with each of your different cell types
    default: 
       return nil
}

Imagine the lengths to which that switch statement could grow! Every new cell adds a new block to it, and also requires a corresponding modification to the CellType enum.

From a developer perspective, this first-pass collection view implementation already improves on legacy code by giving us a single insertion point for our concerns about screen sections. But there’s still a lot to keep track of and a lot of potential for mistakes: adding logic to the switch statement, associating the correct view model, setting up the right cell identifier, and so on. We’d like to make the act of adding or removing sections of the listing screen as simple as possible, so our engineers can focus their time and attention on the work of actually coding those sections.

Making the cell configuration generic

Looking at that switch statement, there’s clearly a common pattern involved with configuring a cell, regardless of type:

  • dequeue a cell with the proper identifier
  • cast the cell to the appropriate type
  • assign the corresponding view model
  • return the cell.

These are exactly the repetitive steps we want to save our engineers having to go through every time they add a new section to the listing screen. Using Swift Protocols and Generics, we should be able to implement an abstraction that will handle these configuration steps for any cell regardless of type.

First, let’s remove the need for hard coding cell identifiers with a common extension:

extension UICollectionViewCell {
    static var defaultReuseIdentifier: String { String(describing: self) }
}

It would also be useful to have a common way to set the view model for the cell and bind its type to the cell type:

protocol ConfigurableCollectionViewCell: UICollectionViewCell {
    associatedtype ViewModelType: AnyObject

    var viewModel: ViewModelType? { get set }
}

Now let’s further define a protocol allowing the generic configuration of cells:

protocol CollectionViewCellConfigurator {
    func registerAndDequeueConfiguredCell(for collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCell?
}

This is the method that will perform our configuration steps and then register the cell type in the collection view. The method signature has the usual parameters required for configuring a cell from a data source: the target collection view and the cell’s index path.

At this point it is not clear how the cell’s identifier, type and view model will be used in this method. We’re going to get there by defining one more protocol, this one inheriting from CollectionViewCellConfigurator:

protocol CollectionViewCellModel: CollectionViewCellConfigurator {
    associatedtype Cell: ConfigurableCollectionViewCell

    var viewModel: Cell.ViewModelType? { get }
    var cellType: Cell.Type { get }
    init(cellType: Cell.Type, viewModel: Cell.ViewModelType?)

}

Notice that CollectionViewCellModel constrains the associated type Cell to our ConfigurableCollectionViewCell. The trick of this protocol is that its realization will pack all the configuration information we need:

  • the cell’s type
  • the cell’s associated view model
  • the configuration procedure itself (from implementing CollectionViewCellConfigurator).

Let’s look at a default implementation for the CollectionViewCellModel protocol:

extension CollectionViewCellModel {

    func registerAndDequeueConfiguredCell(for collectionView: UICollectionView, indexPath: IndexPath) -> UICollectionViewCell? {

        // Register the cell in the collection view
        collectionView.register(Cell.self, forCellWithReuseIdentifier: Cell.defaultReuseIdentifier)

        // Dequeue the cell and cast to the appropriate type
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Cell.defaultReuseIdentifier, for: indexPath) as? Cell

        // Assign the view model (the type is bound by Cell)
        cell?.viewModel = viewModel
        return cell
    }
}

This code performs the same tasks as the previous switch statement–registering the cell in the collection view, dequeuing and type-casting it, assigning the view model and returning the cell–but now we’re relying on the configuration data being supplied by our newly defined protocols.

Why are we using the CollectionViewCellConfigurator protocol as intermediate instead of defining the registerAndDequeueConfiguredCell method directly in CollectionViewCellModel. Associated types generate a type constraint that can’t be used anonymously: without the intermediate protocol, engineers would have to define a new type every time they had a new cell to configure, exactly the kind of repetitive and potentially error-prone task we’re trying to avoid.

Packing generics

Now that we have a well-designed generic configuration framework, we need to provide a way to conform to the protocol and pack our object instances, providing the cell type and the associated view model instance. A struct is a good choice here:

struct CollectionViewDefaultCellModel<T: ConfigurableCollectionViewCell>: CollectionViewCellModel {

    typealias Cell = T
    var cellType: T.Type
    unowned var viewModel: Cell.ViewModelType?

    init(cellType: Cell.Type, viewModel: Cell.ViewModelType?) {
        self.cellType = cellType
        self.viewModel = viewModel
    }
}

This struct in this example is itself a CollectionViewCellConfigurator, so it can be referenced by protocol. Since we are packing the cell configuration process within a struct we need another type referring to instances of this struct as CollectionViewCellConfigurator adopters:

class CollectionViewItem: NSObject {
    var cellConfigurator: CollectionViewCellConfigurator

    required init(configurator: CollectionViewCellConfigurator) {
        self.cellConfigurator = configurator
    }
}

Given that CollectionViewItem is a concrete type (no type constraints here!), it can be packed into arrays and sets, and even used for a diffable data source (since NSObject conforms to Hashable).

The arrangement of these protocols and classes can be seen below:

Class Diagram
Class diagram of protocols and classes used for the generics implementation

Let’s use it!

Let’s take a look at how we can use CollectionViewItem to return configured cells for the collection view. If you’re adopting the UICollectionViewDataSource protocol for your implementation, you’ll be writing something like this:

// Inside the code for dequeing the cell
let item = getItem(for: indexPath)
return item.cellConfigurator.registerAndDequeueConfiguredCell(collectionView: collectionView, indexPath: indexPath)

That’s it! Even if you have newer cell types this code will still hold. All you need to worry about when you’re instantiating a CollectionViewDefaultCellModel object is the association of the cell type with the view model. If you’re using a diffable data source then it’s even easier to configure the cell, a one-liner provided you’re using a CollectionViewItem:

var dataSource = DiffableDataSource(collectionView: collectionView, cellProvider: {  (collectionView, indexPath, item) -> UICollectionViewCell? in
        return item.cellConfigurator.registerAndDequeueConfiguredCell(collectionView: collectionView, indexPath: indexPath)
    })

If you’re adding a new cell type, say MyAwesomeCollectionViewCell with a view model of type MyViewModel, the following snippet offers an implementation conforming to ConfigurableCollectionViewCell:

final class MyAwesomeCollectionViewCell: ConfigurableCollectionViewCell {

    typealias ViewModelType = MyViewModel

    var viewModel: MyViewModel? {
        didSet {
            updateUI()
        }
    }

    // MARK: - Update the UI

    private func updateUI() {
        // Do your magic with the view model
    }

   // More methods
}

Now, when setting up your data, just pack this relationship in a collection view item:

func setupMyAwesomeCollectionViewCellItem(for indexPath: IndexPath, _ viewModel: MyViewModel?) {
    let cellConfigurator = CollectionViewDefaultCellModel(cellType: MyAwesomeCollectionViewCell.self,
                                                          viewModel: viewModel)
    let item = CollectionViewItem(configurator: cellConfigurator)
    associate(item, with: indexPath)
}

The associate method should be the inverse of getItem(for:) from the previous example. Again if you’re using a diffable data source this will be even simpler, because you can add the item directly to the appropriate section.

Wins

Moving to an intrinsically more performant architecture was a big win in itself, for a screen that gets used several times in a session across millions of user sessions. But from an engineer’s standpoint, refactoring the listing screen with generic cell configuration opens up a lot of opportunities. We now have a pluggable system that allows new sections of the listing screen to be added without dealing with the overhead of identifiers, cell registration, dequeuing and casting cells. All of it happens automatically behind the scenes! There’s also the added security of compile-time checking: thanks to the strong typing in our architecture, trying to inject a view model into an incorrect cell type is immediately and trivially flagged.

With a well-organized codebase taking repetitive and cumbersome tasks out of their hands, our engineers can focus their time and attention on the important task: continuing to evolve features for the listing screen to delight users and help them decide their next purchases on Etsy.