UITableView with Expandable Cells

UITableViews are fundamental to any iOS developer’s understanding of UIKit and iOS Development. This project extends the default functionality of a UITableView by allowing users to dynamically expand or collapse sections of cells with a tap.

Software Targets

  • Swift 4.0+
  • Xcode 9.4+
  • iOS 11.4+

Objectives

Upon completion of this tutorial, your UITableView will be able to:

  • Respond to the user’s taps and dynamically expand or collapse cells in a UITableView.
  • Dynamically adjust the content inside of cells based on user interactions or selections in other cells.
  • Transition between view controllers when a selection has been made in each section.

1. Initial Setup – Xcode and Interface Builder

NOTE: This tutorial assumes you have already set up your computer to use Xcode 10. If you have not done this yet, visit https://developer.apple.com/xcode/ to download and install Xcode.

To begin, launch Xcode and Select Create a new Xcode project:

Under the Application section, select Single View App and click Next:

Enter a name for the project in the Product Name field. The other text fields should be filled in according to your developer account information and the language should be set to Swift. Ensure each of the checkboxes remains unchecked and click Next:

Save your project to a folder on your hard drive such as Users>Your_Username_Here>Desktop so you can access it later. Ensure Create Git repository on my Mac is selected if you want to take advantage of version control, and make sure the drop down for Add to is set to Don’t add to any project or workspace, as this app is a standalone project meant for demonstration purposes. Click Create to create the project:

The Single View Application template in Xcode comes with the files shown below out of the box. Open ViewController.swift by clicking on it in the left navigation pane:

In this project, ViewController.swift controls the view that displays our Expandable UITableView. For now, the file should be empty, except for the following:


import UIKit

class ViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
    }
    
}

We need to create a reference to a UITableView object in our code in order to manage its functionality. We accomplish this by adding an Interface Builder Outlet just below the class definition and above the method call to viewDidLoad():

@IBOutlet weak var tableView: UITableView!

With this outlet in place, we have everything we need to set up a UITableView inside of our ViewController’s view using Interface Builder. Using the left navigation pane, open the Main.storyboard file to reveal our project’s storyboard with a ViewController object already included:

Open the Identity Inspector by clicking the right pane toggle button at the top right corner of the Xcode editor. The Identity Inspector is represented by an ID card icon, as shown below. Notice in the Custom Class section that Xcode has done us a favor by setting the View Controller in Main.storyboard equal to the ViewController class we have defined in our ViewController.swift file. This relationship allows for connections to exist between our objects in Interface Builder and the code inside the corresponding class:

As it turns out, there are actually two ways to set up a table view in Interface Builder – by placing a UITableView object on top of a view, or by using a UITableViewController object. The difference between these two setup methods is subtle; however, each has its own pros and cons that are outside of the scope of this tutorial. For simplicity and flexibility, we have chosen to place a new UITableView object on top of ViewController’s view that the template has provided to us.

As of Xcode 10, new objects are added to the view by clicking on the Objects Inspector located at the top of the Xcode editor, as designated by a circle with a square inside of it. Clicking on this icon reveals a popup menu with a search bar and a list of objects. Search for UITableView and drag the object into the view on top of ViewController:

Next, we need to set up the UITableView’s constraints so that it is displayed consistently in the same place on the view. Pin the UITableView to the view’s edges by opening the Add New Constraints Inspector as shown below. Under Add New Constraints, click the constraint bar and set the value to 0 in each of the four cardinal directions. Click Add 4 Constraints to update the constraints on the UITableView:

Next, we need to tell the ViewController object inside of our Main.storyboard file a little more about the UITableView it contains. Specifically, we need to tell ViewController to listen on behalf of the UITableView for specific events via the table view’s delegate protocol. Additionally, we need to allow ViewController to set the initial state of the UITableView according to the UITableView’s data source protocol. For now, this simply means we need to connect references to the UITableView’s delegate and data source protocols to the ViewController class. Hold down the control key and drag from the UITableView to the yellow object icon on the ViewController object for each of the two protocols:

Finally, we need to connect the outlet we created in ViewController.swift to the UITableView object in Main.storyboard using the same control-drag method used for the protocols. Open the Assistant Editor by clicking the Icon with two intersecting circles in the top right of the Xcode editor to reveal two windows at once. In one window, open Main.storyboard and in the other open ViewController.swift. Drag from the outlet’s circle in ViewController.swift to the UITableView in Main.storyboard object:

At this point, running the app produces an error despite our best efforts. It turns out we have one more step to complete in our code before we can see the empty table view; we need to implement the delegate and data source protocols!

