Building an iOS Chat Feature Without Hacks
The Problem
Trying to build a chat feature in iOS is often overwhelming and requires solving several difficult problems. Under a time crunch, we often find ourselves going with the first solution we find that seems to work. However, once the feature nears completion, you realize there are a lot of difficult bugs to solve without adding on more and more “hacks”. In this post, I’ll try and break down the different problems you might encounter trying to build a chat feature and how you can go about solving them without “hacks”.
Typing a Message: The inputAccessoryView
The first thing you might do when trying to replicate an iMessage style app is to build the text view for typing your message. My initial instinct when I had to do this the first time was to build a custom view that I’d manage manually to keep it above the keyboard. Fortunately, there’s an easier way to attach a custom view to the keyboard and get a lot of the keyboard appearance logic for free – the inputAccessoryView. The inputAccessoryView is available as part of UIResponder (of canBecomeFirstResponder fame) which UIViewController implements. To use the inputAccessoryView, you simply override the inputAccessoryView getter in your UIViewController and return your custom view (see below):
override var inputAccessoryView: UIView? { get { if composeBar == nil { composeBar = Bundle.main.loadNibNamed("ComposeBarView", owner: self, options: nil)?.first as? ComposeBarView } return composeBar } }
as well as overriding canBecome and canResign first responder functions by returning true:
override var canBecomeFirstResponder: Bool { return true } override var canResignFirstResponder: Bool { return true }
If you’d like your view to appear when your UIViewController loads, you’ll want to becomeFirstResponder() in one of the lifecycle appearance methods, i.e. viewDidLoad() or viewWillAppear().
override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) becomeFirstResponder() }
Now your custom messaging input view should appear attached to the keyboard and float at the bottom of your UIViewController when the keyboard is closed. Depending on how you construct your view, you might notice your view failing to avoid the safe area on phones like the iPhone XS, i.e. you might see it partially covered by the home indicator line. To resolve this and avoid constraint warnings, you’ll want to make sure your view is constrained to the safe area. Here’s how we solved this with a xib:
Make sure your View has the Safe Area Layout Guide enabled. Then put the contents of your view in a wrapper view constrained on all sides to the Safe Area especially the bottom. This will allow your view to constrain to the bottom above the home indicator when contained within your inputAccessoryView.
You might have noticed by now that Auto Layout doesn’t work as you’d normally expect inside the inputAccessoryView. To work around this, your custom view for the inputAccessoryView should override intrinsicContentSize and return the size of your content. I used a UITextView for inputting text, so I calculated the content size like this:
// inputAccessoryView adds a height constraint based on the original // intrinsicContentSize of it's self when it is first assigned, so we have to // override this to prevent unsatisfiable constraints. override var intrinsicContentSize: CGSize { return textViewContentSize() }
func textViewContentSize() -> CGSize { let size = CGSize(width: textView.bounds.width, height: CGFloat.greatestFiniteMagnitude) let textSize = textView.sizeThatFits(size) return CGSize(width: bounds.width, height: textSize.height) }
UITextView within inputAccessoryView
It can get tricky to make UITextView work like you’d expect in an app like iMessage. Here are some helpful tips that might save you some time:
- Add a height constraint to your UITextView and make an @IBOutlet to it so you can dynamically change the height in code as the text changes using UITextViewDelegate’s textViewDidChange(_ textView: UITextView)
extension ComposeBarView: UITextViewDelegate { func textViewDidChange(_ textView: UITextView) { placeholderText.isHidden = !textView.text.isEmpty let contentHeight = textViewContentSize().height if textViewHeight.constant != contentHeight { textViewHeight.constant = textViewContentSize().height layoutIfNeeded() } } }
- Turn of scrolling on your UITextView
- Enable or disable the Quick Type keyboard feature for your UITextView using the autoCorrectionType property.
Avoiding a Flipped UITableView/UICollectionView
When trying to figure out the best way to show your messages starting at the bottom of a UITableView (a UICollectionView applies here in the same ways), the first solution a lot of people find on Stack Overflow is to flip the UITableView and reverse the logic for your data source so that you don’t have to fight to keep your message content in view since table views like to start by displaying the content at the top. This can be an okay solution but it’s easy to forget your logic is reversed and your UI is “upside down” which easily leads to new bugs especially for developers joining later in the development cycle.
Fortunately, this can be avoided by calculating your content’s offset and setting the offset of your UITableView or UICollectionView so that it loads at the bottom. If done correctly, you won’t see the content scrolling to the bottom as it comes into view and it should avoid the keyboard just like iMessage. Let’s look at some examples:
override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() if shouldScrollToBottom { shouldScrollToBottom = false scrollToBottom(animated: false) } } func scrollToBottom(animated: Bool) { view.layoutIfNeeded() tableView.setContentOffset(bottomOffset(), animated: animated) } func bottomOffset() -> CGPoint { return CGPoint(x: 0, y: max(-tableView.contentInset.top, tableView.contentSize.height - (tableView.bounds.size.height - tableView.contentInset.bottom))) }
In the examples above we’ll see that once we know the size of our content, either after a server response has updated our data source or our view’s content has loaded (viewDidLayoutSubviews), we should make sure our UITableView’s contentOffset is set such that the content loads at the bottom of the scrollable area.
Finally, we should subscribe to keyboard notifications and adjust our contentInset in a similar way so that the keyboard appearing and disappearing keeps our scroll position:
func registerKeyboardNotifications() { NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) } @objc func keyboardWillShow(_ notification: NSNotification) { adjustContentForKeyboard(shown: true, notification: notification) } @objc func keyboardWillHide(_ notification: NSNotification) { adjustContentForKeyboard(shown: false, notification: notification) } func adjustContentForKeyboard(shown: Bool, notification: NSNotification) { guard shouldAdjustForKeyboard, let payload = KeyboardInfo(notification) else { return } let keyboardHeight = shown ? payload.frameEnd.size.height : composeBar?.bounds.size.height ?? 0 if tableView.contentInset.bottom == keyboardHeight { return } let distanceFromBottom = bottomOffset().y - tableView.contentOffset.y var insets = tableView.contentInset insets.bottom = keyboardHeight UIView.animate(withDuration: payload.animationDuration, delay: 0, options: payload.animationCurveOptions, animations: { self.tableView.contentInset = insets self.tableView.scrollIndicatorInsets = insets if distanceFromBottom < 10 { self.tableView.contentOffset = self.bottomOffset() } }, completion: nil) }
That was a lot, but after all of that, you should have something that resembles the baseline functionality of a chat app. We skipped quite a few details, but hopefully this helps get you started to building a stable and bug free native chat solution in Swift on iOS.
What does the shouldAdjustForKeyboard do?
Hi John,
Thanks for reading. Great question. This is a boolean that tracks if the view controller is about to appear or disappear to prevent the UITableView’s contentInset from being adjusted when doing an interactive pop gesture on the navigation controller. When viewWillDisappear is called, this flag is set to false and when viewDidAppear is called, this is set back to true. We are also able to leverage this toggle to determine if the inputAccessoryView should becomeFirstResponder on viewWillAppear.
Event all your helpful advises, I did not succeed to make my textview resize properly when embedded as an accessory view.
After settings the configurable height constraint, Xcode detected some conflicting constraints. Indeed, iOS automatically adds a height constraint when loading your ComposeBarView !
To solve the problem, you have to set `translatesAutoresizingMaskIntoConstraints = false` to your ComposeView right after instanciating it.