Julian Tejera-Frias in iOS, Swift, Command Pattern, UICollectionView

Command Patterns and UICollectionView

OkCupid is hiring for iOS Click here to learn more

Working at OkCupid I've learned a lot about the science of dating. However, anytime I have to go on a date I'm always scrambling to figure out what to wear. Maybe a plain solid t-shirt and khaki pants, perhaps dark jeans and a dress shirt? 🤔. The point is, organizing data and presenting it using the right custom layout can be tough.

I may not have all the answers when it comes to your dating life. However, I can offer some guidance on how to write elegant, maintainable and testable UICollectionView code (which is cheaper than an in-house stylist). To achieve this goal, we're going to use the Command Pattern to create an abstraction that will allow us to dequeue, configure, and handle the selection of cells without knowing their concrete types.

UICollectionView is an incredibly flexible tool provided in UIKit. Apple defines it as

An object that manages an ordered collection of data items and presents them using customizable layouts.

They can be fairly straightforward, as long as you represent your data in the same way. UICollectionViewDataSource and UICollectionViewDelegate grow in complexity when we register multiple UICollectionViewCell, which need to be configured in their own unique way. If you've been working with iOS for a while, I'm pretty sure you have witnessed horrifying ( 🤡 ) implementations of func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell.

At OkCupid, we're constantly exploring new ways of writing elegant, maintainable and testable UICollectionView code. To achieve this goal, we're going to use the Command Pattern to create an abstraction that will allow us to dequeue, configure, and handle the selection of cells without knowing their concrete types.

Users Feed

In our sample project, we are going to display users in a feed along with ads. This is a fairly common scenario that many developers face today.

The heterogeneous content displayed in the feed is the following:

  • Regular users will be displayed using the Snapshot cell (see Tongue Twister). It has a background image, avatar and a label for the username.
  • Featured users will be displayed using the Circular cell (see Nerd). It only has an avatar and a label for the username.
  • Ads will be intertwined with our users. For the MVP, we are only going to include image ads.

Let's get to work ☺️ !!!

Decoupling cells

To decouple the configuration of the collection view cells and the data source / delegate, we need to employ the abstractions detailed below.

Command Pattern

This behavioral design pattern encapsulates a request into an object in order to decouple the concrete implementation from the invoker.

This pattern is key to simplify our cell configuration, cancellation, or any other specific requests.

protocol CollectionViewCellCommand {  
    func perform(cell: UICollectionViewCell)
}

Given that UICollectionViewCells are reusable, the commands need to take in a cell as an argument.

View Model

The CollectionViewCellViewModel contains all the information required to dequeue, display and interact with our cells.

struct CollectionViewCellViewModel {  
    let identifier: String
    let size: CGSize
    let commands: [CollectionViewCellCommandKey: CollectionViewCellCommand]
}

enum CollectionViewCellCommandKey {  
    case configuration
    case cancellation
    case selection
    // You can add more cases to handle deselection or any other interaction
}

All the concrete details about the operations are held in the commands, which allows our view model to be used in any UICollectionView with any type of underlying model (isn't low coupling cool?). It means the view model is independent of any class or actions.

Heterogeneous data is the main driver of conditional branch statements because each case needs to be handled differently due to business requirements or just mere data incompatibility. CollectionViewCellViewModel becomes the de facto standard representation for any data or behavior that needs to be visually represented through an UICollectionView.

How to use the commands:

They are really easy to use! CollectionViewCellCommand can be executed in any context as long as the appropriate arguments are passed. They generally correspond to methods specified in the UICollectionViewDataSource or UICollectionViewDelegate protocols.

Configuration Command

The identifier of a CollectionViewCellModel is used to dequeue the appropriate cell from the UICollectionView. A particular command from the view model can be picked by using a CollectionViewCellCommandKey, in this case the .configuration key.

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {  
    let viewModel = viewModels[indexPath.item]
    // The cell is dequeued based on the identifier tied to the view model
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: viewModel.identifier, for: indexPath)
    viewModel.commands[.configuration]?.perform(cell: cell)

    return cell
}

Note that optional chaining safely handles the case of a missing command for the .configuration key.

Selection Command

The process for choosing a command consists on getting the viewModel for an IndexPath and then using the appropriate CollectionViewCellCommandKey. The .selection key corresponds to didSelectItemAt