2. UITableView Delegate and Data Source

Now that we have everything connected in storyboard, it is time to implement the delegate and data source protocol code for tableView inside of ViewController.swift.

First, open ViewController.swift and modify the class definition adding calls to inherit from UITableViewDelegate and UITableViewDataSource:

class ViewController: UIViewController, UITableViewDelegate, UITableViewDataSource { ...

Next, add the following lines after viewDidLoad() before the last curly brace:

// Delegate

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {

}

// Data Source

func numberOfSections(in tableView: UITableView) -> Int {

}

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {

}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

}

These are the empty methods or protocol “stubs” that we need to implement in order to conform to the UITableView’s delegate and data source. Each method performs the following tasks:

  • tableView(_:didSelectRowAt:) gives the compiler instructions for what tasks to perform when certain events occur on the TableView, such as when the user taps on a row to expand or collapse a section.
  • numberOfSections(in:) returns an Int for the number of sections in our UITableView.
  • tableView(_:numberofRowsInSection) returns an Int for the number of rows in each section of our UITableView.
  • tableView(_:cellForRowAt:) initializes the cells in the UITableView with values based on the data it is provided.

Before we get too far along, we need to ensure that everything has been connected correctly for our blank UITableView. For now, that means we need to fill in some placeholders in the four methods mentioned above so that our project will compile without errors. Add in the following placeholders, then build and run the project:

// Delegate

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    // Nothing to add here for now...
}

// Data Source

func numberOfSections(in tableView: UITableView) -> Int {
    return 1
}

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

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    return UITableViewCell()
}

If everything is working correctly, you should now see an empty UITableView in the iOS Simulator:

Great! Our UITableView is ready to display data. There are a variety of ways to accomplish managing the data, but in our case we know two things that make choosing a data structure simpler:

  • The sections and rows of each section in our data are inherently finite; we do not need to populate more than 10-20 cells in total.
  • We know beforehand the data will be used to populate each cell.

As a result of these two assumptions, a solid choice for structuring the data is to use a struct with properties that manage each aspect of the data we need to control. Create a new Swift file by selecting File…New ▶︎…File…Swift File and save it in the project folder as SectionData.swift. Once created, open the file and update its contents to the following:

import Foundation

struct SectionData { 
    var sectionTitle: String 
    var isExpanded: Bool 
    var containsUserSelectedValue: Bool 
    var sectionOptions: [String] 
    
    init(sectionTitle: String, isExpanded: Bool, 
         containsUserSelectedValue: Bool, sectionOptions: [String]) { 
        self.sectionTitle = sectionTitle self.isExpanded = isExpanded 
        self.containsUserSelectedValue = containsUserSelectedValue 
        self.sectionOptions = sectionOptions 
    } 

}

Here, we have four properties that will be used to organize our data and a custom initializer that will set each section’s specific values that are passed into the struct. Each of these values will be explained in detail later.

Our SectionData struct is useful for representing one section of our UITableView, but our project requires multiple sections. Back in ViewController.swift, we need a way to group instances of SectionData together in order to display them simultaneously in tableView‘s tableView(_:cellForRowAt:) method. As you may have guessed, this is a job for arrays. Add the following properties to ViewController.swift above the outlet to tableView:

var tableViewData = [SectionData]()
var initialSectionTitles = ["Section 1", "Section 2", "Section 3"]

The tableViewData property stores each section’s data while the initialSectionTitles stores a reference to the original section titles in case we need to reset cells to their original state at runtime.

In viewDidLoad(), add the following to initialize section data for the first two sections:

override func viewDidLoad() {
    super.viewDidLoad()

    tableViewData = [SectionData(sectionTitle: "Section 1",
                                 isExpanded: false,
                                 containsUserSelectedValue: false,
                                 sectionOptions: ["Row Item 1",
                                                  "Row Item 2",
                                                  "Row Item 3",
                                                  "Row Item 4"]),
                     SectionData(sectionTitle: "Section 2",
                                 isExpanded: false,
                                 containsUserSelectedValue: false,
                                 sectionOptions: ["Row Item 1",
                                                  "Row Item 2",
                                                  "Row Item 3"])]   
}

Now that we have some data in our array, we can use the contents to set the initial number of sections and rows. Update numberOfSections(in:) and tableView(_:numberofRowsInSection) to the following:

func numberOfSections(in tableView: UITableView) -> Int {
    return tableViewData.count
}

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    if tableViewData[section].isExpanded == true {
        return tableViewData[section].sectionOptions.count + 1
    } else {
        return 1
    }
}

