Skip to content

Swift Table and Collection Views — Building Scrolling Lists

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about Swift Table and Collection Views. We cover key concepts, practical examples, and best practices to help you master this topic.

Swift table views (UITableView) and collection views (UICollectionView) are UIKit's primary components for displaying scrollable lists and grids of content, using a data-driven architecture that reuses cells for smooth performance.

What You'll Learn

  • UITableView and UICollectionView fundamentals
  • Data source and delegate patterns
  • Cell reuse and the reuse queue
  • Customizing cells with UIKit
  • Handling selection, swipe actions, and reordering
  • Modern diffable data sources

Why It Matters

The table view is the most common UI pattern in iOS apps. Settings, Contacts, Messages, Mail, and most list-based screens use UITableView or UICollectionView. Understanding how to configure, customize, and optimize these views is essential for any iOS developer.

Real-World Use

The Apple Weather app uses a UICollectionView to display the 10-day forecast as a horizontal scrollable list. Each cell shows the day, weather icon, and high/low temperature. The Mail app uses UITableView with swipe-to-delete, swipe-to-archive, and pull-to-refresh for the message list.

Learning Path

flowchart LR
  A[Views and UI
Lesson 17] --> B[Table and Collection Views
You are here] B --> C[Navigation
Lesson 19] B --> D[Networking
Lesson 20] style B fill:#f90,color:#fff

UITableView Basics

UITableView displays a single-column list of rows. Each row is a UITableViewCell that you configure through a data source.

import UIKit

class SimpleTableViewController: UIViewController, UITableViewDataSource {

    let fruits = ["Apple", "Banana", "Cherry", "Date", "Elderberry", "Fig", "Grape"]
    private let tableView = UITableView()

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.frame = view.bounds
        tableView.dataSource = self
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        view.addSubview(tableView)
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return fruits.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = fruits[indexPath.row]
        cell.accessoryType = .disclosureIndicator
        return cell
    }
}

The dataSource provides the number of rows and configures each cell. dequeueReusableCell reuses existing cells that scrolled off screen, preventing memory bloat.

Cell Reuse Explained

When a cell scrolls off screen, it enters a reuse queue. When a new row appears, the table view pulls a cell from the queue instead of creating a new one.

class ReuseDemoViewController: UIViewController, UITableViewDataSource {

    let items = Array(1...1000)
    private let tableView = UITableView()
    private var createdCount = 0

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.frame = view.bounds
        tableView.dataSource = self
        tableView.register(CustomCell.self, forCellReuseIdentifier: "custom")
        view.addSubview(tableView)
    }

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return items.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "custom", for: indexPath) as! CustomCell
        // cell was reused — content must be reset
        cell.configure(with: "Item \(items[indexPath.row])")
        return cell
    }
}

class CustomCell: UITableViewCell {
    private let customLabel = UILabel()

    override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
        super.init(style: style, reuseIdentifier: reuseIdentifier)
        createdCount += 1
        print("Cell created — total: \(createdCount)")

        customLabel.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(customLabel)
        NSLayoutConstraint.activate([
            customLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
            customLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor)
        ])
    }

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

    func configure(with text: String) {
        customLabel.text = text
    }
}

With 1000 items, only about 10-12 cells are ever created. The rest are reused. Always reset cell content in cellForRowAt — never assume the cell is fresh.

Delegate Methods

The UITableViewDelegate handles row selection, height customization, header/footer views, and swipe actions.

class DelegateDemoViewController: UIViewController {
    private let tableView = UITableView()
    let data = ["First", "Second", "Third", "Fourth", "Fifth"]

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.frame = view.bounds
        tableView.dataSource = self
        tableView.delegate = self
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        view.addSubview(tableView)
    }
}

extension DelegateDemoViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return data.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = data[indexPath.row]
        return cell
    }
}

