Custom Cells
Create custom cell layouts using Auto Layout for complete visual control.
The default DataCell displays text — that's it. When you need richer content — avatars, status badges, progress bars, action buttons — you need custom cells. SwiftDataTables lets you use any UICollectionViewCell subclass with full Auto Layout support.
What Custom Cells Unlock
- Images and icons — User avatars, product thumbnails, status icons
- Multiple elements per cell — Name + subtitle, label + badge, icon + text
- Interactive controls — Buttons, switches, steppers that users can tap
- Custom layouts — Horizontal stacks, vertical arrangements, any Auto Layout design
- Dynamic content — Progress bars, charts, live-updating elements
When to Use Custom Cells vs defaultCellConfiguration
| Need | Solution |
|---|---|
| Different font, color, or alignment | defaultCellConfiguration — much simpler |
| Conditional styling (red for negative numbers) | defaultCellConfiguration |
| Image alongside text | Custom cell |
| Multiple labels in one cell | Custom cell |
| Buttons or interactive elements | Custom cell |
| Complex Auto Layout design | Custom cell |
Start with defaultCellConfiguration. Only create custom cells when you need layout changes, not just style changes.
Building a Custom Cell: Complete Example
Let's build a user cell with an avatar, name, and status indicator.
Step 1: Create the Cell Class
class UserCell: UICollectionViewCell {
static let reuseIdentifier = "UserCell"
private let avatarView = UIImageView()
private let nameLabel = UILabel()
private let statusDot = UIView()
override init(frame: CGRect) {
super.init(frame: frame)
setupViews()
setupConstraints()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupViews() {
avatarView.contentMode = .scaleAspectFill
avatarView.clipsToBounds = true
avatarView.layer.cornerRadius = 16
avatarView.backgroundColor = .systemGray5
nameLabel.font = .systemFont(ofSize: 14, weight: .medium)
nameLabel.numberOfLines = 1
statusDot.layer.cornerRadius = 4
contentView.addSubview(avatarView)
contentView.addSubview(nameLabel)
contentView.addSubview(statusDot)
}
private func setupConstraints() {
avatarView.translatesAutoresizingMaskIntoConstraints = false
nameLabel.translatesAutoresizingMaskIntoConstraints = false
statusDot.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
// Avatar: 32x32, vertically centered, 8pt from leading
avatarView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8),
avatarView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
avatarView.widthAnchor.constraint(equalToConstant: 32),
avatarView.heightAnchor.constraint(equalToConstant: 32),
// Name: after avatar, vertically centered
nameLabel.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 8),
nameLabel.trailingAnchor.constraint(equalTo: statusDot.leadingAnchor, constant: -8),
nameLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
// Status dot: 8x8, trailing edge, vertically centered
statusDot.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8),
statusDot.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
statusDot.widthAnchor.constraint(equalToConstant: 8),
statusDot.heightAnchor.constraint(equalToConstant: 8),
// Cell height: at least 48pt
contentView.heightAnchor.constraint(greaterThanOrEqualToConstant: 48)
])
}
func configure(name: String, avatarURL: URL?, isOnline: Bool) {
nameLabel.text = name
statusDot.backgroundColor = isOnline ? .systemGreen : .systemGray
// Load avatar image (use your preferred image loading library)
if let url = avatarURL {
// avatarView.loadImage(from: url)
}
}
override func prepareForReuse() {
super.prepareForReuse()
avatarView.image = nil
nameLabel.text = nil
}
}Key patterns in this cell:
- Static reuse identifier — Defines a constant string the collection view uses to recycle cells. Must match what you register later.
- Frame-based init — Collection views create cells programmatically, so implement
init(frame:)and call your setup methods. - Add to contentView — Always add subviews to
contentView, not toself. This is required for Auto Layout measurement. - Height constraint — The
greaterThanOrEqualToConstant: 48tells Auto Layout the minimum height. Without this, automatic row heights can't calculate the cell size. - prepareForReuse — Cells are recycled. Clear old content here so scrolled-in cells don't show stale data.
Step 2: Create the Cell Provider
DataTableCustomCellProvider connects your cells to the table. It has four closures:
| Closure | Purpose | Called |
|---|---|---|
register | Register cell classes with the collection view | Once at setup |
reuseIdentifierFor | Return which cell type to use for each position | For every cell |
configure | Set up the cell with its data | When cell is dequeued |
sizingCellFor | Provide an off-screen cell for height measurement | Once per cell type |
// Cache sizing cells for performance
private var sizingCells: [String: UICollectionViewCell] = [:]
let provider = DataTableCustomCellProvider(
register: { collectionView in
collectionView.register(UserCell.self, forCellWithReuseIdentifier: UserCell.reuseIdentifier)
},
reuseIdentifierFor: { indexPath in
// indexPath.section = column index
// indexPath.row = data row index
if indexPath.section == 0 {
return UserCell.reuseIdentifier
}
return "DataCell" // Default cell for other columns
},
configure: { cell, value, indexPath in
guard let userCell = cell as? UserCell else { return }
// value.stringRepresentation gives you the text
// For more data, access your model directly (see below)
userCell.configure(
name: value.stringRepresentation,
avatarURL: nil,
isOnline: true
)
},
sizingCellFor: { [weak self] identifier in
// Return cached cell or create new one
if let cached = self?.sizingCells[identifier] {
return cached
}
let cell = identifier == UserCell.reuseIdentifier ? UserCell() : DataCell()
self?.sizingCells[identifier] = cell
return cell
}
)Let's break down what each closure does:
- register — Called once when the table initialises. Register every custom cell class you plan to use. You don't need to register
DataCell— it's built in. - reuseIdentifierFor — Called for every cell position. The
indexPath.sectionis the column (0 = first column), andindexPath.rowis the data row. Return your custom identifier for columns you want to customise, or"DataCell"for standard text. - configure — Called whenever a cell is about to appear. The
valueparameter is the column value (e.g., the user's name). Cast the cell to your type and populate it. - sizingCellFor — Provides a cell instance for Auto Layout measurement. This cell is never displayed — it's just used to calculate heights. Cache these to avoid creating new instances on every scroll.
Step 3: Configure the Table
var config = DataTableConfiguration()
config.cellSizingMode = .autoLayout(provider: provider)
config.rowHeightMode = .automatic(estimated: 48)
let columns: [DataTableColumn<User>] = [
.init("User", \.name), // Uses UserCell
.init("Email", \.email), // Uses default DataCell
.init("Role", \.role) // Uses default DataCell
]
let dataTable = SwiftDataTable(data: users, columns: columns, options: config)The two critical config settings:
- cellSizingMode = .autoLayout(provider:) — Tells the table to use your custom cell provider instead of the default text cells.
- rowHeightMode = .automatic(estimated:) — Enables self-sizing rows. The estimated value (48 in this case) is used initially, then Auto Layout calculates the actual height. Match this to your typical cell height for smooth scrolling.
Accessing Your Typed Model
The configure closure only gives you the cell value as DataTableValueType. To access your full model (for avatars, complex data, etc.), use model(at:):
configure: { [weak self] cell, value, indexPath in
guard let userCell = cell as? UserCell,
let user: User = self?.dataTable.model(at: indexPath.row) else {
return
}
// Now you have access to the full User model
userCell.configure(
name: user.name,
avatarURL: user.avatarURL,
isOnline: user.isOnline
)
}The value parameter in configure only contains the string representation of that column's data. If your cell needs multiple properties from your model (like an avatar URL alongside the name), use model(at:) to retrieve the full object. Specify the type explicitly (User in this example) so Swift can infer the return type.
Different Cells for Different Columns
You can mix custom cells and default cells in the same table. Use indexPath.section (the column index) to decide which cell type each column should use:
let provider = DataTableCustomCellProvider(
register: { collectionView in
collectionView.register(AvatarCell.self, forCellWithReuseIdentifier: "avatar")
collectionView.register(StatusBadgeCell.self, forCellWithReuseIdentifier: "status")
collectionView.register(ActionButtonCell.self, forCellWithReuseIdentifier: "action")
// DataCell is registered automatically
},
reuseIdentifierFor: { indexPath in
switch indexPath.section {
case 0: return "avatar" // First column: user avatar
case 3: return "status" // Fourth column: status badge
case 5: return "action" // Sixth column: action buttons
default: return "DataCell" // Everything else: default text
}
},
configure: { cell, value, indexPath in
switch cell {
case let avatarCell as AvatarCell:
avatarCell.configure(imageURL: URL(string: value.stringRepresentation))
case let statusCell as StatusBadgeCell:
statusCell.configure(status: value.stringRepresentation)
case let actionCell as ActionButtonCell:
actionCell.onTap = { print("Tapped row \(indexPath.row)") }
default:
break
}
},
sizingCellFor: { identifier in
switch identifier {
case "avatar": return AvatarCell()
case "status": return StatusBadgeCell()
case "action": return ActionButtonCell()
default: return DataCell()
}
}
)This pattern keeps your table flexible: avatar images in the first column, plain text for names and emails in the middle, status badges where they make sense, and action buttons at the end. The default case falls through to DataCell, so any column you don't explicitly handle gets standard text rendering.
Interactive Cells: Buttons and Actions
Cells can contain interactive elements like buttons, switches, or steppers. The challenge is that cells are reused — so you can't just wire up an action once. Instead, use closures that get set in configure each time the cell appears:
class ActionButtonCell: UICollectionViewCell {
static let reuseIdentifier = "ActionButtonCell"
private let editButton = UIButton(type: .system)
private let deleteButton = UIButton(type: .system)
var onEdit: (() -> Void)?
var onDelete: (() -> Void)?
override init(frame: CGRect) {
super.init(frame: frame)
editButton.setTitle("Edit", for: .normal)
editButton.addTarget(self, action: #selector(editTapped), for: .touchUpInside)
deleteButton.setTitle("Delete", for: .normal)
deleteButton.setTitleColor(.systemRed, for: .normal)
deleteButton.addTarget(self, action: #selector(deleteTapped), for: .touchUpInside)
let stack = UIStackView(arrangedSubviews: [editButton, deleteButton])
stack.spacing = 12
stack.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(stack)
NSLayoutConstraint.activate([
stack.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
stack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor)
])
}
required init?(coder: NSCoder) { fatalError() }
@objc private func editTapped() { onEdit?() }
@objc private func deleteTapped() { onDelete?() }
override func prepareForReuse() {
super.prepareForReuse()
onEdit = nil
onDelete = nil
}
}The key parts: onEdit and onDelete are closures that get called when the buttons are tapped. The cell doesn't know what to do — it just calls the closure. This keeps the cell reusable. Note that prepareForReuse sets both closures to nil — this prevents a recycled cell from accidentally triggering an old action.
Wire up the actions in configure:
configure: { [weak self] cell, value, indexPath in
guard let actionCell = cell as? ActionButtonCell else { return }
actionCell.onEdit = {
self?.editItem(at: indexPath.row)
}
actionCell.onDelete = {
self?.deleteItem(at: indexPath.row)
}
}Each time the cell appears, you set the closures to capture the current indexPath.row. When the user taps Edit or Delete, your view controller's methods get called with the correct row index. Use [weak self] to avoid retain cycles.
Auto Layout Requirements
For automatic row heights to work, your cell must have constraints that define its height:
- Add subviews to
contentView— not directly to the cell - Pin to contentView edges — top, bottom, leading, trailing as needed
- Define intrinsic height — through content (labels, images) or explicit constraints
- Use
greaterThanOrEqualTo— for minimum heights
Common mistake If rows all have the same height regardless of content, check that you're adding constraints to contentView, not to self. The cell's content view is what gets measured.
Performance: Sizing Cell Cache
The sizingCellFor closure is called during height measurement. Creating a new cell each time is expensive. Cache them:
class MyViewController: UIViewController {
private var sizingCells: [String: UICollectionViewCell] = [:]
private lazy var provider = DataTableCustomCellProvider(
// ...
sizingCellFor: { [weak self] identifier in
if let cached = self?.sizingCells[identifier] {
return cached
}
let cell: UICollectionViewCell
switch identifier {
case UserCell.reuseIdentifier: cell = UserCell()
case StatusCell.reuseIdentifier: cell = StatusCell()
default: cell = DataCell()
}
self?.sizingCells[identifier] = cell
return cell
}
)
}Troubleshooting
Cells not appearing
- Check registration — Cell class must be registered in the
registerclosure - Verify reuse identifier — Must match exactly between registration and
reuseIdentifierFor - Cast correctly — Use
as?to safely cast inconfigure
All rows same height
- Check constraints — Must define height through content or explicit constraints
- Add to contentView — Subviews go in
contentView, notself - Enable automatic heights — Set
rowHeightMode = .automatic(estimated:)
Buttons not responding
- Check user interaction —
contentView.isUserInteractionEnabledmust betrue - Wire up in configure — Closures must be set each time cell is dequeued
- Clear in prepareForReuse — Prevents stale closures from previous rows