5

I'm trying to create an accordion-like animation inside a UITableView with cells of dynamic height.

First attempt using reloadRows

enter image description here

The issue seems to be that reloadRows results in an instant cell height update, so although the tableview animates, the cell itself doesn't animate its height. This results in other cells sometimes overlapping and interfering with the expanded cells content.

Second attempt using beginUpdates/endUpdates

enter image description here

This is the closest I've got where the cell expands as desired. However when collapsing, the TextView disappears immediately leaving the title hanging in the middle until collapsed.

Goal

What I try to accomplish is the exact same effect as when expanding in attempt 2, but also in reverse when collapsing (accordion/reveal-style).

Note: if possible I would also like to keep using a StackView for setting the hidden property on the UITextView, as the actual project I'm working on involves several other subviews that should animate similar to the UITextView.

I'm using XCode 11 beta 7.

Code for first attempt using reloadRows:

struct Post {
    var id: String
    var title: String
    var content: String
}

let posts = [
    Post(id: "1", title: "Post 1", content: "Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est"),
    Post(id: "2", title: "Post 2", content: "Lorem ipsum dolor"),
    Post(id: "3", title: "Post 3", content: "Lorem ipsum dolor"),
    Post(id: "4", title: "Post 4", content: "Lorem ipsum dolor"),
    Post(id: "5", title: "Post 5", content: "Lorem ipsum dolor sit er elit lamet, consectetaur cillium adipisicing pecu, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Nam liber te conscient to factor tum poen legum odioque civiuda."),
    Post(id: "6", title: "Post 6", content: "Lorem ipsum dolor")
]

UITableViewController

class MyTableViewController: UITableViewController {
    var selectedRow: IndexPath? = 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 = selectedRow == indexPath
        cell.configure(expanded: isExpanded, post: post)

        return cell
    }

    override func tableView(_ tableView: UITableView,
                            didSelectRowAt indexPath: IndexPath) {
        var reloadPaths: [IndexPath] = []

        if selectedRow != nil {
            reloadPaths.append(selectedRow!)
        }

        if selectedRow == indexPath {
            selectedRow = nil
        } else {
            reloadPaths.append(indexPath)
            selectedRow = indexPath
        }

        tableView.reloadRows(at: reloadPaths, with: .automatic)
    }
}

Cell

class MyTableViewCell: UITableViewCell {
    @IBOutlet weak var title: UILabel!
    @IBOutlet weak var textView: UITextView!

    func configure(expanded: Bool, post: Post) {
        title.text = post.title
        textView.text = post.content
        configureExpansion(expanded)
    }

    func configureExpansion(_ expanded: Bool) {
        self.textView.isHidden = !expanded
        self.contentView.backgroundColor = expanded ?
            .red : .systemBackground
    }
}

Storyboard

enter image description here

Code for attempt 2 using beginUpdates/endUpdates

Exact same code as attempt 1, except ...didSelectRowAt... is replaced with:

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

    if let selectedRow = selectedRow,
        let prevCell = tableView.cellForRow(at: selectedRow) as? MyTableViewCell {
        prevCell.configureExpansion(false)
    }

    let selectedCell = tableView.cellForRow(at: indexPath) as? MyTableViewCell
    if selectedRow == indexPath {
        selectedCell?.configureExpansion(false)
        selectedRow = nil
    } else {
        selectedCell?.configureExpansion(true)
        selectedRow = indexPath
    }

    tableView.endUpdates()
}
Mads Mobæk
  • 34,762
  • 20
  • 71
  • 78
  • 3
    You are getting just the result I would expect. You are changing the cell content and then doing the accordion. And that is just what we see. – matt Sep 08 '19 at 20:20
  • Well, it is not what I expected. I expected an animation similar to: https://jqueryui.com/accordion/#collapsible – Mads Mobæk Sep 08 '19 at 20:25
  • Ah but that is a collapse to zero height with no content change. So you have no reason to expect that. That’s easy to achieve but it is not what you are doing. – matt Sep 08 '19 at 20:27
  • Well, the problem with jqueryui.com/accordion/#collapsible is that the title and the content are different objects - you could use different labels to get around this. When you expand, either 1) create another label or 2) already have a label that you turn visible – impression7vx Sep 08 '19 at 20:27
  • Or just leave the content inside there and shrink it back to the normal size - then on animation completion change the text. – impression7vx Sep 08 '19 at 20:30
  • 2
    @matt that's not a very helpful comment. I guess it is easy to achieve if you know the solution, but if I had the solution I wouldn't ask – Mads Mobæk Sep 08 '19 at 20:41
  • @impression7vx hmm, as far I can tell, that's what I'm doing? I already have the views and when expanding/collapsing I'm simply toggling the visibility of the UITextField. The shrinking/expanding works as expected when no animation is applied. And also at the start/end states of the animation examples above. The problem is the intermediate animation steps between the start and end that looks weird. – Mads Mobæk Sep 08 '19 at 20:58
  • I see I see - I'm not sure on a direct solution - but an indirect solution might be to turn the text the same color as the background until the animation is done - then hide it, potentially? – impression7vx Sep 08 '19 at 21:04
  • I do apologize - I had another question in mind when regarding the other comments. – impression7vx Sep 08 '19 at 21:04
  • You could even make animate the `alpha` from `1` to `0`. Not the original behavior, but an idea – impression7vx Sep 08 '19 at 21:09
  • Well what you are trying to do is much harder than collapse to zero height or expand from zero height. That’s my point. I’ve never seen it done and I don’t know how to do it. It’s very unusual. – matt Sep 08 '19 at 22:18

1 Answers1

5

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()
    }
}

enter image description here

Mads Mobæk
  • 34,762
  • 20
  • 71
  • 78