Imagine we’re building a mobile shopping app and we are tasked with implementing the products list screen. We’ve got a design specification and we are ready to go.

The obvious implementation

Most iOS engineers will immediately grab a UITableView, subclass a UITableViewCell, and start implementing the specified layout.

struct ProductViewModel {
    let title: String
    let price: String
    let image: UIImage
}

class ProductView: UITableViewCell {
    private let productImageView: UIImageView = {
        let imageView = UIImageView()
        imageView.translatesAutoresizingMaskIntoConstraints = false
        return imageView
    }()

    private let titleLabel: UILabel = {
        let label = UILabel()
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()

    private let priceLabel: UILabel = {
        let label = UILabel()
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)

        setupViews()
    }

    private func setupViews() {
        let labelsStack = UIStackView(arrangedSubviews: [
            titleLabel,
            priceLabel
        ])
        labelsStack.axis = .vertical
        labelsStack.translatesAutoresizingMaskIntoConstraints = false

        let mainStack = UIStackView(arrangedSubviews: [
            productImageView,
            labelsStack
        ])
        mainStack.axis = .horizontal
        mainStack.translatesAutoresizingMaskIntoConstraints = false

        contentView.addSubview(mainStack)

        NSLayoutConstraint.activate([
            productImageView.widthAnchor.constraint(equalToConstant: 40),
            productImageView.heightAnchor.constraint(equalToConstant: 40),

            mainStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16),
            mainStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
            mainStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
            mainStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16)
        ])
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func configure(with product: ProductViewModel) {
        titleLabel.text = product.title
        priceLabel.text = product.price
        productImageView.image = product.image
    }
}

// some UITableView intialization and setup code, skipped for brevity
// ...

// register
tableView.register(ProductTableViewCell.self, forCellReuseIdentifier: "ProductTableViewCell")

// dequeue
let cell = tableView.dequeueReusableCell(withIdentifier: "ProductTableViewCell", for: indexPath) as? ProductTableViewCell
cell?.configure(with: productViewModel)

This probably feels familiar. After all, this is how Apple taught us to build list layouts.

A new requirement

Everything looks good — until our designer pings us again after a few days and says:

“You know these product items that we have on the products list screen? How hard would it be to show them in a horizontally scrollable layout on the product categories screen? Please find the design specification attached:

A new implementation with more flexibility

At this point, we are thinking to ourselves we should have gone with the UICollectionView in the first place. Yes, it’s a bit more setup to make it work, but UICollectionView offers more flexibility with custom layouts.

So, we reimplement the same layout with a UICollectionView and a UICollectionViewCell.

class ProductCollectionViewCell: UICollectionViewCell {
    // all the same properties
    // ...

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupLayout()
    }

    private func setupLayout() {
      // all the same layout
      // ...
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func configure(with product: ProductViewModel) {
      // same configuration logic
      // ...
    }
}

// some UICollectionView initialization, setup, and layout configuration code, skipped for brevity
// ...

// register
collectionView.register(ProductCollectionViewCell.self, forCellWithReuseIdentifier: "ProductCollectionViewCell")

// dequeue
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ProductCollectionViewCell", for: indexPath) as? ProductCollectionViewCell
cell?.configure(with: product)

At this point, we are quite happy because the designer can’t surprise us anymore, and we can support any layout they can come up with.

A curveball - an unexpected requirement

Well, the designer does not disappoint and comes back with another request.

“We also need these product items inside an upsell popup. There are just two static product items in the popup. No scrolling.”

We have all the flexibility in the world now, but we also need a lot of boilerplate to spin up a full UICollectionView, just to render two static product items.

Ugh.

Maybe we could just stuff two UICollectionViewCells into a UIStackView. No one will know, right?

Naah, we can’t risk anyone seeing that with our name attached to it in the Git history.

Here is what we are going to do instead.

Unlocking additional flexibility

We will build our product item in a regular UIView:

class ProductView: UIView {
    // all the same properties
    // ...

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupLayout()
    }

    private func setupLayout() {
      // all the same layout
      // ...
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func configure(with product: ProductViewModel) {
      // same configuration logic
      // ...
    }
}

Now we can use ProductView in any context and layout we need.

Vertical list? No problem. We’ll embed it into a UITableViewCell:

class ProductTableViewCell: UITableViewCell {
    let productView = ProductView()

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)

        contentView.addSubview(productView)

        productView.translatesAutoresizingMaskIntoConstraints = false

        NSLayoutConstraint.activate([
            productView.topAnchor.constraint(equalTo: contentView.topAnchor),
            productView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            productView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            productView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
        ])
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

