Why I never subclass UITableViewCell or UICollectionViewCell
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 UICollectionViewCell
s 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:
- reusability – our UI can be used anywhere, not just in one type of cell.
- 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.