Creating a Custom Table View Cell in UIKit
A custom table view cell is one of the most useful UIKit patterns for junior iOS developers. The standard UITableViewCell styles work for simple text, but real apps usually need a logo, title, subtitle, badge, button, image, or custom spacing. A dedicated cell class keeps that layout organised and reusable.
The basic idea is straightforward: create a Swift class that inherits from UITableViewCell, add its views, apply Auto Layout constraints, and expose a method that fills the cell with model data. The table view then reuses that cell as the user scrolls, which is essential for smooth performance.
This approach is useful for many familiar Australian app experiences, including a Sydney public transport timetable, a Melbourne café directory, or a shopping list showing prices in Australian dollars. Each row can have a consistent design while displaying different data.
UIKit offers several ways to build the layout. You can use a storyboard prototype cell, a separate XIB file, or construct everything in code. This guide uses programmatic views because the dependencies are visible and the same pattern works well in portfolio projects, coding exercises, and Swift Student Challenge apps.
Designing The Cell’s Data Model
Start with the data the row needs to display. A small value type makes the cell’s requirements clear and prevents view controllers from passing unrelated values individually.
struct Product {
let name: String
let category: String
let price: Decimal
let imageName: String?
}
A model can be more specialised for the app. For a local events list, it might include a venue, date, and suburb. For an app aimed at users in Brisbane or Perth, a product model could also include stock status or delivery information. The cell should display this information, while the view controller or another layer remains responsible for loading it.
Next, create a subclass with outlets declared as properties. translatesAutoresizingMaskIntoConstraints must be set to false for views managed by Auto Layout.
final class ProductCell: UITableViewCell {
static let reuseIdentifier = "ProductCell"
private let productImageView = UIImageView()
private let nameLabel = UILabel()
private let categoryLabel = UILabel()
private let priceLabel = UILabel()
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
super.init(style: style, reuseIdentifier: reuseIdentifier)
setupViews()
setupConstraints()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
Using private protects the cell’s internal layout. The rest of the app only needs a public configuration method, rather than direct access to every label. This separation makes future changes easier and reduces accidental visual bugs.
Building The Layout With Auto Layout
The setup method defines the appearance. Configure fonts, colours, image behaviour, and the default selected background here. Dynamic Type is worth supporting from the start, so use preferred text styles instead of fixed font sizes where possible.
private extension ProductCell {
func setupViews() {
productImageView.translatesAutoresizingMaskIntoConstraints = false
nameLabel.translatesAutoresizingMaskIntoConstraints = false
categoryLabel.translatesAutoresizingMaskIntoConstraints = false
priceLabel.translatesAutoresizingMaskIntoConstraints = false
nameLabel.font = .preferredFont(forTextStyle: .headline)
nameLabel.adjustsFontForContentSizeCategory = true
categoryLabel.font = .preferredFont(forTextStyle: .subheadline)
categoryLabel.textColor = .secondaryLabel
categoryLabel.adjustsFontForContentSizeCategory = true
priceLabel.font = .preferredFont(forTextStyle: .headline)
priceLabel.textColor = .systemBlue
priceLabel.textAlignment = .right
productImageView.contentMode = .scaleAspectFill
productImageView.clipsToBounds = true
productImageView.layer.cornerRadius = 8
contentView.addSubview(productImageView)
contentView.addSubview(nameLabel)
contentView.addSubview(categoryLabel)
contentView.addSubview(priceLabel)
}
}
Place all custom views inside contentView, rather than directly inside the cell. UIKit manages contentView during selection and reuse, and constraints attached to it behave more predictably. A common mistake is adding subviews to the cell itself, which can create layout and highlighting problems.
The constraints below create a 56-point image on the leading edge, two labels in the middle, and a price aligned to the trailing edge.
func setupConstraints() {
NSLayoutConstraint.activate([
productImageView.leadingAnchor.constraint(
equalTo: contentView.layoutMarginsGuide.leadingAnchor
),
productImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
productImageView.widthAnchor.constraint(equalToConstant: 56),
productImageView.heightAnchor.constraint(equalToConstant: 56),
priceLabel.trailingAnchor.constraint(
equalTo: contentView.layoutMarginsGuide.trailingAnchor
),
priceLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
priceLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 60),
nameLabel.leadingAnchor.constraint(
equalTo: productImageView.trailingAnchor,
constant: 12
),
nameLabel.trailingAnchor.constraint(
equalTo: priceLabel.leadingAnchor,
constant: -12
),
nameLabel.topAnchor.constraint(
equalTo: contentView.layoutMarginsGuide.topAnchor
),
categoryLabel.leadingAnchor.constraint(equalTo: nameLabel.leadingAnchor),
categoryLabel.trailingAnchor.constraint(equalTo: nameLabel.trailingAnchor),
categoryLabel.topAnchor.constraint(
equalTo: nameLabel.bottomAnchor,
constant: 4
),
categoryLabel.bottomAnchor.constraint(
equalTo: contentView.layoutMarginsGuide.bottomAnchor
)
])
}
The vertical chain from the top margin through both labels to the bottom margin allows the table view to calculate the row’s height. If a label can contain long text, ensure its numberOfLines is set appropriately and use self-sizing rows. Test the result with larger accessibility text sizes, because a layout that works only at the default font size is not ready for release.
Configuring And Reusing The Cell
Give the cell one method that accepts a Product. This keeps formatting close to the view that presents it and creates a consistent place to reset values.
func configure(with product: Product) {
nameLabel.text = product.name
categoryLabel.text = product.category
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = "AUD"
priceLabel.text = formatter.string(
from: product.price as NSDecimalNumber
)
if let imageName = product.imageName {
productImageView.image = UIImage(named: imageName)
} else {
productImageView.image = UIImage(systemName: "bag")
}
}
Using AUD is a small but practical detail for an Australian-facing app. A marketplace listing in Adelaide may need different formatting from a global product, and currency should be determined by the model or a dedicated formatting layer rather than being hard-coded throughout the interface.
Reuse means an old cell can display a new item. For that reason, every state that can change must be assigned during configuration. If images are loaded asynchronously, cancel the previous request in prepareForReuse() and provide a placeholder immediately.
override func prepareForReuse() {
super.prepareForReuse()
nameLabel.text = nil
categoryLabel.text = nil
priceLabel.text = nil
productImageView.image = UIImage(systemName: "photo")
}
Avoid creating expensive objects repeatedly in cellForRowAt when possible. For a production app, keep a shared NumberFormatter, use an image-loading solution with caching, and avoid blocking the main thread. A table view should remain responsive while the user scrolls through a long list.
Registering The Cell In A Table View
The view controller registers the class before requesting cells. Registration tells UIKit how to create a cell when the reuse queue is empty.
final class ProductsViewController: UITableViewController {
private var products: [Product] = []
override func viewDidLoad() {
super.viewDidLoad()
tableView.register(
ProductCell.self,
forCellReuseIdentifier: ProductCell.reuseIdentifier
)
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 80
}
override func tableView(
_ tableView: UITableView,
numberOfRowsInSection section: Int
) -> Int {
products.count
}
override func tableView(
_ tableView: UITableView,
cellForRowAt indexPath: IndexPath
) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(
withIdentifier: ProductCell.reuseIdentifier,
for: indexPath
) as? ProductCell else {
return UITableViewCell()
}
cell.configure(with: products[indexPath.row])
return cell
}
}
The indexPath identifies the item currently being displayed, but it should not be stored by the cell as permanent state. Data can change after sorting, filtering, or an update from the server. The source of truth belongs in the view controller or data source.
For a more modern UIKit project, UITableViewDiffableDataSource can replace the traditional data source methods. The custom cell remains almost identical; only the way snapshots and items are supplied changes. This is useful for lists that update frequently, such as a live events feed or an inventory screen.
A cell can also notify its view controller about an interaction through a closure or delegate. Keep navigation outside the cell. The cell can report that a button was tapped, while the view controller decides whether to open a detail screen, show an alert, or update the data.
Reviewing Accessibility And Visual Quality
A custom row needs more than attractive constraints. Check that labels have meaningful text, images include an accessibility label when they convey information, and decorative images are hidden from VoiceOver. A button inside the row should have a clear accessibility label and a sufficiently large touch area.
Preview the cell with short and long names, missing images, large prices, and long category text. Test in Light and Dark Mode, with Reduce Motion enabled, and at the largest Dynamic Type sizes. Apple’s Human Interface Guidelines provide useful visual references, and browsing interface inspiration can help when comparing spacing and hierarchy patterns.
The following checks catch many common problems before the cell reaches TestFlight:
- Register the cell with a stable reuse identifier.
- Add custom views to
contentView, not the cell itself. - Set every reusable value during configuration or reset it in
prepareForReuse(). - Use Auto Layout constraints that define both horizontal and vertical relationships.
- Test with Dynamic Type, VoiceOver, Dark Mode, and long translated text.
- Keep image loading and data formatting efficient on the main thread.
- Use Xcode’s View Debugger to find ambiguous or conflicting constraints.
A polished list also benefits from realistic data. If a row displays articles, verify that external content opens safely and that empty, loading, and error states have their own designs. A content card could link to a technical explanation or a niche reading example such as roulette player systems, while the surrounding app still needs clear labelling and predictable navigation.
A custom table view cell is successful when its code has a small public surface, its layout adapts to real content, and its reuse behaviour is predictable. Build the model first, create the views inside contentView, constrain them from edge to edge, configure every state, and then test the result with Australian prices, longer names, accessibility settings, and real scrolling. That workflow turns a basic UIKit row into a dependable component for a production iOS app.