7

I have a table view similar to the Compose screen in Mail, where one of the cells is used for text input. I'd like the cell to resize automatically by constraining the contentView of the cell to its UITextView subview using Auto Layout. The table view's rowHeight and estimatedRowHeight are set to UITableViewAutomaticDimension. Additionally, the text view's scrollEnabled property is set to false so that it reports its intrinsicContentSize.

However, the cell does not update its height as lines of text are entered by the user, even though the text view itself does update its intrinsic content size. As far as I know, the only way to request this manually is for the table view to call beginUpdates() and endUpdates(). The height of the cell is then correctly updated, but this also causes re-layout of the entire table view. Here's (someone else's) sample code demonstrating the problem.

How can I reflect changes in the cell's text view without laying out the entire table view?

  • How have you solved this? Do you have a solution that does not jitter? – SwiftArchitect Feb 09 '16 at 18:13
  • So far I've only found partial solutions. The first approach I tried, just calling `beginUpdates()` and `endUpdates()` in `textViewDidChange`, is close to what I want. But then you have to deal with the whole table view layout becoming invalidated, and subsequent scrolling. Alternatively, reloading the row could work _if_ it could be done with no visible interruption, but that starts to lead into hacky KVO stuff with Core Animation that I'd rather avoid. –  Feb 10 '16 at 03:18
  • Trouble with `beginUpdates`+`endUpdates`: starts jittering when top of cell is out of sight. Trouble with `reloadRowsAtIndexPaths`: cancels editing so keyboard goes away. I have had reasonable success with a **throttled** solution. – SwiftArchitect Feb 10 '16 at 16:56

3 Answers3

3

Throttled Solution

The trouble with reloadRowsAtIndexPaths is that the UITextField will resignFirstResponder since the cell, in essence, is reloaded: i.e. destroyed.

Conversely, beginUpdates() & endUpdates() do maintain the existing cells, yet jitters when invoked on a cell containing a scrollEnabled UITextView if triggered with every single textViewDidChange.

Limit the updates frequency

This solution is based on the popular textViewDidChange approach, only reduces or stops flickering entirely by postponing the update.

Subclass UITableViewCell:

class TableViewTextViewCell : UITableViewCell, UITextViewDelegate {
    var refreshCell:(() -> Void)? = nil
    var textViewDirtyCount = 0

    // MARK: - UITextViewDelegate
    func textViewDidChange(_ textView: UITextView) {
        textViewDirtyCount += 1
        perform(#selector(TableViewTextViewCell.queuedTextVewDidChange),
                with: nil,
                afterDelay: 0.3) // Wait until typing stopped
    }

    func textViewDidBeginEditing(_ textView: UITextView) {
        textViewDirtyCount = 0 // initialize queuedTextVewDidChange
    }

    func textViewDidEndEditing(_ textView: UITextView) {
        textViewDirtyCount = -1 // prevent any further queuedTextVewDidChange
    }

    func queuedTextVewDidChange() {
        if textViewDirtyCount > 0 {
            textViewDirtyCount -= 1
            if 0 == textViewDirtyCount, let refreshCell = refreshCell {
                refreshCell()
            }
        }
    }
}

Dequeue & update closure:

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCellWithIdentifier(
        "cell", forIndexPath: indexPath) as! TableViewTextViewCell

    cell.refreshCell = {
        () -> Void in
        tableView.beginUpdates()
        tableView.endUpdates()
    }
    return cell
}

Notice the 0.3 seconds delay after the last character has been entered ; if less than 0.3 seconds has elapsed since the last change, no update takes place. This significantly reduces flickering.

Frame Delay

↻ replay animation


► Find this solution on GitHub and additional details on Swift Recipes.

Community
  • 1
  • 1
SwiftArchitect
  • 47,376
  • 28
  • 140
  • 179
  • I really appreciate the effort, but this still isn't an appropriate solution when the primary focus of the interface involves editing text. Every new line is obscured until the user stops typing, which can become even more glaring if text attachments are involved. Hopefully others will find this answer useful, though. –  Feb 11 '16 at 16:57
  • I experience the obscured line until the 2nd word. No worries on the effort, it is in a shipped product. Clearly, I am very interested in a robust, universal solution. – SwiftArchitect Feb 11 '16 at 17:56
3

The beginUpdates method works if you disable UIView animations before calling it.

//inside of cellForRowAtIndexPath
cell.performCellUpdates = { [unowned self] in
    UIView.setAnimationsEnabled(false)   //the secret sauce, prevents the jitter
    self.tv.beginUpdates()
    self.tv.endUpdates()
    UIView.setAnimationsEnabled(true)    //don't forget to turn animations back on
}
   
...

//inside of cell subclass
func textViewDidChange(_ textView: UITextView) {
    textView.sizeToFit()
    performCellUpdates()
}
desertnaut
  • 57,590
  • 26
  • 140
  • 166
bgolson
  • 3,460
  • 5
  • 24
  • 41
  • 1
    Thanks, I actually tried this early on… Unfortunately, this approach prevents the table view from scrolling at all while the user edits the text (even when the text view's cursor winds up outside the visible content area), so it wasn't usable in my case. This method doesn't cause "jittering" though, so it could be sufficient for others. –  Feb 06 '18 at 00:48
1

You cannot change the height of an existing UITableViewCell. You want to tell the UITableView that cell is invalid, and reload it.

At the time the cell is reloaded (cellForRowAtIndexPath) you can then pass the new, updated cell. And you trigger a single cell refresh with tableView.reloadRowsAtIndexPaths([indexPath], withRowAnimation: .Fade)

reloadRowsAtIndexPaths is not as drastic are reloadData(). It is a punctual index refresh, does not need to be surrounded by tableView.beginUpdates and tableView.endUpdates, and you get to supply an animation method of your choice, such as .Fade.

See reloadRowsAtIndexPaths(_:withRowAnimation:) in the documentation.


Also visit this Stack Overflow answer which describes changing a cell height in details.

Animated demo

Community
  • 1
  • 1
SwiftArchitect
  • 47,376
  • 28
  • 140
  • 179
  • Well, indeed you don't have to reload the cell…the documentation for `beginUpdates` states, "You can also use this method followed by the `endUpdates` method to animate the change in the row heights without reloading the cell." However, a _layout_ pass is nevertheless performed for the entire table view, which scrolls it to the top, disrupting text input. –  Feb 08 '16 at 01:49
  • I have clarified my answer: invoking `tableView.reloadRowsAtIndexPaths([indexPath], withRowAnimation: .Fade)` is all you need to trigger the refresh of a single cell and it's height. – SwiftArchitect Feb 08 '16 at 15:09
  • The approach I described above already updates the layout without reloading any cells. Since the text view in my cell updates as the user edits text, reloading the cell doesn't appear to be an option (at least not that I've figured out). My question is whether there's a way to specify that only the specific cell with updated content needs to be laid out. Going by the start of your post, I guess there isn't. –  Feb 08 '16 at 18:34
  • There is this conundrum described on http://stackoverflow.com/a/26599389/218152: **(1)** If you use `beginUpdates`/`endUpdates` with `scrollEnabled=false`, you end up jittering uncontrollably when text gets large, since the table is redrawn from top-down, then scrolls back to insertion point. **(2)** If you use `reloadRowsAtIndexPaths`, you loose the editing. An approach consists in tie `scrollEnabled` for the `UITextView` to `becomeFirstResponder/resignFirstResponder`. Visual effect: the cell only resizes when editing is complete. – SwiftArchitect Feb 08 '16 at 19:56