func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {  
    guard let cell = collectionView.cellForItem(at: indexPath) else {
        return
    }

    let viewModel = viewModels[indexPath.item]
    viewModel.commands[.selection]?.perform(cell: cell)
}

Macro Commands

In the sample project, we're pushing a view controller when an user cell gets selected. If we also wanted to track an analytics event, we could put it all together in a single command but that would break the single responsibility principle. To avoid this pitfall, we can create a MacroCommand that contains an array of multiple commands.

struct CollectionViewCellMacroCommand: CollectionViewCellCommand {

    private let commands: [CollectionViewCellCommand]

    init(commands: [CollectionViewCellCommand]) {
        self.commands = commands
    }

    func perform(cell: UICollectionViewCell) {
        commands.forEach { $0.perform(cell: cell) }
    }
}

User Snapshot Cell

A factory serves as the ideal abstraction to create a view model for our Snapshot Cell. In it, we can specify the size and identifier of the cell as well as any commands.

struct UserSnapshotCollectionViewCellViewModelFactory {

    func create(user: User) -> CollectionViewCellViewModel {
        let size = CGSize(width: 300, height: 200)
        let configurationCommand = UserSnapshotCollectionViewCellConfigurationCommand(user: user, imageNetworkManager: ImageNetworkManager())
        let commands: [CollectionViewCellCommandKey: CollectionViewCellCommand] = [
            .configuration: configurationCommand
            // Additional (CommandKey, Command) key-value pairs can be added here to address different scenarios (selection, deselection, etc.)
        ]
        return CollectionViewCellViewModel(identifier: "UserSnapshotCollectionViewCell", size: size, commands: commands)
    }

}

To configure the User Snapshot Cell, we need to create a type that adheres to the CollectionViewCellCommand protocol. Commands are typically initialized with the state required to execute the request, which means we need to inject all the dependencies into them.

⚠️ Take into account potential retain cycles when injecting objects into commands. Remember to use weak when appropriate ⚠️

struct UserSnapshotCollectionViewCellConfigurationCommand: CollectionViewCellCommand {

    // Internal state required to perform the command
    private let user: User
    private let imageNetworkManager: ImageNetworkManagerProtocol

    init(user: User, imageNetworkManager: ImageNetworkManagerProtocol = ImageNetworkManager()) {
        self.user = user
        self.imageNetworkManager = imageNetworkManager
    }

    // This is where the magic happens 🎩
    func perform(cell: UICollectionViewCell) {
        guard let cell = cell as? UserSnapshotCollectionViewCell else {
            return
        }

        cell.usernameLabel.text = user.username

        _ = imageNetworkManager.request(url: user.avatarUrl) { (image) in
            cell.avatarImageView.image = image
        }

        if let backgroundUrl = user.backgroundUrl {
            _ = imageNetworkManager.request(url: backgroundUrl) { (image) in
                cell.backgroundImageView.image = image
            }
        }

    }

}

The configuration command requests images for the avatarImageView and the backgroundImageView through the imageNetworkManager. It also updates the usernameLabel.

We can now use the factory in the UsersViewController to transform users to CollectionViewCellViewModel.

class UsersViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {

    @IBOutlet weak var collectionView: UICollectionView! {
        didSet {
            collectionView.delegate = self
            collectionView.dataSource = self
        }
    }

    var viewModels = [CollectionViewCellViewModel]()

    override func viewDidLoad() {
        super.viewDidLoad()
        setupViewModels()
    }

    func setupViewModels() {
        let userFactory = UserSnapshotCollectionViewCellViewModelFactory()
        let userViewModels = User.all.map(userFactory.create)
        viewModels.append(contentsOf: userViewModels)
    }

Ad Cell

To represent ads, we are going to use a Struct that contains a contentUrl and a clickthroughUrl.

struct Advertisement {  
    let id: Int
    let contentUrl: URL
    let clickthroughUrl: URL
    let type: AdvertisementType

    // Test data
    static var all: [Advertisement] {
        return [
            Advertisement(id: 1, contentUrl: URL(string: "https://cdn.okccdn.com/media/img/hub/mediakit/okcupid_darkbg.png")!, clickthroughUrl: URL(string: "https://okcupid.com/home")!, type: .image)
        ]
    }
}

enum AdvertisementType {  
    case image
    case video
    case audio
}

The image ads will be visually represented by the AdvertisementCollectionViewCell, which just includes an UIImageView. When we tap on this cell, the clickthrough url will be opened by the UIApplication. The SelectionCommand requires the UIApplication and the Advertisement to execute.

import UIKit

struct AdvertisementCollectionViewCellSelectionCommand: CollectionViewCellCommand {

