Making Simple Accordion Tableview in Swift

Making Simple Accordion TableView in swift?

The answer provided by @TechBee works fine using sections for those interested in not using sections and use cells.

The implementation of an Accordion Menu in Swift can be achieved using UITableView in a very simple way, just having two cells one for the parent cells and another for the childs cells and in every moment keep the track for the cells expanded or collapsed because it can change the indexPath.row every time a new cell is expanded or collapsed.

Using the functions insertRowsAtIndexPaths(_:withRowAnimation:) and deleteRowsAtIndexPaths(_:withRowAnimation:) always inside a block of call to tableView.beginUpdates() and tableView.endUpdates() and updating the total of items in the data source or simulating it changes we can achieve the insertion of deletion of new cells in the UITableView in a very easy way with animation included.

I've implemented myself a repository in Github with all the explained above AccordionMenu using Swift and UITableView in a easy and understandable way. It allows several cells expanded or only one at time.

How to create an Accordion with UItableview under a UItableview?

Better solution is Expand or Collapse TableView Sections

Good Tutorial is available here

You can download the sample code here

Sample Code

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
{
static NSString *CellIdentifier = @"Cell";

UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:CellIdentifier];
if (cell == nil) {
cell = [[[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:CellIdentifier] autorelease];
}

// Configure the cell...

if ([self tableView:tableView canCollapseSection:indexPath.section])
{
if (!indexPath.row)
{
// first row
cell.textLabel.text = @"Expandable"; // only top row showing

if ([expandedSections containsIndex:indexPath.section])
{
cell.accessoryView = [DTCustomColoredAccessory accessoryWithColor:[UIColor grayColor] type:DTCustomColoredAccessoryTypeUp];
}
else
{
cell.accessoryView = [DTCustomColoredAccessory accessoryWithColor:[UIColor grayColor] type:DTCustomColoredAccessoryTypeDown];
}
}
else
{
// all other rows
cell.textLabel.text = @"Some Detail";
cell.accessoryView = nil;
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
}
}
else
{
cell.accessoryView = nil;
cell.textLabel.text = @"Normal Cell";

}

return cell;
}

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
if ([self tableView:tableView canCollapseSection:indexPath.section])
{
if (!indexPath.row)
{
// only first row toggles exapand/collapse
[tableView deselectRowAtIndexPath:indexPath animated:YES];

NSInteger section = indexPath.section;
BOOL currentlyExpanded = [expandedSections containsIndex:section];
NSInteger rows;

NSMutableArray *tmpArray = [NSMutableArray array];

if (currentlyExpanded)
{
rows = [self tableView:tableView numberOfRowsInSection:section];
[expandedSections removeIndex:section];

}
else
{
[expandedSections addIndex:section];
rows = [self tableView:tableView numberOfRowsInSection:section];
}

for (int i=1; i<rows; i++)
{
NSIndexPath *tmpIndexPath = [NSIndexPath indexPathForRow:i
inSection:section];
[tmpArray addObject:tmpIndexPath];
}

UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];

if (currentlyExpanded)
{
[tableView deleteRowsAtIndexPaths:tmpArray
withRowAnimation:UITableViewRowAnimationTop];

cell.accessoryView = [DTCustomColoredAccessory accessoryWithColor:[UIColor grayColor] type:DTCustomColoredAccessoryTypeDown];

}
else
{
[tableView insertRowsAtIndexPaths:tmpArray
withRowAnimation:UITableViewRowAnimationTop];
cell.accessoryView = [DTCustomColoredAccessory accessoryWithColor:[UIColor grayColor] type:DTCustomColoredAccessoryTypeUp];

}
}
}
}

UITableView with accordion-like animation of cells with dynamic height

I finally solved it building upon beginUpdates/endUpdates. My further research into the problem and solution follows.

The problem

As described in the original question, the expansion phase works correctly. The reason this works is:

  1. When setting the UITextView's isHidden property to false, the containing stack view resizes and gives the cell a new intrinsic content size
  2. Using beginUpdates/endUpdates will animate the change in row heights without reloading the cell. The starting state is that the UITextView's content is visible but currently clipped by the current cell height, since the cell hasn't resized yet.
  3. When endUpdates is called, the cell will automatically expand since the intrinsic size has changed and tableView.rowHeight = .automaticDimension. The animation will gradually reveal the new content as desired.

