Jul 9, 2019

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.

About the Author

Brian Bethke profile.

Brian Bethke

Sr. Consultant

Brian has experience in Swift, Objective-C, Java, Kotlin, PHP, integration with hardware peripherals over BLE, and restful API’s. He has spent over 8 years developing native iOS applications in a variety of fields. When he’s not working, Brian enjoys scuba diving, playing piano, playing video games, and reading.

One thought on “Building an iOS Chat Feature Without Hacks

  1. John says:

    What does the shouldAdjustForKeyboard do?

    1. Brian Bethke says:

      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.

  2. Martin says:

    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.

  3. Dinesh says:

    How will I get KeyboardInfo in KeyboardInfo(notification)?

    1. Brian Bethke says:

      Thanks for reading Dinesh. This is a custom struct I made to pull out the details about the keyboard size from the keyboard appearance notification. You can find a great example of how to do this here: https://www.objc.io/blog/2018/07/03/notifications/

Leave a Reply

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

Related Blog Posts
Android Development for iOS Developers
Android development has greatly improved since the early days. Maybe you tried it out when Android development was done in Eclipse, emulators were slow and buggy, and Java was the required language. Things have changed […]
Add a custom object to your Liquibase diff
Adding a custom object to your liquibase diff is a pretty simple two step process. Create an implementation of DatabaseObject Create an implementation of SnapshotGenerator In my case I wanted to add tracking of Stored […]
Keeping Secrets Out of Terraform State
There are many instances where you will want to create resources via Terraform with secrets that you just don’t want anyone to see. These could be IAM credentials, certificates, RDS DB credentials, etc. One problem […]
Validating Terraform Plans using Open Policy Agent
When developing infrastructure as code using terraform, it can be difficult to test and validate changes without executing the code against a real environment. The feedback loop between writing a line of code and understanding […]