GitHub

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

NeedSolution
Different font, color, or alignmentdefaultCellConfiguration — much simpler
Conditional styling (red for negative numbers)defaultCellConfiguration
Image alongside textCustom cell
Multiple labels in one cellCustom cell
Buttons or interactive elementsCustom cell
Complex Auto Layout designCustom 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 to self. This is required for Auto Layout measurement.
  • Height constraint — The greaterThanOrEqualToConstant: 48 tells 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:

ClosurePurposeCalled
registerRegister cell classes with the collection viewOnce at setup
reuseIdentifierForReturn which cell type to use for each positionFor every cell
configureSet up the cell with its dataWhen cell is dequeued
sizingCellForProvide an off-screen cell for height measurementOnce 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.section is the column (0 = first column), and indexPath.row is 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 value parameter 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 register closure
  • Verify reuse identifier — Must match exactly between registration and reuseIdentifierFor
  • Cast correctly — Use as? to safely cast in configure

All rows same height

  • Check constraints — Must define height through content or explicit constraints
  • Add to contentView — Subviews go in contentView, not self
  • Enable automatic heights — Set rowHeightMode = .automatic(estimated:)

Buttons not responding

  • Check user interactioncontentView.isUserInteractionEnabled must be true
  • Wire up in configure — Closures must be set each time cell is dequeued
  • Clear in prepareForReuse — Prevents stale closures from previous rows