// Register
tableView.register(ProductTableViewCell.self, forCellReuseIdentifier: "ProductTableViewCell")

// Dequeue
let cell = tableView.dequeueReusableCell(withIdentifier: "ProductTableViewCell", for: indexPath) as? ProductTableViewCell
cell?.productView.configure(with: productViewModel)

Horizontal list? No sweat, we’ll embed it into a UICollectionViewCell:

class ProductCollectionViewCell: UICollectionViewCell {
    let productView = ProductView()

    override init(frame: CGRect) {
        super.init(frame: frame)

        contentView.addSubview(productView)

        productView.translatesAutoresizingMaskIntoConstraints = false

        NSLayoutConstraint.activate([
            productView.topAnchor.constraint(equalTo: contentView.topAnchor),
            productView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            productView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            productView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
        ])
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

// Register
collectionView.register(ProductCollectionViewCell.self, forCellWithReuseIdentifier: "ProductCollectionViewCell")

// Dequeue
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ProductCollectionViewCell", for: indexPath) as? ProductCollectionViewCell
cell?.productView.configure(with: productViewModel)

Static list with a few items? Piece of cake, we’ll use UIStackView:

class UpsellPopUpProductListViewController: UIViewController {
    private let product1View = {
        let productView = ProductView()
        productView.translatesAutoresizingMaskIntoConstraints = false
        return productView
    }()

    private let product2View = {
        let productView = ProductView()
        productView.translatesAutoresizingMaskIntoConstraints = false
        return productView
    }()

    private let stackView = {
        let stackView = UIStackView()
        stackView.axis = .vertical
        stackView.translatesAutoresizingMaskIntoConstraints = false
        return stackView
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.addSubview(stackView)
        stackView.addArrangedSubview(product1View)
        stackView.addArrangedSubview(product2View)

        NSLayoutConstraint.activate([
            stackView.topAnchor.constraint(equalTo: view.topAnchor, constant: 16),
            stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
            stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
            stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -16)
        ])
    }

    func configure(
        with productViewModel1: ProductViewModel,
        and productViewModel2: ProductViewModel
    ) {
        product1View.configure(with: productViewModel1)
        product2View.configure(with: productViewModel2)
    }
}

We can even reuse ProductView within SwiftUI with UIViewRepresentable:

import SwiftUI

struct ProductViewRepresentable: UIViewRepresentable {
    let product: ProductViewModel

    func makeUIView(context: Context) -> ProductView {
        return ProductView()
    }

    func updateUIView(_ uiView: ProductView, context: Context) {
        uiView.configure(with: product)
    }
}

Finally, let’s reduce boilerplate

Embedding a UIView into a cell type every time we need to display a list of items can be a bit repetitive. Luckily, we don’t need to do it manually. We can use generics and create a reusable container cell type:

public class ViewEmbeddingCollectionViewCell<EmbeddedView: UIView>: UICollectionViewCell {
    public let embeddedView: UIView = {
        let view = EmbeddedView(frame: .zero)
        view.translatesAutoresizingMaskIntoConstraints = false
        return view
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)

        contentView.addSubview(embeddedView)

        NSLayoutConstraint.activate([
            embeddedView.topAnchor.constraint(equalTo: contentView.topAnchor),
            embeddedView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            embeddedView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            embeddedView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
        ])
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

Usage of the container cell type is straightforward:

collectionView.register(
  ViewEmbeddingCollectionViewCell<ProductView>.self,
  forCellWithReuseIdentifier: "ProductCell"
)

let cell = collectionView.dequeueReusableCell(
    withReuseIdentifier: "ProductCell",
    for: indexPath
) as? ViewEmbeddingCollectionViewCell<ProductView>

cell?.embeddedView.configure(with: productViewModel)

This same generic pattern can be applied to UITablewViewCell as well.

With that, we have a solution that gives us

  • full reusability of our list item layout
  • zero duplication
  • and minimal glue code.

Summary

Subclassing UITableViewCell or UICollectionViewCell tightly couples our UI to its container. That’s fine until the same UI needs to show up in a new context.

By building our item layouts as standalone views, we gain:

  1. reusability – our UI can be used anywhere, not just in one type of cell.
  2. maintainability – our item layout code always lives in one place regardless of how many different contexts it is used in.

The next time you reach for a UITableViewCell or UICollectionViewCell, consider implementing a plain UIView first.

References