Autoresizing table cells

Extending the autoresizing of UITextView to table cells, this is how that looks.

First, let’s create a trivial model class and a storage class for that model:

class Item: NSObject {

  var text: String

    

  init(text: String) {

    self.text = text

    super.init()

  }

}

 

class ItemStore {

  var allItems = [Item]()

    

  func createItem(text: String) -> Item {

    let newItem = Item(text: text)

    allItems.append(newItem)

    return newItem

  }

    

  init() {

    for_in 0..<20 {

      createItem(“For ever and ever, this may be a great filler text. We will never know with absolute certainty, will we? Horray!”)

    }

  }

}

 
As you can see, I create 20 items all with the same text, just to be able to fill more than a screen in the table view. In the main storyboard, delete the default window and replace it all with a table view controller. Subclass the controller:

import UIKit

protocol RefreshableTableViewController {

  func refresh()

}

class MyTableViewController: UITableViewController, RefreshableTableViewController {

  var itemStore: ItemStore!

    

  override func viewDidLoad() {

    super.viewDidLoad()

        

    // move down the table view contents a bit

        

    let statusBarHeight = UIApplication.sharedApplication().statusBarFrame.height

    let insets = UIEdgeInsets(top: statusBarHeight, left: 0, bottom: 0, right: 0)

    tableView.contentInset = insets

    tableView.scrollIndicatorInsets = insets

        

    tableView.rowHeight = UITableViewAutomaticDimension

    tableView.estimatedRowHeight = 65

  }

    

  override func tableView(tableView: UITableView,

               numberOfRowsInSection section: Int) -> Int {

    return itemStore.allItems.count

  }

    

  override func tableView(tableView: UITableView,

                cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {

    let cell = tableView.dequeueReusableCellWithIdentifier(“ItemCell”,

                forIndexPath: indexPath) as! ItemCell

        

    // cell needs a delegate link back to the tableView controller, so it

    // can trigger refresh of row heights

    cell.tableController = self

        

    // hand the item to the cell. as it updates the item, the model store

    // is automatically kept up to date since it holds a reference to the item

    let item = itemStore.allItems[indexPath.row]

    cell.useItem(item)

        

    return cell

  }

  func refresh() {

    // after updating the size and constraints of the table view cell,

    // the table itself

    // must refresh to adjust the distance between the rows

        

    tableView.beginUpdates()

    tableView.endUpdates()

  }

    

  override func didRotateFromInterfaceOrientation(

                 fromInterfaceOrientation: UIInterfaceOrientation) {

    // updating all constraints and table rows after a rotation requires the heavy

    // hand of a full reload

        

    tableView.reloadData()

  }

}

 
 
From the application delegate, create the store, get the view controller and feed it the store:
 

  func application(application: UIApplication, didFinishLaunchingWithOptions

             launchOptions: [NSObject: AnyObject]?) -> Bool {

    // Override point for customization after application launch.

        

    let itemStore = ItemStore()

        

    let itemController = window!.rootViewController as! MyTableViewController

    itemController.itemStore = itemStore

        

    return true

  }

 
Now, subclass UITableViewCell:

class ItemCell: UITableViewCell, UITextViewDelegate {

    

  @IBOutlet var textView: UITextView!

  @IBOutlet var heightConstraint: NSLayoutConstraint!

  var lastCalculatedTextHeight: CGFloat = 0.0

  var tableController: RefreshableTableViewController?

  var item: Item!     // must be class for reference semantics

    

  func useItem(item: Item) {

    self.item = item

    self.textView.text = item.text

    updateMeasurements()

  }

    

  func updateMeasurements() {

    let newHeight = calculateHeightTextView(textView, forText: textView.text)

    heightConstraint.constant = newHeight

  }

  func textViewDidChange(textView: UITextView) {

    // update the model with the current text

    item.text = textView.text

  }

    

  func textView(textView: UITextView, shouldChangeTextInRange range: NSRange,

                    replacementText text: String) -> Bool {

    // it’s important that we measure the text and set the constraint *before*

    // the textview actually sets the new

    // text, else the scroll view will jitter and jump.

    // that’s why we catch “shouldChange” to get in ahead of that

        

    let newText = buildNewText(textView.text, range: range, replacementText: text)

    let newHeight = calculateHeightTextView(textView, forText: newText)

    refreshIfNeeded(newHeight)

    return true

  }

    

  func refreshIfNeeded(newHeight: CGFloat) {

    if newHeight != lastCalculatedTextHeight {

      lastCalculatedTextHeight = newHeight

      heightConstraint.constant = newHeight

      tableController?.refresh()

    }

  }

    

  func buildNewText(oldText: String, range: NSRange,

                   replacementText: String) -> String {

    if let nsRange = oldText.rangeFromNSRange(range) {

      let newText = oldText.stringByReplacingCharactersInRange(nsRange,

                   withString: replacementText)

      return newText

    }

    // if the range was invalid, just return the old text

    return oldText

  }

    

  func calculateHeightTextView(textView: UITextView, forText: String) -> CGFloat {

        

    if let font = textView.font {

      let padding = textView.textContainer.lineFragmentPadding

      let width = textView.contentSize.width – 2 * padding

            

      let attributes = [NSFontAttributeName: font]

 

      let newRect = forText.boundingRectWithSize(

               CGSize(width: width, height: CGFloat.max),

               options: [.UsesLineFragmentOrigin, .UsesFontLeading],

               attributes: attributes, context: nil)

      let textInset = textView.textContainerInset

      let newHeight = newRect.height + textInset.bottom + textInset.top

            

      return newHeight

    }

    // if we don’t know what else to say, just return the old height,

    // probably better than nothing

    NSLog(“failed to calculate height, no font?”)

    return textView.contentSize.height

  }

}

 
This cell is designed in IB as the prototype cell. It needs to get constraints as follows:
 
XcodeScreenSnapz001
The height constraint on the text view needs to get a priority less than 1000, e.g. 999, else it will give a conflict with a constraint that iOS will add on the fly when instantiating the cell. Interestingly, even though your own height constraint gets a lower priority than the system created constraint, still it will work fine.
XcodeScreenSnapz002
You then need to bind the height constraint to the outlet in the ItemCell, and the text view to its outlet in the ItemCell, as well. The result looks like in this very brief movie I posted on Vimeo.
Interestingly, the size change happens with a smooth animation even though I didn’t animate the change in the size of the text view as I had to do in the previous project. The animation originates in the table view itself, so it comes for free.
Because there are a number of things you need to set correctly in Interface Builder, and some details I may have glossed over here, I’ve posted the entire project for your downloading pleasure. Again, as in the previous post, this is all Swift 2.x and XCode 7.3.
 
 

Leave a Reply

Your email address will not be published. Required fields are marked *