Introduction to Dynamic Table Views in Swift

A table view is one of the most versatile and widely used UI components in iOS development. When you need to display a list of data that changes over time — whether due to user input, network responses, or database updates — a dynamic table view becomes essential. Unlike a static table view where rows are predefined in Interface Builder, a dynamic table view adapts its content and structure at runtime based on its data source.

In this guide, you’ll learn how to build a fully dynamic UITableView from scratch using Swift. We’ll cover programmatic setup, data source and delegate methods, updating the UI when data changes, handling user interactions, and best practices for performance and maintainability. By the end, you’ll have the confidence to implement a responsive table view that can handle any evolving data set.

Setting Up the Table View

You can create a table view either through a storyboard or programmatically. For maximum flexibility, we’ll focus on a programmatic approach. Start by creating a new subclass of UIViewController and adding a UITableView property.

import UIKit

class ViewController: UIViewController {

    let tableView = UITableView()

    override func viewDidLoad() {
        super.viewDidLoad()
        setupTableView()
    }

    private func setupTableView() {
        // Register a default cell style
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")

        // Set data source and delegate
        tableView.dataSource = self
        tableView.delegate = self

        // Configure layout
        view.addSubview(tableView)
        tableView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor)
        ])
    }
}

The key step is assigning the view controller as both the data source and the delegate. The data source provides the table view with information about how many rows to display and what cells to show at each index path. The delegate handles row selection, height, and other interactive behavior.

If you prefer using Interface Builder, drag a UITableView onto your view controller scene, connect its dataSource and delegate outlets to the view controller, and create an IBOutlet for the table view. Both approaches work equally well; choose the one that fits your project’s architecture.

Conforming to UITableViewDataSource and UITableViewDelegate

After setting up the table view, you must conform your view controller to the required protocols. Add them to your class declaration:

class ViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { }

Xcode will prompt you to implement the mandatory data source methods. The two required methods are:

  • tableView(_:numberOfRowsInSection:) — returns the number of rows in a given section.
  • tableView(_:cellForRowAt:) — dequeues or creates a cell and configures it for the specified index path.

Implementing numberOfRowsInSection

This method returns the count of your data model. Suppose you maintain an array of strings:

var items: [String] = ["Item 1", "Item 2", "Item 3"]

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

Implementing cellForRowAt

Inside this method you dequeue a reusable cell and configure its content. Using the cell identifier you registered earlier:

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

If you need custom styling, register a custom UITableViewCell subclass instead and cast the dequeued cell accordingly.

Making the Table View Dynamic

A dynamic table view responds to changes in the underlying data. The simplest way to update the UI is to call tableView.reloadData(), which refreshes all visible rows. However, for a smooth and performant experience, you should use more granular reload methods when possible.

Updating with reloadData()

When you modify the data array, for example by adding a new item, call reloadData() immediately after:

@IBAction func addItem(_ sender: UIButton) {
    items.append("New Item \(items.count + 1)")
    tableView.reloadData()
}

While reloadData() works, it forces the table view to query all data source methods again and can cause a flicker. For better animations, use the insert/delete/update methods.

Inserting Rows with Animation

To add a row with a smooth animation, use insertRows(at:with:). First update the data source, then tell the table view about the change:

@IBAction func addRowWithAnimation() {
    let newIndex = items.count
    items.append("Animated Item")
    tableView.insertRows(at: [IndexPath(row: newIndex, section: 0)], with: .automatic)
}

Similarly, you can use deleteRows(at:with:) to remove rows, reloadRows(at:with:) to update specific rows, and moveRow(at:to:) to reorder rows. Always modify the data array before calling these methods to keep the table view and data source in sync.

Supporting Editing Actions

To enable swipe-to-delete, implement the delegate method tableView(_:commit:forRowAt:):

func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
    if editingStyle == .delete {
        items.remove(at: indexPath.row)
        tableView.deleteRows(at: [indexPath], with: .fade)
    }
}

You can also enable edit mode by calling tableView.setEditing(true, animated: true) on a button press, allowing users to reorder rows visually.

Handling User Interaction

Responding to user taps is a core part of any table view. Implement the delegate method tableView(_:didSelectRowAt:):

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    tableView.deselectRow(at: indexPath, animated: true)
    let selectedItem = items[indexPath.row]
    // Perform action — e.g., navigate to detail screen or show alert
    print("Tapped \(selectedItem)")
}

Always de-select the row after selection to provide a clean visual feedback. For more advanced interactivity, you can add gesture recognizers or custom buttons inside cells.

Creating Custom Cells for Dynamic Content

While the default cell style works for simple strings, real‑world apps often require custom layouts. Create a subclass of UITableViewCell with your own UI elements.

Registering a Custom Cell

Define a cell class and a reuse identifier:

class CustomCell: UITableViewCell {
    let titleLabel = UILabel()
    let subtitleLabel = UILabel()
    let customImageView = UIImageView()

    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() {
        // Add subviews and configure constraints
    }
}

Register the cell in viewDidLoad:

tableView.register(CustomCell.self, forCellReuseIdentifier: "customCell")

Then in cellForRowAt, dequeue your custom cell and populate its properties:

let cell = tableView.dequeueReusableCell(withIdentifier: "customCell", for: indexPath) as! CustomCell
cell.titleLabel.text = items[indexPath.row]
return cell

Custom cells enable you to display images, labels with different fonts, buttons, or any other view. Remember to set accessibilityIdentifier and isAccessibilityElement for a dynamic table view that works well with VoiceOver.

Managing Sections for More Complex Data