numberOfSections(in:) returns the count of SectionData instances inside of our tableViewData array, and tableView(_:numberofRowsInSection) uses the .isExpanded property of each SectionData instance to decide whether to display the list of sectionOptions or a default value of 1.

Next, we need a way to represent two types of cells, since tableView’s data is divided into sections that contain their own row items. For convenience, we will use one custom UITableViewClass to manage the content of both cells while referencing two different dynamic prototypes in Interface Builder. First, open Main.storyboard and click on tableView, then on the Attributes Inspector:

Change the number of prototype cells to 2, then add three labels to the cells in the following configuration:

For each of the labels, add the following constraints using the Add New Constraints Inspector and the Alignment Inspector directly to the left of Add New Constraints:

  • Section Title Label – Vertically in Container, Leading Space to Superview: 8
  • Section Detail Label – Vertically in Container, Trailing Space to Superview: 8
  • Row Item Title Label – Vertically in Container, Horizontally in Container

Next, create a new Swift file called CustomCell.swift and add the following to its contents:

import UIKit

// MARK: CustomCell: UITableViewCell

class CustomCell: UITableViewCell {

    // MARK: IB Outlets

    @IBOutlet weak var sectionTextLabel: UILabel!
    @IBOutlet weak var sectionDetailTextLabel: UILabel!
    @IBOutlet weak var rowTextLabel: UILabel!

    //MARK: Helper Methods

    func setSectionText(string: String) {
        sectionTextLabel.text = string
    }

    func setSectionDetailText(string: String) {
        sectionDetailTextLabel.text = string
    }

    func setRowText(string: String) {
        rowTextLabel.text = string
    }
}

Interface Builder is doing the majority of the heavy lifting for our custom cells in setting up our labels and constraints. Aside from the outlets, the helper methods in this class are added for convenience in setting the titles for each type of label.

Before connecting the outlets, change the class name for each of the prototype cells to CustomCell in the Identity Inspector, and add identifiers for each cell in the Attributes Inspector (TableViewSectionCell and TableViewRowCell, respectively):

The three outlets in our custom class should be connected to CustomCell.swift by dragging from the open circle in the line number column of the code editor, similar to the method used to connect the tableView outlet to ViewController.swift. Make sure that the section labels are connected to the first cell and the row label is connected to the second cell:

Ok, now we are ready for the real meat-and-potatoes portion of our code. Open ViewController.swift and add the following to the tableView(_:cellForRowAt:) method:

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

        switch indexPath.row {
            case 0: // Custom Cell - Section Header
                guard let cell = tableView.dequeueReusableCell(withIdentifier: "TableViewSectionCell") as? CustomCell else { 
                return UITableViewCell() }

                cell.setSectionText(string: tableViewData[indexPath.section].sectionTitle)

                if tableViewData[indexPath.section].isExpanded == true {
                    cell.setSectionText(string: initialSectionTitles[indexPath.section])
                    cell.setSectionDetailText(string: "")
                } else {
                    if tableViewData[indexPath.section].containsUserSelectedValue == true {
                        cell.setSectionDetailText(string: "\(initialSectionTitles[indexPath.section]) ✓")
                    } else {
                        cell.setSectionDetailText(string: ">")
                    }
                }
                return cell
            default:
                guard let cell = tableView.dequeueReusableCell(withIdentifier: "TableViewRowCell") as? CustomCell else {         
                return UITableViewCell() }

                cell.setRowText(string: tableViewData[indexPath.section].sectionOptions[indexPath.row - 1])
                return cell
        }
}

Here, we set up a switch statement that reacts to the indexPath‘s row, which starts at zero for each section. Since each section starts with a section header cell, we know that indexPath.row == 0 should be a TableViewSectionCell and anything else should be a TableViewRowCell by default. The rest of the logic inside of each case in the switch statement performs the task of setting up the appropriate cells and populating them based on their relative position in tableView. The if-statement inside case 0: checks to see if a section has been expanded in order to show or hide the text on sectionDetailTextLabel. Lastly, note that indexPath.row is decremented by 1 in the setRowText(string:) call inside of default: in order to line up the values in the cells with the correct indices of the sectionOptions array for each SectionData instance.

Build and run the app in the Simulator, and you should see two sections populated:

Right now clicking on the cells does not do anything. Let’s change that! In tableView(_:didSelectRowAt:) add the following code:

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {

    // Part A
    if indexPath.section == 0 {
        switch indexPath.row {
            case 0: // Section Header
                if tableViewData.count > 2 {
                    tableViewData.removeLast()
                    tableView.reloadData()
                }
            case 1:
                tableViewData.append(SectionData(sectionTitle: "Section 3",
                                                 isExpanded: false,
                                                 containsUserSelectedValue: false,
                                                 sectionOptions: ["100",
                                                                  "500",
                                                                  "1000",
                                                                  "Custom"]))
                tableView.reloadData()
            case 2:
                tableViewData.append(SectionData(sectionTitle: "Section 3",
                                                 isExpanded: false,
                                                 containsUserSelectedValue: false,
                                                 sectionOptions: ["50",
                                                                  "250",
                                                                  "750",
                                                                  "Custom"]))
                tableView.reloadData()
            case 3:
                tableViewData.append(SectionData(sectionTitle: "Section 3",
                                                 isExpanded: false,
                                                 containsUserSelectedValue: false,
                                                 sectionOptions: ["10",
                                                                  "30",
                                                                  "60",
                                                                  "Custom"]))
                tableView.reloadData()
            case 4:
                if tableViewData.count > 2 {
                    tableViewData.removeLast()
                    tableView.reloadData()
                }
            default:
                break
        }
    }

    // Part B
    if indexPath.row == 0 {
        if tableViewData[indexPath.section].isExpanded == true {
            tableViewData[indexPath.section].isExpanded = false
            tableViewData[indexPath.section].sectionTitle = initialSectionTitles[indexPath.section]
            tableViewData[indexPath.section].containsUserSelectedValue = false
            let sections = IndexSet.init(integer: indexPath.section)
            tableView.reloadSections(sections, with: .none)
        } else {
            tableViewData[indexPath.section].isExpanded = true
            tableViewData[indexPath.section].containsUserSelectedValue = false
            let sections = IndexSet.init(integer: indexPath.section)
            tableView.reloadSections(sections, with: .none)
        }
    } else {
        tableViewData[indexPath.section].isExpanded = false
        tableViewData[indexPath.section].sectionTitle = tableViewData[indexPath.section].sectionOptions[indexPath.row - 1]
        tableViewData[indexPath.section].containsUserSelectedValue = true

        let sections = IndexSet.init(integer: indexPath.section)
        tableView.reloadSections(sections, with: .none)
    }
}

There is a lot going on in this method, so let’s walk through it in two parts:

  • Part Aif indexPath.section == 0 {...} isolates the first section of tableView. In this project, the first section’s selection dictates whether or not a third section is presented. The nested switch statement handles tableView‘s reaction to selecting each cell in the first section. For case 0 and case 4, the third section is removed if it is present. For “case 1tocase 3“`, a third section is added with row items that correlate to the row item selected in the first section.
  • Part B – the second if-statement, if indexPath.row == 0 {, handles the expanding or collapsing of the cells based on whether the user selects a TableViewSectionCell or a TableViewRowCell.

Build and run the app in the simulator, and you should now be able to select sections that expand or collapse with a tap:

3. Finishing Touches

At this point, your UITableView is fully implemented. If you are interested, the sample project in this repository expands on this tutorial by implementing navigation and a bar button item. Additionally, methods have been added to alert the user when they have not made selections before transitioning to the next view, and to segue to the next view.

4 Comments

  • Hi..

    I’m struggling with the new iOS 13 SDK and the problems of UITableViews with collapsable sections in UIPopoverPresentationControllers – especially with safe areas and constraint warnings / asserts.

    I found your blog and wanted to see if you’d fixed some of my problems.

    I just downloaded your project and get the following error:

    ExpandableUITableView[3107:858527] [Warning] Warning once only: Detected a case where constraints ambiguously suggest a height of zero for a table view cell’s content view. We’re considering the collapse unintentional and using standard height instead. Cell: <ExpandableUITableView.CustomCell: 0x14be1b900; baseClass = UITableViewCell; frame = (0 0; 320 44); clipsToBounds = YES; autoresize = W; layer = >

    Was this always in the project / code or is it too, new for iOS 13 SDK?

    Reply
    • Hi zoe, It looks like your issue is related to setting constraints in Interface Builder. Can you share a screenshot of your UITableViewController with the constraints showing in your main.storyboard file?

      Reply
  • Hello !! Very helpful article, I want to go one step further and I am struggling a little bit… What if the selected rows are expandable too, and when I tap a row to expand like the section below in your example How will this be implemented… Any help will be appreciated !!

    Reply
    • Hi Rachel, I’m glad you enjoyed the article! To answer your question, you could accomplish that by nesting additional logic inside of the UITableView delegate and data source methods. Although, if I were faced with that situation, I would probably segue to another view controller instead.

      Reply

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top