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

Object Partners profile.

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/

  4. Zulwiyoza Putra says:

    Great article. I find it super helpful.
    One thing I don’t understand, What does
    “`
    if distanceFromBottom < 10 {
    self.tableView.contentOffset = self.bottomOffset()
    }
    “`
    do?

    1. Brian Bethke says:

      Thanks for reading! This code keeps the most recent message in view as the keyboard appearance changes and the tableView’s contentInset is set to the keyboard height. In this implementation, it does this only within a certain threshold where `distanceFromBottom < 10`. You may find you want a different number here or none at all depending on your needs.

  5. blkmn says:

    Great article!

    Can you show us ComposeBarView implementation?

  6. H says:

    When/where is shouldScrollToBottom set?

Leave a Reply to Zulwiyoza Putra Cancel reply

Your email address will not be published.

Related Blog Posts
Natively Compiled Java on Google App Engine
Google App Engine is a platform-as-a-service product that is marketed as a way to get your applications into the cloud without necessarily knowing all of the infrastructure bits and pieces to do so. Google App […]
Building Better Data Visualization Experiences: Part 2 of 2
If you don't have a Ph.D. in data science, the raw data might be difficult to comprehend. This is where data visualization comes in.
Unleashing Feature Flags onto Kafka Consumers
Feature flags are a tool to strategically enable or disable functionality at runtime. They are often used to drive different user experiences but can also be useful in real-time data systems. In this post, we’ll […]
A security model for developers
Software security is more important than ever, but developing secure applications is more confusing than ever. TLS, mTLS, RBAC, SAML, OAUTH, OWASP, GDPR, SASL, RSA, JWT, cookie, attack vector, DDoS, firewall, VPN, security groups, exploit, […]