Swift Table and Collection Views — Building Scrolling Lists
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
Forgetting to register cell classes: Always call
register(_:forCellReuseIdentifier:)before dequeuing cells. Failure causes a crash at runtime.Not resetting cell content in cellForRowAt: Reused cells retain previous state. Set all visual properties (text, color, image) explicitly even if empty.
Blocking the main thread with complex cell setup: Cell configuration must be fast. Offload image loading and data formatting to background queues.
Using frame-based layout in custom cells: Always use Auto Layout or autoresizing masks. Cells change width on rotation and on different devices.
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
- How does cell reuse improve table view performance?
- What is the difference between a data source and a delegate?
- When would you use UICollectionView instead of UITableView?
- How do diffable data sources simplify table view management?
- 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
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