Embedding UITextView inside UITableViewCell

George Tsifrikas
6 min readOct 28, 2018

--

The backstory

At Workable we have a feature which is called Evaluations. Part of it, is the ability to fill in interview kits as it is shown below.

Interview kits on Workable iOS app

When we started to research how to implement this functionality, one idea that came up was to use stack views but interview kits are created by the users so their size may vary. So, we chose to use UITableView as the backbone of this screen. After all it just a UITextView in a UITableViewCell, right?

Well.. 🙈, it was far from simple

In this article I’ll try to list all the problems we faced and how we tackled them. At the end of the walkthrough, a full solution, is attached.

Adding the UITextView inside the cell

We created a prototype cell and added the UITextView as shown below.

Adding the UITextView (❤️ Dark Mode)

Also, don’t forget to turn off the Scrolling Enabled property in the property inspector of UITextView. This allows UITextView to have an intrinsic content size depending on the text, or close to the text size as we later found out.

Editable text

Now, we want to be able to edit the text. Easy, just enable `Editable` property of UITextView.

But we have a slight problem, we want our UITableViewCell to follow the height of the UITextView.

We can create a callback from the cell to inform us on any text changes like so:

In the TextTableViewCell.swift

    @IBOutlet weak var textView: UITextView!
var textChanged: ((String) -> Void)?

override func awakeFromNib() {
super.awakeFromNib()
textView.delegate = self
}

func textChanged(action: @escaping (String) -> Void) {
self.textChanged = action
}

func textViewDidChange(_ textView: UITextView) {
textChanged?(textView.text)
}

In the TableTableViewController.swift -> cellForRow

cell.textChanged {[weak tableView] (newText: String) in

}

Note: Don’t forget to make tableView weak in this closure’s capture list because you will end up with a retain cycle. tableView -> cell (UI), cell -> tableView (closure)

The only thing left for us to do is just to reload the cell.

We have multiple ways to do that, so we’ll try the most common one, tableView.reloadData().

cell.textChanged {[weak tableView] (_) in
// Possible solution: 1
// Nope, cell changes once and then loses focus
tableView?.reloadData()
}

With reload data you get to make only one change to the text view because reloadData() works asynchronously. So we can’t use it.

Ok, then we’ll try to reload the cell.

cell.textChanged {[weak tableView] (_) in
// Possible solution: 2
// Nope, cell is reloaded, text doesn't even change
tableView?.reloadRows(at: [indexPath], with: .none)
}

With reloadRows, because of its synchronous nature, we don’t even get to make one change we just lose focus from the UITextView. So we can’t use it either.

One more thing, we can try is to just tell tableView just to re-layout itself.

We can accomplish that by calling beginUpdates() & endUpdates().

cell.textChanged {[weak tableView] (_) in
// Possible solution: 3
// Seems to work
tableView?.beginUpdates()
tableView?.endUpdates()
}

And that solution indeed seems to work! With the added benefit of making the layout changes animated!

Note: If you don’t want the layout change animated you can invoke beginUpdates() endUpdates() inside UIView.performWithoutAnimation()

Result:

But this solution is not over yet.

UIScrollView and UITextView/UITextField

When there is a UITextView or a UITextField inside a UIScrollView, the scrollview adjust its contentOffset to make the cursor of the UITextView visible. In our case, when a user starts typing and the cursor goes under the keyboard, you’ll notice a weird scrolling behavior.

Example:

Solution

While a user is typing a letter, the focusing of the cursor and the layout of the tableView are conflicting. The only thing we need to do is to defer the layout for the next run loop by dispatching it to the main queue like so:

cell.textChanged {[weak tableView] (_) in
DispatchQueue.main.async {
tableView?.beginUpdates()
tableView?.endUpdates()
}
}

And now it works!

That was it? Depends from your app’s Deployment target. If you targeting from iOS 11 or later you’re OK! If not, continue reading.

iOS 10 and earlier

Weird scrolling and layout issues

When you scroll down on the UITableView and try to edit a text you’ll notice weird jumping behaviors and cells with wrong layout.

Editing UITextViews in iOS 10 or earlier.

Solution

After some experimentation we found out, that the weird scrolling behavior was “weirder” with greater estimatedRowHeight values. So, we suspected that had something to do with how the UITableView calculated the height of the row.

So we decided to go old-school.

Manual cell height calculation

First turn off automatic height calculation and set estimatedRowHeight to 0.

Then, we’ll manually calculate the height of each cell so the UITableView can correctly layout the cells and the height changes as the user types.

We’ll need this extension which gives you the height of a String, given the available width and font which it’ll be rendered.

extension String {
func heightWithConstrainedWidth(width: CGFloat, font: UIFont) -> CGFloat {
let constraintRect = CGSize(width: width, height: .greatestFiniteMagnitude)
let boundingBox = self.boundingRect(with: constraintRect, options: [.usesFontLeading, .usesLineFragmentOrigin], attributes: [NSAttributedStringKey.font: font], context: nil)

return boundingBox.height
}
}

Now we can use this to calculate the height of each cell.

public override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
return models[indexPath.row]
.heightWithConstrainedWidth(width: tableView.frame.width, font: UIFont.systemFont(ofSize: 14))
}

Result:

Not the result we were looking for

As you can see, the row indeed changes height but UITextView is clipped, also we don’t have the weird scrolling behavior! We’re getting close!

UITextView size

If you look closely to the UITextViews you’ll notice some kind of padding around despite having constraints with 0 distance from the superview.

To make this clearer we used a semitransparent label with red background, we’ll make the background of UITextView grey and we’ll make the cell height 100 points larger for clarity. The UILabel and UITextView have the same text with same font and font-size.

Result:

Difference in size between UITextView and UILabel

So our next move was to remove that padding from the UITextView. For that, we subclassed UITextView, removed the paddings and used it as a drop in replacement.

@IBDesignable class ZeroPaddingTextView: UITextView {

override func layoutSubviews() {
super.layoutSubviews()
textContainerInset = UIEdgeInsets.zero
textContainer.lineFragmentPadding = 0
}

}
UITextView & UILabel

The texts inside UILabel and UITextView now have exactly the same position!

Important!

Now, that we calculated manually the height of the row, we do not need the bottom constraint between the UITableViewCell content view and UITextView because we know that these two have the same height by design.

UITextView’s constraints

Final result:

Great, now it works perfectly! 🎉

Example project here.

I hope it helps! Also, feedback is welcome!

--

--