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”.

Demo Example of Basic Chat Feature

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:

inputAccessoryView Custom View

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.

Leave a Reply

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

*

*