iOS: How to build a Table View with Collapsible Sections

Part 2. Continue adopting Protocols and MVVM with Table Views

This is the second part of my Tutorial series on Table View with multiple cell types.

After reading the multiple responses and advice for the first part, I decided to add some major updates.
UITableViewController is changed to UIViewController with a TableView as a subview.
Now, ViewModel conforms to TableViewDataSource protocol. NumberOfRowsInSection, cellForRowAt, and numberOfSections are the part of ViewModel. This keeps the ViewController and ViewModel separated.
Please find the final updated project here.
Thanks everyone for the contribution!

In the first part we created the following Table View:

In this article, we will make some changes to have the section collapsible:

Table View With Collapsible Sections

To add the collapsible behavior, we need to know two things about the section:

  • is the section is collapsible or not
  • the current section state: collapsed/expanded

We can add both properties to existing ProfileViewModelItem protocol:

protocol ProfileViewModelItem {
var type: ProfileViewModelItemType { get }
var sectionTitle: String { get }
var rowCount: Int { get }
var isCollapsible: Bool { get }
var isCollapsed: Bool { get set }
}

Note, that isCollapsible property only has a getter, because we will not need to modify it.

Next, we add a default isCollapsible value to the protocol extension. We set the default value to true:

extension ProfileViewModelItem {
var rowCount: Int {
return 1
}

var isCollapsible: Bool {
return true
}
}

Once you modified the protocol, you will see multiple compile errors in each of ProfileViewModelItems. Fix it by adding this property to each ViewModelItem:

class ProfileViewModelNamePictureItem: ProfileViewModelItem {
var isCollapsed = true
}

These are all the changes we need to make in out ViewModel. The remaining part is to modify the View, so it can handle the collapse/expand actions.


There is no out-of-the-box way to add the collapsible behavior to the tableView, so we will mimic in a very simple way: when the section is collapsed, we will set its row count to zero. When it is expanded, we will use the default rowCount for this section. For the TableView we can provide this information in the numberOfRowsInSection method:

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
let item = viewModel.items[section]
if item.isCollapsible && item.isCollapsed {
return 0
}
return item.rowCount
}

Now we need to create a custom header view, that will have the title and the arrow label. Create a subclass of UITableViewHeaderFooterView and set the layout in either xib or code:

class HeaderView: UITableViewHeaderFooterView {
@IBOutlet weak var titleLabel: UILabel?
@IBOutlet weak var arrowLabel: UILabel?
   var section: Int = 0
}

We will use the section variable to store the current section index, that we will need later.

When the user taps on the section, the arrow view should rotate down. We can achieve that with a UIView Extension:

extension UIView {
func rotate(_ toValue: CGFloat, duration: CFTimeInterval = 0.2) {
let animation = CABasicAnimation(keyPath: “transform.rotation”)
animation.toValue = toValue
animation.duration = duration
animation.isRemovedOnCompletion = false
animation.fillMode = kCAFillModeForwards
self.layer.add(animation, forKey: nil)
}
}
This is just one of the possible ways to animate the view rotation

Using this extension method, add the following code inside the HeaderView class:

func setCollapsed(collapsed: Bool) {
arrowLabel?.rotate(collapsed ? 0.0 : .pi)
}

When we call this method for collapsed state, it will rotate the arrow to the original position, for expanded state it will rotate the arrow to pi radians.

Next, we need to setup the current section title. As we did for the cells in the previous tutorial, create the item variable and use the didSet observer to set the title and the initial position of the arrow label:

var item: ProfileViewModelItem? {
didSet {
guard let item = item else {
return
}
     titleLabel?.text = item.sectionTitle
setCollapsed(collapsed: item.isCollapsed)
}
}

The last questions are: how to detect the user tap on the header, and how to notify the TableView?

To detect a user interaction we can set a TapGestureRecognizer in our header:

override func awakeFromNib() {
super.awakeFromNib()
addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapHeader)))
}
@objc private func didTapHeader() {
}

To notify the TableView, we can use any of the ways I described here. In this case, I will use the delegation. Create a HeaderViewDelegate protocol with one method:

protocol HeaderViewDelegate: class {
func toggleSection(header: HeaderView, section: Int)
}

Add a delegate property inside the HeaderView:

weak var delegate: HeaderViewDelegate?

Finally, call this delegate method from tapHeader selector:

@objc private func tapHeader(gestureRecognizer: UITapGestureRecognizer) {
delegate?.toggleSection(header: self, section: section)
}

The HeaderView is now ready to use. Let’s connect it to our ViewController.

Open ViewModel and make it conform to the TableViewDelegate:

extension ProfileViewModel: UITableViewDelegate {
}

Next, remove the titleForHeaderInSection method. Since we use a custom header, we will set a title another way:

func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
if let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: HeaderView.identifier) as? HeaderView {
headerView.item = viewModel.items[section]
headerView.section = section
headerView.delegate = self // don't forget this line!!!
      return headerView
}
return UIView()
}
For dequeueReusableHeaderFooterView to work, don’t forget to register headerView for the tableView

Once you set the headerView.delegate to self, you will notice the compiler error, because our ViewModel does not conform to the protocol yet. Fix it by adding another extension:

extension ProfileViewModel: HeaderViewDelegate {
func toggleSection(header: HeaderView, section: Int) {
var item = items[section]
      if item.isCollapsible {
         // Toggle collapse
let collapsed = !item.isCollapsed
item.isCollapsed = collapsed
header.setCollapsed(collapsed: collapsed)
         // Adjust the number of the rows inside the section   
}
}
}

We need to set a way to reload the TableView section, so it will update the UI. In the more complex ViewModels that require to update, add or remove the tableViewRows, it will make sense to use a delegate with multiple methods. In our project we only need one method (ReloadSection), so we can us the callback:

class ProfileViewModel: NSObject {
var items = [ProfileViewModelItem]()
   // callback to reload tableViewSections
var reloadSections: ((_ section: Int) -> Void)?
   .....
}

Call this callback in toggleSection:

extension ProfileViewModel: HeaderViewDelegate {
func toggleSection(header: HeaderView, section: Int) {
var item = items[section]
      if item.isCollapsible {
         // Toggle collapse
let collapsed = !item.isCollapsed
item.isCollapsed = collapsed
header.setCollapsed(collapsed: collapsed)
         // Adjust the number of the rows inside the section  
reloadSections?(section)
}
}
}

In ViewController we to use this callback to reload the tableView sections:

override func viewDidLoad() {
super.viewDidLoad()
viewModel.reloadSections = { [weak self] (section: Int) in
self?.tableView?.beginUpdates()
self?.tableView?.reloadSections([section], with: .fade)
self?.tableView?.endUpdates()
}

...
}

If you build and run the project, you will see this nice animating collapsing behavior.

You can check out the Final Project here.

There are some potential upgrades for this feature:

  1. Try to think of the way to only allow one section to be expanded. So when the user taps another section, it will first collapse the expanded one, and then expand the new one.
  2. When the section is expanding, scroll the tableView to show the last row in this section.

Please share your thoughts in the comments bellow, so we can discuss it.

Thanks for reading!