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
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
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.
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.
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
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