Sometimes your data is grouped into sections. For example, contacts grouped by first letter. You’ll need to implement two additional data source methods:

  • numberOfSections(in:) — defaults to 1 if not implemented.
  • tableView(_:titleForHeaderInSection:) or tableView(_:viewForHeaderInSection:) to provide section headers.
func numberOfSections(in tableView: UITableView) -> Int {
    return sections.count
}

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

You can also implement sectionIndexTitles(for:) to display a quick‑scroll index on the right side of the table view — very useful for long lists.

Performance Optimization

Dynamic table views can become sluggish if not handled carefully, especially with large data sets or complex cells. Follow these best practices:

  • Reuse cells — always use dequeueReusableCell(withIdentifier:for:). Never create a new UITableViewCell inside cellForRowAt.
  • Offload expensive work — if your cell displays images from the network, use a library like Kingfisher or implement asynchronous loading with caching.
  • Preprare for reuse — override prepareForReuse() in your custom cell to reset properties (e.g., cancel pending image requests, clear labels).
  • Use automatic dimension — for variable‑height cells, set tableView.rowHeight = UITableView.automaticDimension and provide an estimated height.
  • Avoid blocking the main thread — perform heavy data transformations on a background queue and update the table view on the main queue.

Using Diffable Data Source (iOS 13+)

For a more declarative and less error‑prone approach, consider adopting UITableViewDiffableDataSource. It automatically handles diffing and animates changes. You define a snapshot and apply it; the table view updates accordingly without manual inserts or deletes.

enum Section { case main }

var dataSource: UITableViewDiffableDataSource<Section, String>!

override func viewDidLoad() {
    super.viewDidLoad()
    // Configure data source
    dataSource = UITableViewDiffableDataSource<Section, String>(tableView: tableView) { tableView, indexPath, itemIdentifier in
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.textLabel?.text = itemIdentifier
        return cell
    }
    // Apply initial snapshot
    var snapshot = NSDiffableDataSourceSnapshot<Section, String>()
    snapshot.appendSections([.main])
    snapshot.appendItems(items)
    dataSource.apply(snapshot, animatingDifferences: false)
}

func updateItems(newItems: [String]) {
    var snapshot = dataSource.snapshot()
    snapshot.deleteAllItems()
    snapshot.appendSections([.main])
    snapshot.appendItems(newItems)
    dataSource.apply(snapshot, animatingDifferences: true)
}

The diffable data source eliminates common bugs like index‑out‑of‑range crashes and provides automatic animations for insertions, deletions, and reordering. It’s recommended for all new iOS projects targeting iOS 13 and later.

Loading Data Asynchronously

In a real app, data often comes from a remote API or a local database. Load data asynchronously and then update the table view on the main queue.

func fetchItems() {
    let url = URL(string: "https://api.example.com/items")!
    URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
        guard let data = data, error == nil else { return }
        do {
            let decodedItems = try JSONDecoder().decode([String].self, from: data)
            DispatchQueue.main.async {
                self?.items = decodedItems
                self?.tableView.reloadData()
            }
        } catch {
            print("Decoding error: \(error.localizedDescription)")
        }
    }.resume()
}

Always use a weak reference to the view controller inside the closure to avoid retain cycles. Consider adding a loading indicator (e.g., UIActivityIndicatorView) while the data is being fetched.

Handling Empty and Error States

A dynamic table view should gracefully handle situations when there’s no data or when a network error occurs. Implement a background view to display a message or a retry button.

func showEmptyState() {
    let emptyView = UIView(frame: tableView.bounds)
    let label = UILabel()
    label.text = "No items yet. Pull to refresh."
    // ... configure and center label
    emptyView.addSubview(label)
    tableView.backgroundView = emptyView
}

func hideEmptyState() {
    tableView.backgroundView = nil
}

Check the data count after each update and toggle the background view accordingly. You can also use tableFooterView for a more consistent approach, but a background view works across all scroll states.

Integrating with Core Data or SwiftData

For persistent data, combine your table view with a Core Data fetch request controller or the new SwiftData framework (iOS 17+). These frameworks provide built‑in change monitoring and can automatically update the table view when data changes.

With Core Data, use NSFetchedResultsController:

let fetchRequest: NSFetchRequest<Item> = Item.fetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)]
fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest,
                                                      managedObjectContext: persistentContainer.viewContext,
                                                      sectionNameKeyPath: nil, cacheName: nil)
fetchedResultsController.delegate = self
try fetchedResultsController?.performFetch()

Implement the delegate methods to receive callbacks when objects are inserted, deleted, or updated, and then call the corresponding insertRows, deleteRows, or reloadRows methods.

For SwiftData, you can use @Query and a List view in SwiftUI, but if you’re staying with UIKit, you can observe model context changes manually and refresh the table view.

Conclusion

Creating a dynamic table view in Swift is a fundamental skill every iOS developer should master. By understanding the separation of concerns between data source, delegate, and the table view itself, you can build interfaces that adapt seamlessly to changing data. Start with a simple array and reloadData(), then graduate to animated insertions, custom cells, and the diffable data source for a production‑ready experience.

Remember to follow performance best practices, handle empty and error states, and integrate with your app’s data layer. The table view is incredibly powerful — it powers everything from simple to‑do lists to complex messaging apps. With the techniques outlined in this guide, you’re equipped to handle any dynamic list scenario.

“A table view that responds to change is the heart of many iOS apps. Build it right, and your users will never notice the magic behind the scenes.”

For further reading, check out the official Apple documentation on UITableView and UITableViewDiffableDataSource, or explore the Table View Programming Guide for deeper insights.

Now go ahead and make your table views dynamic — your app’s data will thank you.