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:
- When setting the UITextView's
isHidden
property tofalse
, the containing stack view resizes and gives the cell a new intrinsic content size - 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.
- When
endUpdates
is called, the cell will automatically expand since the intrinsic size has changed andtableView.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:
- When setting the UITextView's
isHidden
property totrue
, the containing stack view hides the view and resizes the cell a new intrinsic content size. - 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()
}
}
Related Topics
How to Access Program Arguments in Swift
Get Description of Emoji Character
How to Use the Appropriate Color Class For the Current Platform
How to Get User Home Directory Path (Users/"User Name") Without Knowing the Username in Swift3
Swift: Declaration in Generic Class
Mathematical Functions in Swift
Dispatchsourcetimer and Swift 3.0
Swift: Determine What Object Called a Function
Error When Trying to Save Image in Nsuserdefaults Using Swift
Check Whether Swift Object Is an Instance of a Given Metatype
Function Does Not Wait Until the Data Is Downloaded
How to Satisfy Swift Protocol and Add Defaulted Arguments
How to Create an Importable Module in Swift
Thread Safe Singleton in Swift