    private let application: UIApplication
    private let advertisement: Advertisement

    init(advertisement: Advertisement, application: UIApplication) {
        self.application = application
        self.advertisement = advertisement
    }

    func perform(cell: UICollectionViewCell) {

        if application.canOpenURL(advertisement.clickthroughUrl) {
            application.open(advertisement.clickthroughUrl, options: [:], completionHandler: nil)
        }

    }
}

The AdvertisementCollectionViewCellViewModelFactory is responsible for the transformation of an Advertisement into a CollectionViewCellViewModel. Just like in the User -> ViewModel case, the factory defines the size, identifier and supported commands.

struct AdvertisementCollectionViewCellViewModelFactory {

    func create(advertisement: Advertisement, application: UIApplication) -> CollectionViewCellViewModel {
        let size = CGSize(width: 220, height: 220)
        let configurationCommand = AdvertisementCollectionViewCellConfigurationCommand(advertisement: advertisement, imageNetworkManager: ImageNetworkManager())
        let selectionCommand = AdvertisementCollectionViewCellSelectionCommand(advertisement: advertisement, application: application)
        let commands: [CollectionViewCellCommandKey: CollectionViewCellCommand] = [
            .configuration: configurationCommand,
            .selection: selectionCommand
        ]

        return CollectionViewCellViewModel(identifier: "AdvertisementCollectionViewCell", size: size, commands: commands)
    }

}

To incorporate these changes into the UsersViewController, we can modify the setupViewModels() function by adding a few lines:

let advertisementFactory = AdvertisementCollectionViewCellViewModelFactory()  
let ads = Advertisement.all.map { advertisementFactory.create(advertisement: $0, application: .shared) }  
viewModels.insert(contentsOf: ads, at: viewModels.count / 2)  

Conclusions

By applying the command pattern abstraction the responsibilities become clearly defined by separating each component into classes / types that serve a single purpose. Having classes that do one thing makes it less mentally taxing for Software Engineers to reason about it, even more so for anybody that is not familiar with the codebase. It also facilitates the inclusion of unit tests because of the low coupling of the Model, View and Controller layers.

As you may have noticed, adding different types of cells barely modified the UIViewController. The Command pattern also opens the door for us to create undoable operations, which can come very handy if a collection view possesses cells with modifiable input fields that support clearing pending changes.

Closures are becoming increasingly popular, and functional programming adoption is higher than ever. However, commands are an object-oriented replacement for callbacks, they are first-class objects that can be manipulated and extended.

This is what the UserViewController looks like after applying all the changes.

import UIKit

class UsersViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {

    @IBOutlet weak var collectionView: UICollectionView! {
        didSet {
            collectionView.delegate = self
            collectionView.dataSource = self
        }
    }

    var viewModels = [CollectionViewCellViewModel]()

    override func viewDidLoad() {
        super.viewDidLoad()
        setupViewModels()
    }

    func setupViewModels() {
        let userFactory = UsersCollectionViewCellViewModelFactory()
        let advertisementFactory = AdvertisementCollectionViewCellViewModelFactory()

        let userViewModels = User.all.map { userFactory.create(user: $0, viewController: self) }
        viewModels.append(contentsOf: userViewModels)
        let ads = Advertisement.all.map { advertisementFactory.create(advertisement: $0, application: .shared) }
        viewModels.insert(contentsOf: ads, at: viewModels.count / 2)
    }

    // MARK: - UICollectionViewDataSource

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return viewModels.count
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {

        let viewModel = viewModels[indexPath.item]
        // The cell is dequeued based on the identifier tied to the view model
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: viewModel.identifier, for: indexPath)
        viewModel.commands[.configuration]?.perform(cell: cell)

        return cell
    }

    // MARK: UICollectionViewDelegate

    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        guard let cell = collectionView.cellForItem(at: indexPath) else {
            return
        }

        let viewModel = viewModels[indexPath.item]
        viewModel.commands[.selection]?.perform(cell: cell)
    }

    func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
        let viewModel = viewModels[indexPath.item]
        viewModel.commands[.cancellation]?.perform(cell: cell)
    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        // The appropriate size is defined by the view model
        return viewModels[indexPath.item].size
    }

}

What do you think about the Command Pattern 🤔 ? Would you use it in your next project? Join the conversation on reddit!