UITableView
iOS Development
Expand Collapse
iOS Programming
SwiftUI

Expand/collapse section in UITableView in iOS

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

Implementing expand/collapse sections in a UITableView involves toggling the number of rows in each section based on a collapsed/expanded state. When a section header is tapped, you update the state and call tableView.reloadSections() to animate the rows appearing or disappearing. The key data structure is an array of section models, each tracking its title, items, and whether it is currently expanded.

Data Model

swift
1struct Section {
2    let title: String
3    let items: [String]
4    var isExpanded: Bool
5}
6
7class ExpandableTableViewController: UIViewController {
8    var sections: [Section] = [
9        Section(title: "Fruits", items: ["Apple", "Banana", "Cherry", "Date"], isExpanded: true),
10        Section(title: "Vegetables", items: ["Asparagus", "Broccoli", "Carrot"], isExpanded: false),
11        Section(title: "Grains", items: ["Rice", "Wheat", "Oats", "Barley", "Corn"], isExpanded: false),
12    ]
13
14    @IBOutlet weak var tableView: UITableView!
15}

Each section tracks its own isExpanded state. When collapsed, the section shows zero rows. When expanded, it shows all its items.

UITableView Data Source

swift
1extension ExpandableTableViewController: UITableViewDataSource {
2
3    func numberOfSections(in tableView: UITableView) -> Int {
4        return sections.count
5    }
6
7    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
8        // Return 0 rows when collapsed, all items when expanded
9        return sections[section].isExpanded ? sections[section].items.count : 0
10    }
11
12    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
13        let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
14        cell.textLabel?.text = sections[indexPath.section].items[indexPath.row]
15        return cell
16    }
17}

The critical line is numberOfRowsInSection — returning 0 when isExpanded is false hides all rows in that section.

Section Header with Tap Gesture

swift
1extension ExpandableTableViewController: UITableViewDelegate {
2
3    func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
4        let header = UIView()
5        header.backgroundColor = .systemGray5
6        header.tag = section
7
8        let label = UILabel()
9        label.text = sections[section].title
10        label.font = .boldSystemFont(ofSize: 16)
11        label.translatesAutoresizingMaskIntoConstraints = false
12        header.addSubview(label)
13
14        let arrow = UILabel()
15        arrow.text = sections[section].isExpanded ? "▼" : "▶"
16        arrow.translatesAutoresizingMaskIntoConstraints = false
17        header.addSubview(arrow)
18
19        NSLayoutConstraint.activate([
20            label.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: 16),
21            label.centerYAnchor.constraint(equalTo: header.centerYAnchor),
22            arrow.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -16),
23            arrow.centerYAnchor.constraint(equalTo: header.centerYAnchor),
24        ])
25
26        let tap = UITapGestureRecognizer(target: self, action: #selector(handleHeaderTap(_:)))
27        header.addGestureRecognizer(tap)
28
29        return header
30    }
31
32    func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
33        return 44
34    }
35
36    @objc func handleHeaderTap(_ gesture: UITapGestureRecognizer) {
37        guard let section = gesture.view?.tag else { return }
38
39        sections[section].isExpanded.toggle()
40
41        tableView.reloadSections(IndexSet(integer: section), with: .automatic)
42    }
43}

The tag property stores the section index on the header view. When tapped, isExpanded is toggled and reloadSections animates the change.

Using a Custom Header Cell

swift
1class SectionHeaderCell: UITableViewHeaderFooterView {
2    static let reuseIdentifier = "SectionHeader"
3
4    let titleLabel = UILabel()
5    let arrowLabel = UILabel()
6    var onTap: (() -> Void)?
7
8    override init(reuseIdentifier: String?) {
9        super.init(reuseIdentifier: reuseIdentifier)
10        setupViews()
11
12        let tap = UITapGestureRecognizer(target: self, action: #selector(tapped))
13        addGestureRecognizer(tap)
14    }
15
16    required init?(coder: NSCoder) { fatalError() }
17
18    private func setupViews() {
19        titleLabel.font = .boldSystemFont(ofSize: 16)
20        arrowLabel.font = .systemFont(ofSize: 14)
21        [titleLabel, arrowLabel].forEach {
22            $0.translatesAutoresizingMaskIntoConstraints = false
23            contentView.addSubview($0)
24        }
25        NSLayoutConstraint.activate([
26            titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
27            titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
28            arrowLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
29            arrowLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
30        ])
31    }
32
33    func configure(title: String, isExpanded: Bool) {
34        titleLabel.text = title
35        arrowLabel.text = isExpanded ? "▼" : "▶"
36    }
37
38    @objc private func tapped() { onTap?() }
39}
40
41// Register and use in delegate:
42tableView.register(SectionHeaderCell.self, forHeaderFooterViewReuseIdentifier: SectionHeaderCell.reuseIdentifier)
43
44func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
45    let header = tableView.dequeueReusableHeaderFooterView(withIdentifier: SectionHeaderCell.reuseIdentifier) as! SectionHeaderCell
46    header.configure(title: sections[section].title, isExpanded: sections[section].isExpanded)
47    header.onTap = { [weak self] in
48        self?.sections[section].isExpanded.toggle()
49        tableView.reloadSections(IndexSet(integer: section), with: .automatic)
50    }
51    return header
52}

Using UITableViewHeaderFooterView with dequeueReusableHeaderFooterView is more efficient than creating new views each time, especially with many sections.

Animated Arrow Rotation

swift
1@objc func handleHeaderTap(_ gesture: UITapGestureRecognizer) {
2    guard let section = gesture.view?.tag else { return }
3
4    sections[section].isExpanded.toggle()
5
6    // Animate the arrow rotation
7    if let arrow = gesture.view?.subviews.compactMap({ $0 as? UIImageView }).first {
8        UIView.animate(withDuration: 0.3) {
9            arrow.transform = self.sections[section].isExpanded
10                ? CGAffineTransform(rotationAngle: .pi / 2)
11                : .identity
12        }
13    }
14
15    tableView.reloadSections(IndexSet(integer: section), with: .automatic)
16}

Common Pitfalls

  • Using reloadData() instead of reloadSections(): Calling reloadData() refreshes the entire table without animation. Use reloadSections(_:with:) with .automatic to get smooth expand/collapse animations for the specific section.
  • Forgetting to return 0 rows for collapsed sections: If numberOfRowsInSection always returns the item count regardless of state, the section never visually collapses. The row count must be 0 when isExpanded is false.
  • Using cell tag for section identification in reusable headers: The tag approach works for simple cases, but reused header views may retain stale tags. Using a closure callback (onTap) or storing the section index in a custom property is more reliable.
  • Not handling estimated row heights: When using self-sizing cells with UITableView.automaticDimension, failing to set estimatedRowHeight causes jump artifacts during expand/collapse animations. Set a reasonable estimate to smooth transitions.
  • Modifying sections array off the main thread: UITableView updates must happen on the main thread. Toggling isExpanded and calling reloadSections from a background thread causes crashes or visual corruption.

Summary

  • Track isExpanded state per section in your data model
  • Return 0 from numberOfRowsInSection when a section is collapsed
  • Use reloadSections(_:with: .automatic) for animated expand/collapse
  • Add a tap gesture to section headers that toggles isExpanded and reloads the section
  • Use UITableViewHeaderFooterView with dequeue for efficient header reuse
  • Consider using UIImageView with rotation transforms for animated arrow indicators

Course illustration
Course illustration

All Rights Reserved.