extension DelegateDemoViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        print("Selected: \(data[indexPath.row])")
        tableView.deselectRow(at: indexPath, animated: true)
    }

    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        return 60.0
    }

    func tableView(_ tableView: UITableView,
                   trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath
    ) -> UISwipeActionsConfiguration? {
        let deleteAction = UIContextualAction(style: .destructive, title: "Delete") {
            _, _, completion in
            print("Delete \(self.data[indexPath.row])")
            completion(true)
        }
        return UISwipeActionsConfiguration(actions: [deleteAction])
    }
}

The delegate is separate from the data source. Separating them into extensions keeps code organized and follows Swift best practices.

Custom Cells

For complex layouts, create a custom UITableViewCell subclass with its own views and Auto Layout constraints.

class ContactCell: UITableViewCell {
    let avatarView: UIImageView = {
        let iv = UIImageView()
        iv.contentMode = .scaleAspectFill
        iv.clipsToBounds = true
        iv.layer.cornerRadius = 25
        iv.backgroundColor = .systemGray
        iv.translatesAutoresizingMaskIntoConstraints = false
        return iv
    }()

    let nameLabel: UILabel = {
        let label = UILabel()
        label.font = UIFont.boldSystemFont(ofSize: 16)
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()

    let statusLabel: UILabel = {
        let label = UILabel()
        label.font = UIFont.systemFont(ofSize: 14)
        label.textColor = .secondaryLabel
        label.translatesAutoresizingMaskIntoConstraints = false
        return label
    }()

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

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

    private func setupViews() {
        contentView.addSubview(avatarView)
        contentView.addSubview(nameLabel)
        contentView.addSubview(statusLabel)

        NSLayoutConstraint.activate([
            avatarView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
            avatarView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
            avatarView.widthAnchor.constraint(equalToConstant: 50),
            avatarView.heightAnchor.constraint(equalToConstant: 50),

            nameLabel.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: 12),
            nameLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 12),
            nameLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),

            statusLabel.leadingAnchor.constraint(equalTo: nameLabel.leadingAnchor),
            statusLabel.topAnchor.constraint(equalTo: nameLabel.bottomAnchor, constant: 4),
            statusLabel.trailingAnchor.constraint(equalTo: nameLabel.trailingAnchor)
        ])
    }

    func configure(name: String, status: String) {
        nameLabel.text = name
        statusLabel.text = status
    }
}

UICollectionView

UICollectionView provides more flexible layouts than UITableView. It supports grids, horizontal scrolling, and custom layouts.

class CollectionViewController: UIViewController {
    private let collectionView: UICollectionView!
    let data = (1...50).map { "Item \($0)" }

    override func viewDidLoad() {
        super.viewDidLoad()

        let layout = UICollectionViewFlowLayout()
        layout.itemSize = CGSize(width: 100, height: 100)
        layout.minimumInteritemSpacing = 10
        layout.minimumLineSpacing = 10
        layout.sectionInset = UIEdgeInsets(top: 10, left: 10, bottom: 10, right: 10)

        collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
        collectionView.dataSource = self
        collectionView.delegate = self
        collectionView.register(ItemCell.self, forCellWithReuseIdentifier: "cell")
        collectionView.backgroundColor = .systemBackground
        view.addSubview(collectionView)
    }
}

extension CollectionViewController: UICollectionViewDataSource {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return data.count
    }

    func collectionView(_ collectionView: UICollectionView,
                        cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell",
                                                       for: indexPath) as! ItemCell
        cell.configure(with: data[indexPath.item])
        return cell
    }
}

extension CollectionViewController: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        print("Selected: \(data[indexPath.item])")
    }
}

class ItemCell: UICollectionViewCell {
    let label = UILabel()

    override init(frame: CGRect) {
        super.init(frame: frame)
        contentView.backgroundColor = .systemBlue
        contentView.layer.cornerRadius = 8
        label.textColor = .white
        label.textAlignment = .center
        label.font = UIFont.boldSystemFont(ofSize: 14)
        label.frame = contentView.bounds
        label.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        contentView.addSubview(label)
    }

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

    func configure(with text: String) {
        label.text = text
    }
}

Diffable Data Source