However, the collapsing phase doesn't yield the same animation effect in reverse. The reason it doesn't is:

  1. When setting the UITextView's isHidden property to true, the containing stack view hides the view and resizes the cell a new intrinsic content size.
  2. When endUpdates is called, the animation will start by removing the UITextView immediately. Thinking about it, this is expected since it's the inverse of what happened during expansion. However, it is also breaking the desired animation effect by leaving the visible elements "hanging" in the middle instead of gradually concealing the UITextView when the row shrinks.

A solution

To get the desired concealing effect the UITextView should stay visible during the whole animation while the cell shrinks. This consists of several steps:

Step 1: Override heightForRow

override func tableView(_ tableView: UITableView,
heightForRowAt indexPath: IndexPath) -> CGFloat {
// If a cell is collapsing, force it to its original height stored in collapsingRow?
// instead of using the intrinsic size and .automaticDimension
if indexPath == collapsingRow?.indexPath {
return collapsingRow!.height
} else {
return UITableView.automaticDimension
}
}

The UITextView will now stay visible while the cell shrinks, but due to auto layout the UITextView is clipped immediately since it is anchored to the cell height.

Step 2: Create a height constraint on UIStackView and have it take priority while the cell is shrinking

2.1: added a height constraint on UIStackView in Interface builder

2.2: added heightConstraint as a strong (not weak, this is important) outlet in MyTableViewCell

2.3 in awakeFromNib in MyTableViewCell, set heightConstraint.isActive = false to keep the default behavior

2.4 in interface builder: make sure the priority of UIStackView's bottom constraint is set lower than the priority of the height constraint. I.e. 999 for the bottom constraint and 1000 for the height constraint. Failing to do this results in conflicting constraints during the collapsing phase.

Step 3: when collapsing, activate the heightConstraint and set it to UIStackView's current intrinsic size. This keep the contents of UITextView visible while cell height decreases, but also clips the contents as desired, resulting in the "conceal" effect.

if let expandedRow = expandedRow,
let prevCell = tableView.cellForRow(at: expandedRow.indexPath) as? MyTableViewCell {
prevCell.heightConstraint.constant = prevCell.stackView.frame.height
prevCell.heightConstraint.isActive = true

collapsingRow = expandedRow
}

Step 4: reset state when the animation is complete by using CATransaction.setCompletionBlock

The steps combined

class MyTableViewController: UITableViewController {
var expandedRow: (indexPath: IndexPath, height: CGFloat)? = nil
var collapsingRow: (indexPath: IndexPath, height: CGFloat)? = nil

override func viewDidLoad() {
super.viewDidLoad()
self.tableView.backgroundColor = .darkGray
self.tableView.rowHeight = UITableView.automaticDimension
self.tableView.estimatedRowHeight = 200
}

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

override func tableView(_ tableView: UITableView,
numberOfRowsInSection section: Int) -> Int {
return posts.count
}

override func tableView(_ tableView: UITableView,
cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(
withIdentifier: "Cell", for: indexPath) as? MyTableViewCell
else { fatalError() }

let post = posts[indexPath.row]
let isExpanded = expandedRow?.indexPath == indexPath
cell.configure(expanded: isExpanded, post: post)

return cell
}

override func tableView(_ tableView: UITableView,
heightForRowAt indexPath: IndexPath) -> CGFloat {
if indexPath == collapsingRow?.indexPath {
return collapsingRow!.height
} else {
return UITableView.automaticDimension
}
}

override func tableView(_ tableView: UITableView,
didSelectRowAt indexPath: IndexPath) {
guard let tappedCell = tableView.cellForRow(at: indexPath) as? MyTableViewCell
else { return }

CATransaction.begin()
tableView.beginUpdates()

if let expandedRow = expandedRow,
let prevCell = tableView.cellForRow(at: expandedRow.indexPath)
as? MyTableViewCell {
prevCell.heightConstraint.constant = prevCell.stackView.frame.height
prevCell.heightConstraint.isActive = true

CATransaction.setCompletionBlock {
if let cell = tableView.cellForRow(at: expandedRow.indexPath)
as? MyTableViewCell {
cell.configureExpansion(false)
cell.heightConstraint.isActive = false
}
self.collapsingRow = nil
}

collapsingRow = expandedRow
}


if expandedRow?.indexPath == indexPath {
collapsingRow = expandedRow
expandedRow = nil
} else {
tappedCell.configureExpansion(true)
expandedRow = (indexPath: indexPath, height: tappedCell.frame.height)
}

tableView.endUpdates()
CATransaction.commit()
}
}

Sample Image



Related Topics



Leave a reply



Submit