Expandable Collapsible UITableView Sections

About a year ago, I had a client that wanted a creative way to condense groups of information currently displayed in a UITableView. Now I knew of some cases in which Apps have made UITableView sections collapse and expand to satisfy this requirement. This solution was suggested to the client and of course they loved the idea.
This seemed like a pretty simple development task… until I started to investigate what would be needed to create this functionality programmatically. What I found were complex frameworks, numerous methods and entire custom classes designed to implement this feature. This all seemed way too involved and overly complex given the simplicity of the root functionality.
Now I was faced with the difficult task of implementing this complex logic or convincing the client that this functionality was not the way to go. Given that the client was now enamored with this functionality, the later was not an option. So, I needed to roll up my selves and make this feature work without having to implement logic that is more complex than the host App.
Before going any further, a picture is worth a thousand words, so here is what the desired functionality should look like. When the App is launched, all sections are collapsed as shown in Figure 1. Once a user touches one of the sections to expand it, the view will appear as shown in Figure 2.

Figure 1
Figure 2

To get started, I will outline the tasks that I believed to be required to deliver this functionality. At the end of the day, the solution was rather simple, as I initially suspected, and I didn’t need to include a heavy, complex framework to make it work!
This is the solution that I developed and I have used this solution numerous times for other clients that had the same need. I am sure that there are improvements that could be made to the overall solution, but it met my needs of being extremely light-weight and easy to implement.

The pseudocode for the functionality is as follows.

  1. Display UITableView containing just the header/section titles for each category.
  2. When a section is touched, expand it if it is not already expanded. Otherwise, collapse the section.
  3. When a section is touched, expand it and if another section is currently expanded collapse that section so that only one section is expanded at a time.
  4. To expand a section, determine the number of items that must be displayed within that section and insert that number of rows in the UITableView; then display the items in the newly inserted rows.
  5. To collapse a section, determine the number of items currently displayed within that section and delete that number of rows from the UITableView.

So, at a high-level, this isn’t that complex of a problem and shouldn’t require a heavy framework, right?

That’s what I thought, so let’s begin. I need to do a little bit of setup here. It is my assumption that you have a firm grasp and understanding of the UITableView, its delegate methods and how they function whenever a UITableView is displayed and/or its data are reloaded. If not, you may want to review this functionality as I will not be explaining it within this document.
The design and sample code assume that the UITableView is embedded within a UIViewController rather than starting with a UITableViewController. I believe that this design would still work if you used a UITableViewController, but due to other requirements that I needed to satisfy, my implementation requires this structure, as shown in Figure 3.

Figure 3

1. The first step is to organize your UITableView data.

I decided to use two separate arrays to hold the section titles and section data, which are named sectionNames and sectionItems respectively. There also needs to be a reference to the section header that is currently expanded and position of this header, which are expandedSectionHeader and expandedSectionHeaderNumber respectively. Normally, the position number would be sufficient, but I also decided to be fancy and make the dropdown chevron rotate when sections are expanded and collapsed. So, for this you need a reference to the section header object to lookup the reference to the chevron image for the expanded section and un-rotate it as the section is collapsed.

Swift:

var expandedSectionHeaderNumber: Int = -1 var expandedSectionHeader: UITableViewHeaderFooterView! var sectionItems: Array<Any> = [] var sectionNames: Array<Any> = []

Objective-c:

@property (assign) NSInteger expandedSectionHeaderNumber;
@property (assign) UITableViewHeaderFooterView *expandedSectionHeader;
@property (strong) NSArray *sectionItems;
@property (strong) NSArray *sectionNames;

2. Mock data. In the viewDidLoad method, define the mock data needed to support the demo App.

Swift:

        sectionNames = [ "iPhone", "iPad", "Apple Watch" ];
        sectionItems = [ ["iPhone 5", "iPhone 5s", "iPhone 6", "iPhone 6 Plus", "iPhone 7",
     "iPhone 7 Plus"],
                         ["iPad Mini", "iPad Air 2", "iPad Pro", "iPad Pro 9.7"],
                         ["Apple Watch", "Apple Watch 2", "Apple Watch 2 (Nike)"]
                       ];

Objective-c:

    self.sectionNames = @[ @"iPhone", @"iPad", @"Apple Watch" ];
    self.sectionItems = @[ @[@"iPhone 5", @"iPhone 5s", @"iPhone 6", @"iPhone 6 Plus",
 @"iPhone 7", @"iPhone 7 Plus"],
                           @[@"iPad Mini", @"iPad Air 2", @"iPad Pro", @"iPad Pro 9.7"],
                           @[@"Apple Watch", @"Apple Watch 2", @"Apple Watch 2 (Nike)"]
                         ];

3. Define the number of sections that must be displayed in the initial UITalbeView when the view loads. This is determined by counting the number of items in the sectionNames array.

Swift:

func numberOfSections(in tableView: UITableView) -> Int {
    if sectionNames.count > 0 {
        tableView.backgroundView = nil
        return sectionNames.count
    } else {
        let messageLabel = UILabel(frame: CGRect(x: 0, y: 0, width: view.bounds.size.width, height: view.bounds.size.height))
        messageLabel.text = "Retrieving data.\nPlease wait."
        messageLabel.numberOfLines = 0;
        messageLabel.textAlignment = .center;
        messageLabel.font = UIFont(name: "HelveticaNeue", size: 20.0)!
        messageLabel.sizeToFit()
        self.tableView.backgroundView = messageLabel;
    }
    return 0
}

Objective-c:

- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {    
    if (self.sectionNames.count > 0) {
        self.tableView.backgroundView = nil;
        return self.sectionNames.count;
    } else {
        UILabel *messageLabel = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, self.view.bounds.size.width, self.view.bounds.size.height)];
        messageLabel.text = @"Retrieving data.\nPlease wait.";
        messageLabel.numberOfLines = 0;
        messageLabel.textAlignment = NSTextAlignmentCenter;
        messageLabel.font = [UIFont fontWithName:@"Helvetica Neue" size:20];
        [messageLabel sizeToFit];
        self.tableView.backgroundView = messageLabel;     
        return 0;
    }
}

4. Then determine the number of rows that should be displayed in each section, if it is expanded. Initially, or any time that all sections are collapsed, the number of rows within a section is set to zero.

Otherwise, for the currently expanded section (expandedSectionHeaderNumber), the number of row to display is set to the number of items in the sectionItems array, for that selected section. E.g. if section one is active, count the items at position one of the array (sectionItems).

Swift:

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    if (self.expandedSectionHeaderNumber == section) {
        let arrayOfItems = self.sectionItems[section] as! NSArray
        return arrayOfItems.count;
    } else {
        return 0;
    }
}

Objective-c:

- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    if (self.expandedSectionHeaderNumber == section) {
        NSMutableArray *arrayOfItems = [self.sectionItems objectAtIndex:section];
        return arrayOfItems.count;
    } else {
        return 0;
    }
}

5. Set the title text for the active section in the titleForHeaderInSection method.

Swift:

    if (self.sectionNames.count != 0) {
        return self.sectionNames[section] as? String
    }
    return ""

Objective-c:

    if (self.sectionNames.count) {
        return [self.sectionNames objectAtIndex:section];
    }  
    return @"";

6. Next setup the active header view in the willDisplayHeaderView method.

To make it possible to identify each specific imageView within a given header, I assign a unique tag number to each imageView. This tag number provides the ability to lookup the imageView and animate it when the user selects a new or different section to expand or collapse. This reference helps to simplify the process and ensures that each header contains only one copy of the image. Otherwise, each time the UITableView is redrawn, duplicate copies of the imageView would be inserted into the header.

Swift:

    //recast your view as a UITableViewHeaderFooterView
    let header: UITableViewHeaderFooterView = view as! UITableViewHeaderFooterView
    header.contentView.backgroundColor = UIColor.colorWithHexString(hexStr: "#408000")
    header.textLabel?.textColor = UIColor.white
    if let viewWithTag = self.view.viewWithTag(kHeaderSectionTag + section) {
        viewWithTag.removeFromSuperview()
    }
    let headerFrame = self.view.frame.size
    let theImageView = UIImageView(frame: CGRect(x: headerFrame.width - 32, y: 13, width: 18, height: 18));
    theImageView.image = UIImage(named: "Chevron-Dn-Wht")
    theImageView.tag = kHeaderSectionTag + section
    header.addSubview(theImageView)
   
    // make headers touchable
    header.tag = section
    let headerTapGesture = UITapGestureRecognizer()
    headerTapGesture.addTarget(self, action: #selector(ViewController.sectionHeaderWasTouched(_:)))
    header.addGestureRecognizer(headerTapGesture)

Objective-c:

    // recast your view as a UITableViewHeaderFooterView
    UITableViewHeaderFooterView *header = (UITableViewHeaderFooterView *)view;
    header.contentView.backgroundColor = [UIColor colorWithHexString:@"#408000"];
    header.textLabel.textColor = [UIColor whiteColor];
    UIImageView *viewWithTag = [self.view viewWithTag:kHeaderSectionTag + section];
    if (viewWithTag) {
        [viewWithTag removeFromSuperview];
    }
    // add the arrow image
    CGSize headerFrame = self.view.frame.size;
    UIImageView *theImageView = [[UIImageView alloc] initWithFrame:CGRectMake(headerFrame.width - 32, 13, 18, 18)];
    theImageView.image = [UIImage imageNamed:@"Chevron-Dn-Wht"];
    theImageView.tag = kHeaderSectionTag + section;
    [header addSubview:theImageView];    
    // make headers touchable
    header.tag = section;
    UITapGestureRecognizer *headerTapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(sectionHeaderWasTouched:)];
    [header addGestureRecognizer:headerTapGesture];

7. Displaying the cell contents when a specific section is expanded is relatively simple and is accomplished using the following code.

Swift:

func tableView(_ tableView: UITableView, cellForRowAtindexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "tableCell", for: indexPath) as UITableViewCell
    let section = self.sectionItems[indexPath.section] as! NSArray
    cell.textLabel?.textColor = UIColor.black
    cell.textLabel?.text = section[indexPath.row] as? String 
    return cell
}

Objective-c:

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"tableCell" forIndexPath:indexPath];
    NSArray *section = [self.sectionItems objectAtIndex:indexPath.section];
    cell.textLabel.textColor = [UIColor blackColor];
    cell.textLabel.text = [section objectAtIndex:indexPath.row]; 
    return cell;
}

8. To ensure that the highlighting for the selected row removed, implement this functionality in the didDeselectRowAtindexPath method.

Swift:

func tableView(_ tableView: UITableView, didDeselectRowAtindexPath: IndexPath) {
    tableView.deselectRow(at: indexPath, animated: true)
}

Objective-c:

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    [tableView deselectRowAtIndexPath:indexPath animated:YES];
}

Now that the UITableView delegate methods have been setup to manage the expanding and collapsing of the sections, it is time to define the methods that are responsible for determining which sections should be expanded and/or collapsed based upon the user touches. The rotation animation of the chevron image in each header section is also controlled by these methods.

9. The starting point is the sectionHeaderWasTouched method. If you would like to review where the association to this method is defined, refer to step 6) above. It is there that this method is linked to the UITapGestureRecognizer defined and assigned to each section header.

This method does a lot of the heavy lifting, determining which sections are to be expanded and/or collapsed. When the user touches a specific section header, the gesture recognizer triggers this method and the following occurs.

  • The UITableView Header is extracted along with the tags for the headerView and the chevron imageView.
  • If none of the sections are currently expanded, then the task is straight forward, the expandedSectionHeaderNumber is set to the selected section number and the tableViewExpandSection method is called to perform the expansion.
  • If a section is expanded then:
    • the next step is to determine if the expanded section is the section that was touched.
    • If it is then this section is collapsed and the expandedSectionHeaderNumber is reset to -1.
    • If the section is not the expanded section, then the expanded section is collapsed and finally the new section can be expanded.

    The above logic is implemented as shown in the method below.

Swift:

func sectionHeaderWasTouched(_ sender: UITapGestureRecognizer) {
    let headerView = sender.view as! UITableViewHeaderFooterView
    let section    = headerView.tag
    let eImageView = headerView.viewWithTag(kHeaderSectionTag + section) as? UIImageView
    if (self.expandedSectionHeaderNumber == -1) {
        self.expandedSectionHeaderNumber = section
        tableViewExpandSection(section, imageView: eImageView!)
    } else {
        if (self.expandedSectionHeaderNumber == section) {
            tableViewCollapeSection(section, imageView: eImageView!)
        } else {
            let cImageView = self.view.viewWithTag(kHeaderSectionTag + self.expandedSectionHeaderNumber) as? UIImageView
            tableViewCollapeSection(self.expandedSectionHeaderNumber, imageView: cImageView!)
            tableViewExpandSection(section, imageView: eImageView!)
        }
    }
}

Objective-c:

- (void)sectionHeaderWasTouched:(UITapGestureRecognizer *)sender {
    UITableViewHeaderFooterView *headerView = (UITableViewHeaderFooterView *)sender.view;
    NSInteger section = headerView.tag;
    UIImageView *eImageView = (UIImageView *)[headerView viewWithTag:kHeaderSectionTag + section];
    self.expandedSectionHeader = headerView;
    if (self.expandedSectionHeaderNumber == -1) {
        self.expandedSectionHeaderNumber = section;
        [self tableViewExpandSection:section withImage: eImageView];
    } else {
        if (self.expandedSectionHeaderNumber == section) {
            [self tableViewCollapeSection:section withImage: eImageView];
            self.expandedSectionHeader = nil;
        } else {
            UIImageView *cImageView  = (UIImageView *)[self.view viewWithTag:kHeaderSectionTag + self.expandedSectionHeaderNumber];
            [self tableViewCollapeSection:self.expandedSectionHeaderNumber withImage: cImageView];
            [self tableViewExpandSection:section withImage: eImageView];
        }
    }
}

10. The collapse method is detailed within this section. It resets the expandedSectionHeaderNumber back to -1, indicating that no section is expanded. The chevron rotation animation is set and the rows are deleted from the UITableView.

Swift:

func tableViewCollapeSection(_ section: Int, imageView: UIImageView) {
    let sectionData = self.sectionItems[section] as! NSArray
    self.expandedSectionHeaderNumber = -1;
    if (sectionData.count == 0) {
        return;
    } else {
        UIView.animate(withDuration: 0.4, animations: {
            imageView.transform = CGAffineTransform(rotationAngle: (0.0 * CGFloat(Double.pi)) / 180.0)
        })
        var indexesPath = [IndexPath]()
        for i in 0 ..< sectionData.count {
            let index = IndexPath(row: i, section: section)
            indexesPath.append(index)
        }
        self.tableView!.beginUpdates()
        self.tableView!.deleteRows(at: indexesPath, with: UITableViewRowAnimation.fade)
        self.tableView!.endUpdates()
    }
}

Objective-c:

- (void)tableViewCollapeSection:(NSInteger)section withImage:(UIImageView *)imageView {
    NSArray *sectionData = [self.sectionItems objectAtIndex:section];
    self.expandedSectionHeaderNumber = -1;
    if (sectionData.count == 0) {
        return;
    } else {
        [UIView animateWithDuration:0.4 animations:^{
            imageView.transform = CGAffineTransformMakeRotation((0.0 * M_PI) / 180.0);
        }];
        NSMutableArray *arrayOfIndexPaths = [NSMutableArray array];
        for (int i=0; i< sectionData.count; i++) {
            NSIndexPath *index = [NSIndexPath indexPathForRow:i inSection:section];
            [arrayOfIndexPaths addObject:index];
        }
        [self.tableView beginUpdates];
        [self.tableView deleteRowsAtIndexPaths:arrayOfIndexPaths withRowAnimation: UITableViewRowAnimationFade];
        [self.tableView endUpdates];
    }
}

11. The expand section method first verifies that there is data to be displayed. If not, it resets the expandedSectionHeaderNumber since there is no data to display and the section is not expanded. Otherwise, the chevron animation is set and the required number of rows of data are inserted to the UITableView.

Swift:

func tableViewExpandSection(_ section: Int, imageView: UIImageView) {
    let sectionData = self.sectionItems[section] as! NSArray
    if (sectionData.count == 0) {
        self.expandedSectionHeaderNumber = -1;
        return;
    } else {
        UIView.animate(withDuration: 0.4, animations: {
            imageView.transform = CGAffineTransform(rotationAngle: (180.0 * CGFloat(Double.pi)) / 180.0)
        })
        var indexesPath = [IndexPath]()
        for i in 0 ..< sectionData.count {
            let index = IndexPath(row: i, section: section)
            indexesPath.append(index)
        }
        self.expandedSectionHeaderNumber = section
        self.tableView!.beginUpdates()
        self.tableView!.insertRows(at: indexesPath, with: UITableViewRowAnimation.fade)
        self.tableView!.endUpdates()
    }
}

Objective-c:

- (void)tableViewExpandSection:(NSInteger)section withImage:(UIImageView *)imageView {
    NSArray *sectionData = [self.sectionItems objectAtIndex:section]; 
    if (sectionData.count == 0) {
        self.expandedSectionHeaderNumber = -1;
        return;
    } else {
        [UIView animateWithDuration:0.4 animations:^{
            imageView.transform = CGAffineTransformMakeRotation((180.0 * M_PI) / 180.0);
        }];
        NSMutableArray *arrayOfIndexPaths = [NSMutableArray array];
        for (int i=0; i< sectionData.count; i++) {
            NSIndexPath *index = [NSIndexPath indexPathForRow:i inSection:section];
            [arrayOfIndexPaths addObject:index];
        }
        self.expandedSectionHeaderNumber = section;
        [self.tableView beginUpdates];
        [self.tableView insertRowsAtIndexPaths:arrayOfIndexPaths withRowAnimation: UITableViewRowAnimationFade];
        [self.tableView endUpdates];
    }
}

Okay, so this is the solution that I have implemented to satisfy this requirement. As originally stated, the solution is not overly complex, nor is it a heavy framework that will bloat an App.
I created both an Objective-c and Swift projects that demonstrate the final working solution and can be found on GitHub using this URL: https://github.com/jjohnson2/UITableViewExpandCollapse.git
Enjoy, and I hope you find value in this solution.
If you’d like some help with building your organization’s application, check out our Customer Engagement services or reach out. We’d love to get you started.
GET STARTED

Share on

linkedin sharing button twitter sharing button

Ready to get started?

Enter your information to keep the conversation going.
Location image
4 Sentry Parkway East, Suite 300, Blue Bell PA, 19422

Email Image

info@anexinet.com

Phono Image610 239 8100


Location Image4 Sentry Parkway East, Suite 300, Blue Bell PA, 19422
Phono Image610 239 8100