Modern iOS development uses UITableViewDiffableDataSource and UICollectionViewDiffableDataSource for simpler, safer data management with automatic animations.

import UIKit

class DiffableViewController: UIViewController {
    private let tableView = UITableView()
    private var dataSource: UITableViewDiffableDataSource<String, String>!
    var sections = ["Fruits", "Vegetables"]
    var items: [String: [String]] = [
        "Fruits": ["Apple", "Banana", "Cherry"],
        "Vegetables": ["Asparagus", "Broccoli", "Carrot"]
    ]

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.frame = view.bounds
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        view.addSubview(tableView)

        dataSource = UITableViewDiffableDataSource<String, String>(tableView: tableView) {
            tableView, indexPath, item in
            let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
            cell.textLabel?.text = item
            return cell
        }

        applySnapshot()
        addItems()
    }

    func applySnapshot() {
        var snapshot = NSDiffableDataSourceSnapshot<String, String>()
        for (section, items) in items {
            snapshot.appendSections([section])
            snapshot.appendItems(items, toSection: section)
        }
        dataSource.apply(snapshot, animatingDifferences: true)
    }

    func addItems() {
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            self.items["Fruits"]?.append("Durian")
            self.items["Vegetables"]?.append("Daikon")
            self.applySnapshot()
        }
    }
}

Diffable data sources automatically animate insertions, deletions, and reordering. You never call reloadData() — just apply a new snapshot.

Common Mistakes

  1. Forgetting to register cell classes: Always call register(_:forCellReuseIdentifier:) before dequeuing cells. Failure causes a crash at runtime.

  2. Not resetting cell content in cellForRowAt: Reused cells retain previous state. Set all visual properties (text, color, image) explicitly even if empty.

  3. Blocking the main thread with complex cell setup: Cell configuration must be fast. Offload image loading and data formatting to background queues.

  4. Using frame-based layout in custom cells: Always use Auto Layout or autoresizing masks. Cells change width on rotation and on different devices.

  5. Modifying the data source without updating the table: If you change the array after the table is loaded, call reloadData() or use diffable data sources to keep the UI in sync.

Practice Questions

  1. How does cell reuse improve table view performance?
  2. What is the difference between a data source and a delegate?
  3. When would you use UICollectionView instead of UITableView?
  4. How do diffable data sources simplify table view management?
  5. Challenge: Build a collection view that displays a grid of colored squares. Tapping a square changes its color. Add a "Shuffle" button that randomly reorders all squares with animation using diffable data sources.

Mini Project

Create a TaskListViewController with:

  • A UITableView using custom cells with task title, due date label, and priority indicator
  • Swipe-to-delete with confirmation alert
  • Swipe-to-mark-complete (green checkmark)
  • Pull-to-refresh that "reloads" tasks after a 1-second delay
  • A diffable data source with sections for "Today", "Tomorrow", "Upcoming"
  • A button in the navigation bar to add a new task (simulated with alert dialog)

FAQ

Should I use UITableView or UICollectionView?

Use UITableView for simple single-column lists. Use UICollectionView for grids, horizontal scrolling, or complex layouts. UICollectionView is more flexible but requires more setup.

How do I handle dynamic cell height?

Set tableView.rowHeight = UITableView.automaticDimension and provide a estimatedRowHeight. Configure your cell's Auto Layout constraints so the height is determined by content.

What is the reuse queue and how does it work?

When a cell scrolls off screen, it enters a reuse queue. dequeueReusableCell returns a cell from this queue if available, avoiding expensive memory allocation. Cells are created only when the queue is empty.

How do I add pull-to-refresh?

Add a UIRefreshControl to the table view's refreshControl property. Connect its valueChanged event to a method that fetches new data and endsRefreshing when done.

Why does my cell show old content when scrolling?

You are not resetting all cell properties in cellForRowAt. Reused cells retain their previous appearance. Set every visual property explicitly, including hidden states and images.

What's Next

With table and collection views mastered, learn how to connect screens with Navigation controllers, or fetch data from the internet in the Networking lesson.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro