April 10, 2020

How to Add an Animated Typing Indicator to Your iOS Chat App

Marin Benčević

Prerequisites

  • Familiarity with Swift, iOS development and UIKit
  • Xcode 11.3, Swift 5

Introduction

Adding a basic chat feature to your app is an amazing way to connect your users. It allows your users to negotiate, get information, make friends and even fall in love without ever leaving your app. However, modern chat apps don't just display messages. They also show images, have @-mentions, reaction gifs, stickers and all sorts of other features... One of which is the typing indicator.

This tutorial will teach you how to add an indicator while a user is typing, adding one more UX improvement to your iOS app and ensuring your users don't escape your app for something more feature complete.

Since CometChat already has support for typing notifications in the iOS SDK, all we have to do is build out the UI to our liking!

In the process of adding the typing indicator, you'll also learn how to make custom views in iOS, as well as how to make cool-looking animations using Core Animation.

By the end of this iOS typing indicator tutorial, this is what you'll end up with:

A fully animated typing indicator in your iOS chat app!

You can find the finished project on GitHub.

You'll have a typing indicator that:

  • Shows the username of the user that's typing.
  • Pops up and down with a neat little animation.
  • Has a fancy animation where the dots of the ellipsis scale up and down.

Pretty cool, right? Let's get started.

Hitting it off

This tutorial assumes you already have a chat app that you built. Thankfully, we have two tutorials to get you started:

Or, you can start by downloading the starting project for this tutorial. Head to the [start-here](https://www.cometchat.com/tutorials/swift-chat-tutorial-one-on-one/) branch of the GitHub repo [TODO: LINK] and clone or download the repo. Open CometChat.xcwrorkspace (not the project file) in Xcode to get started.

Setting your app ID

Before you can run the app, you'll have to set your CometChat app ID. If you already have a CometChat app, navigate to the CometChat dashboard and click Explore for your app, then click on API Keys in the sidebar. Copy the API Key and the App ID to the appropriate constants in ChatService.swift in Xcode.

{% c-block %}
private enum Constants {
#warning("Don't forget to set your API key and app ID here!")
static let cometChatAPIKey = "API_KEY"
static let cometChatAppID = "APP_ID"
}
{% c-block-end %}

If you don't already have a CometChat account, don't fret. It's free and only takes a couple of minutes to get started.

Head over to CometChat's registration page and sign up. Once you sign up, you'll get taken to a dashboard. Create a new app under Add New App. Select USA as the region and enter CometChat as the app's name. Click the + button to create the app.

After 15 or so seconds, you'll have an up-and-running chat service online! All you need to do is connect to it.

Open your new app's dashboard by clicking Explore. Head over to API Keys in the sidebar. Take a look at the keys with full access scope.

Copy the API Key and the App ID to the appropriate constants in ChatService.swift in Xcode.

{% c-block %}
private enum Constants {
#warning("Don't forget to set your API key and app ID here!")
static let cometChatAPIKey = "API_KEY"
static let cometChatAppID = "APP_ID"
}
{% c-block-end %}

Now that you're all set, let's get started working on the typing indicator.

Making a custom typing indicator view

You'll start working on the typing indicator by creating a new Swift file called, appropriately, TypingIndicatorView.swift. Add the following contents to the file:

{% c-block %}
import UIKit
class TypingIndicatorView: UIView {
private enum Constants {
static let width: CGFloat = 5
static let scaleDuration: Double = 0.6
static let scaleAmount: Double = 1.6
static let delayBetweenRepeats: Double = 0.9
}
}
{% c-block-end %}

You create a new custom view that will hold your typing indicator. You won't use Interface Builder for this. Instead, you'll do everything in code to make it as flexible as possible.

This means you'll have to store a couple of constants, so you create an enum that holds these constants. You'll use them throughout the tutorial.

Next, add the following properties and inits to the class:

{% c-block %}
private let receiverName: String
private var stack: UIStackView!
init(receiverName: String) {
self.receiverName = receiverName
super.init(frame: .zero)
createView()
}
required init?(coder: NSCoder) {
fatalError()
}
{% c-block-end %}

The receiverName is the name of the person that's typing. The stack is your main stack view where you'll add all of your subviews to build out the typing indicator. To set the receiver name, you'll add a new init that receives the name as a parameter. This init will also call createView – a function you'll create shortly. Since this is a subclass of UIView, there's a required init that has to exist, but it doesn't necessarily need to do anything. So, you'll just fatalError out of it ever gets called. (Don't worry, it won't!)

Next, implement the method you called in the initializer:

{% c-block %}
private func createView() {
translatesAutoresizingMaskIntoConstraints = false
stack = UIStackView()
stack.translatesAutoresizingMaskIntoConstraints = false
stack.axis = .horizontal
stack.alignment = .center
stack.spacing = 5
}
{% c-block-end %}


This function will build out your whole typing indicator, so this is just its start! First, you set translatesAutoresizingMaskIntoConstraints to false. This mouthful of a property tells UIKit that it shouldn't make any constraints for this view – you'll add those yourself.

Next, you create the main stack view. This stack will eventually hold the dots of the ellipsis as well as the text saying "Jane Doe is typing". You make sure it's a horizontal stack and that everything is vertically centered with a bit of spacing between each item.

Creating the dot

The first view you'll add to the stack will be an unassuming, lonely dot. You'll start by making one, and then copy it two more times to create the ellipsis.

You’ll start assembling your typing indicator by creating a single dot of the ellipsis..

Add the following method to the class:

{% c-block %}
func makeDot(animationDelay: Double) -> UIView {
let view = UIView(frame: CGRect(
origin: .zero,
size: CGSize(width: Constants.width, height: Constants.width)))
view.translatesAutoresizingMaskIntoConstraints = false
view.widthAnchor.constraint(equalToConstant: Constants.width).isActive = true
}
{% c-block-end %}

First, you create a new view to hold the dot and set its width and height to the one defined in Constants. You also add an Auto Layout constraint to make sure it has a fixed width of the same value.

Next, you'll add a circle to the view using Core Animation:

{% c-block %}
let circle = CAShapeLayer()
let path = UIBezierPath(
arcCenter: .zero,
radius: Constants.width / 2,
startAngle: 0,
endAngle: 2 * .pi,
clockwise: true)
circle.path = path.cgPath
{% c-block-end %}


CAShapeLayers let you draw a custom shape in the screen defined as a Bézier path – a common way to describe paths and shapes as a set of numerical values. You don't have to construct these yourself. Instead, you can use UIBezierPath's initializers to construct different shapes like arcs, lines, ovals, rectangles and any other shape you can think of.

In this case, you create a circular path by creating an arc that starts at 0 degrees and rotates around a full circle, ending up at the same spot at 2π, or 360 degrees. You give it a radius equal to the half of the width so that it spans the whole view. You then set that path on the shape layer to draw the circle.

Then, add the following lines to the method:

{% c-block %}
circle.frame = view.bounds
circle.fillColor = UIColor.gray.cgColor
{% c-block-end %}

You set the layer's frame and give the circle a gray fill. Next, add the circle layer to the view and return the view:

{% c-line %}view.layer.addSublayer(circle){% c-line-end %}
return view

This concludes makeDot. At least, for now.

Back in createView, add the following code to add a dot to the stack:

{% c-block %}
let dot = makeDot(animationDelay: 0)
stack.addArrangedSubview(dot)
addSubview(stack)
}
{% c-block-end %}

Next, you'll add a few constraints to make sure the stack spans the width and height of TypingIndicatorView:

{% c-block %}
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: leadingAnchor),
stack.trailingAnchor.constraint(equalTo: trailingAnchor),
stack.topAnchor.constraint(equalTo: topAnchor),
stack.bottomAnchor.constraint(equalTo: bottomAnchor)
])
{% c-block-end %}

You set the leading, trailing, top and bottom anchor of the stack to all equal the same respective anchors of the view.Speaking of sizing, there's one final thing you need to do to make sure your layout doesn't break. When Auto Layout is laying out your view, it doesn't currently know how wide it should be. Views like UILabel or UIStackView calculate their own size – this size is called the intrinsic content size.


You can replicate the same behavior in your views. Add the following override to the class:

{% c-block %}
override var intrinsicContentSize: CGSize {
stack.intrinsicContentSize
}
{% c-block-end %}


This tells Auto Layout how to size your view. In your case, you can return the stack's size since it will always match the while typing indicator view.

Now that you built your dot, it's time to show it from the chat screen.

Showing the view from the chat screen

Head to ChatViewController.swift and add the following property to the class:

{% c-line %}private var typingIndicatorBottomConstraint: NSLayoutConstraint!{% c-line-end %}

Later in this tutorial, you'll animate this constraint to show and hide the typing indicator.
Next, add this method to the class:

{% c-block %}
private func createTypingIndicator() {
let typingIndicator = TypingIndicatorView(receiverName: receiver.name)
view.insertSubview(typingIndicator, belowSubview: textAreaBackground)
}
{% c-block-end %}


This method will set up the typing indicator and add it as a subview. So, in the implementation, you first call TypingIndicatorView's initializer with the receiver's name and add it as a subview below the text area. This is important because the indicator needs to pop up from behind the text field.

Next, add the following code to the method:

{% c-block %}
typingIndicatorBottomConstraint = typingIndicator.bottomAnchor.constraint(
equalTo: textAreaBackground.topAnchor,
constant: -16)
typingIndicatorBottomConstraint.isActive = true
{% c-block-end %}


Here, you create a constraint to make sure the typing indicator is 16 points from the top of the text field. You'll set the property you created earlier so that you can animate the spacing and hide the typing indicator.

Next, add this code to the method to add a couple of additional constraints:

{% c-block %}
NSLayoutConstraint.activate([
typingIndicator.heightAnchor.constraint(equalToConstant: 20),
typingIndicator.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 26)
])
{% c-block-end %}


These constraints make sure that the typing indicator's height is 20 and that it has a margin of 26 from the leading edge of the view.

Finally, call the method from the bottom of viewDidLoad:

{% c-line %}createTypingIndicator(){% c-line-end %}


Run your project and enter superhero1 as your email. This is a pre-made test user that CometChat creates for you. Select a contact to chat with and take a look at your dot in all of its glory:

Your first dot!

Well, it is just a dot, so it might not look that impressive. But, the potential is there! Let's breathe some life into it by making it animate.

Animating the dot

To make the ellipsis animate you'll animate each dot to scale up and down in the same way. You'll then add a small delay to the second and third dot so that they don't all scale at the same time. That's a pretty neat trick to add the feeling of movement without adding complex animations.

[TODO: Screenshot finished animation]

Head back to TypingIndicatorView.swift and add the following code to the bottom of makeDot, right before return:

{% c-block %}
let animation = CABasicAnimation(keyPath: "transform.scale")
animation.duration = Constants.scaleDuration / 2
animation.toValue = Constants.scaleAmount
animation.isRemovedOnCompletion = false
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
animation.autoreverses = true
{% c-block-end %}

You might be used to the convenient API of UIView animation, but here's you're working at a lower level: Core Animation layers. To animate these, you'll also have to use a lower-level animation API: CAAnimation.

CAAnimations are objects that animate almost anything between a toValue and a fromValue. In your case, you'll animate the scale of the dot, so you supply transform.scale as the key path for the property you want to animate. You then set the toValue to the scale amount from the constants and make sure there's an easing on the animation. By setting autoreverses to true, the layer will automatically reverse the animation so that it scales up and down in the same way.

Next, you'll add this animation to an animation group. Add this code to the method:

{% c-block %}
let animationGroup = CAAnimationGroup()
animationGroup.animations = [animation]
animationGroup.duration = Constants.scaleDuration + Constants.delayBetweenRepeats
animationGroup.repeatCount = .infinity
animationGroup.beginTime = CACurrentMediaTime() + animationDelay
{% c-block-end %}

Usually, animation groups are useful for starting and stopping multiple animations at once. However, in this case, you'll use the group to delay the start of the scaling animation. By giving the group a duration that's longer than the scaling, you ensure a delay between each repeat of the animation. You set the group to repeat infinitely and add another delay to its start time.


Finally, add the animation group to the circle layer by adding this line to the method:

{% c-line %}circle.add(animationGroup, forKey: "pulse"){% c-line-end %}

Run the app and take a look at your circle.

The dot now scales up and down repeatedly.

Now it looks a bit more impressive: It scales up and down infinitely! Let's turn this dot into an ellipsis by copying it a couple of times.

Adding more dots

It's time to add some more dots! Still in TypingIndicatorView.swift, add a new method to the class:

{% c-block %}
private func makeDots() -> UIView {
let stack = UIStackView()
stack.translatesAutoresizingMaskIntoConstraints = false
stack.axis = .horizontal
stack.alignment = .bottom
stack.spacing = 5
stack.heightAnchor.constraint(equalToConstant: Constants.width).isActive = true
}
{% c-block-end %}

This method creates another stack view which you'll place inside the main stack. This inner stack view will hold all of your dots, so you make it horizontal and align everything to the bottom. You also give it a height equal to the width of one dot.


Next, add the following code to the method to add the dots:

{% c-block %}
let dots = (0..<3).map { i in
makeDot(animationDelay: Double(i) * 0.3)
}
dots.forEach(stack.addArrangedSubview)
return stack
{% c-block-end %}

First, you use map to convert a range of integers (0, 1, 2) to dot views by calling makeDot. Each dot will have a larger delay, starting from zero and increasing by 0.3 seconds with each index. Then, you add the views to the stack by calling addArrangedSubview and, finally, return the stack.

Next, inside createView, replace the lines where you create and add dot...

{% c-block %}
let dot = makeDot(animationDelay: 0)
stack.addArrangedSubview(dot)
{% c-block-end %}

...with the following two lines:

{% c-block %}
let dots = makeDots()
stack.addArrangedSubview(dots)
{% c-block-end %}


Build and run the project and take a look.


The ellipsis of your typing indicator, assembled and animated.

You now have an ellipsis with a neat animation that signals to the user that something is going on. We're still missing the text, so let's get on that!

Adding text

Don't worry, adding the dots was the hard part. Adding the text bit should be smooth sailing.
Still in TypingIndicatorView.swift, add a label in createView right under the lines to create and add dots:

{% c-block %}
let typingIndicatorLabel = UILabel()
typingIndicatorLabel.translatesAutoresizingMaskIntoConstraints = false
stack.addArrangedSubview(typingIndicatorLabel)
{% c-block-end %}

You create a label, remove default constraints and add it to the main stack view.

Next, add some text to the label:

{% c-block %}
let attributedString = NSMutableAttributedString(
 string: receiverName,
 attributes: [.font: UIFont.boldSystemFont(ofSize: UIFont.systemFontSize)])
{% c-block-end %}


{% c-block %}
let isTypingString = NSAttributedString(
 string: " is typing",
 attributes: [.font: UIFont.systemFont(ofSize: UIFont.systemFontSize)])
{% c-block-end %}

{% c-block %}
attributedString.append(isTypingString)
typingIndicatorLabel.attributedText = attributedString
{% c-block-end %}

Instead of using plain strings, you use an attributed string to bold part of the text. Specifically, the username will be bold, so you create the username part of the text by adding an attribute to make the font bold. This bit of text is a mutable string.

Next, you create an immutable attributed string with the rest of the text and a regular, non-bold font. You then append the non-bold part to the mutable string, like you were concatenating two regular strings. The result is that attributedString now holds a bold username and regular text saying "is typing".

Run the project to take a look.

Now your typing indicator shows the name of the user that’s typing.

Now the user can see who's typing. The typing indicator looks nice, but there's a little problem. It's always there!

In the rest of this iOS typing indicator tutorial, you'll show and hide the indicator with an animation based on whether the user is typing or not.

Animating the indicator

First, let's add an animation for when the indicator pops up and down.

Open ChatViewController.swift and add a new method to the class:

{% c-block language="swift" %}
private func setTypingIndicatorVisible(_ isVisible: Bool) {
 let constant: CGFloat = isVisible ? -16 : 16
 UIView.animate(withDuration: 0.4, delay: 0, options: .curveEaseInOut, animations: {
  self.typingIndicatorBottomConstraint.constant = constant
  self.view.layoutIfNeeded()
 })
}
{% c-block-end %}

Remember the constraint you declared earlier that determines the spacing between the text field and the typing indicator? Here, you'll toggle its constant between -16, when it's raised, and 16, when it should be hidden. Because the text field is on top of the typing indicator (in the z-axis), you won't see it if the constraint is set to 16.

Next, in createTypingIndicator, locate the line where you set typingIndicatorBottomConstraint and change its constant to 16 instead of -16:

{% c-block %}
typingIndicatorBottomConstraint = typingIndicator.bottomAnchor.constraint(
 equalTo: textAreaBackground.topAnchor,
 // Change this line:
 constant: 16)
{% c-block-end %}

This ensures the typing indicator is hidden when the view loads.

Then, test out the hiding and showing animation by temporarily adding the following two lines at the top of sendMessage:

{% c-line %} setTypingIndicatorVisible(typingIndicatorBottomConstraint.constant == 16) {% c-line-end %}
return

This will toggle the typing indicator whenever you press the send button. Run the project to try it.

You can now show and hide the typing indicator by animating a constraint.

Press the send button a couple of times and you should see the typing indicator pop up and down. Looking good!

Remove the two lines you just added. You won't be needing them anymore, because you'll track the actual typing state.

Getting typing notifications from CometChat

Thankfully, CometChat makes it easy to track when someone is typing. If you followed our previous iOS chat app tutorials, this approach will be familiar to you.

Open ChatService.swift and add the following two closures to the top of the class:

var onTypingStarted: ((User)-> Void)?
var onTypingEnded: ((User)-> Void)?

ChatService will call these functions whenever a user starts or stops typing.

Next, add the following method to the class:

{% c-block language="swift" %}
func startTyping(to receiver: User) {
 let typingIndicator = TypingIndicator(receiverID: receiver.id, receiverType: .user)
 CometChat.startTyping(indicator: typingIndicator)
}
{% c-block-end %}


You'll call this method when a user starts typing. It creates a new TypingIndicator object that holds information about the typing that's going on. It then uses that object to tell CometChat that typing has begun.

Add another, very similar method below the one you just added:

{% c-block language="swift" %}
func stopTyping(to receiver: User) {
 let typingIndicator = TypingIndicator(receiverID: receiver.id, receiverType: .user)
 CometChat.endTyping(indicator: typingIndicator)
}
{% c-block-end %}

This method is analogous to the previous one, only this one stops the typing.

Then, you'll implement delegate methods to track when another user is typing. Add a new method inside the CometChatUserDelegate extension at the bottom of the file:

{% c-block language="swift" %}
func onTypingStarted(_ typingDetails: TypingIndicator) {
 guard let cometChatUser = typingDetails.sender else {
  return
 }
 DispatchQueue.main.async {
  self.onTypingStarted?(User(cometChatUser))
 }
}
{% c-block-end %}

CometChat calls this method when typing begins for a user. First, you'll grab the user and then switch to the main queue. From there, you'll call the closure you declared earlier, but not before you convert the user to your own User struct.

Finally, add another delegate method below the previous one:

{% c-block language="swift" %}
func onTypingEnded(_ typingDetails: TypingIndicator) {
 guard let cometChatUser = typingDetails.sender else {
  return
 }
 DispatchQueue.main.async {
  self.onTypingEnded?(User(cometChatUser))
 }
}
{% c-block-end %}

This method is almost the same, except it gets called when a user stops typing, and calls the appropriate closure for when typing stops.

That's all we need to do in ChatService. Let's move to the view controller to hook everything up.
Open ChatViewController.swift and start by adding the following code to the bottom of viewDidLoad:

{% c-block language="swift" %}
ChatService.shared.onTypingStarted = { [weak self] user in
 if user.id == self?.receiver.id {
  self?.setTypingIndicatorVisible(true)
 }
}
ChatService.shared.onTypingEnded = { [weak self] user in
 if user.id == self?.receiver.id {
  self?.setTypingIndicatorVisible(false)
 }
}
{% c-block-end %}

Here, you assign the two closures you created earlier. When a user starts typing, you'll check that the user is your receiver. If they are, you'll show the typing indicator. This is necessary because the typing closure could be called for a different contact that's not open in this view controller.  Similarly, if you get notified that the receiver stopped typing, you'll hide the typing indicator.

Next, you'll need to check if the current user is typing and send that information to CometChat. Scroll down to the UITextViewDelegate extension and add a new method to the extension:

{% c-block %}
// 1
func textView(
 _ textView: UITextView,
 shouldChangeTextIn range: NSRange,
 replacementText text: String) -> Bool {
 // 2
 let currentText: String = textView.text
 let range = Range(range, in: currentText)!
 let newText = currentText.replacingCharacters(in: range, with: text)
 // 3
 switch (currentText.isEmpty, newText.isEmpty) {
 // 4
 case (true, false):
  ChatService.shared.startTyping(to: receiver)
 // 5
 case (false, true):
  ChatService.shared.stopTyping(to: receiver)
 default:
  break
 }
 // 6
 return true
}
{% c-block-end %}


This method looks jam-packed with information, so let's take it step-by-step:

  1. This delegate method will get called every time the user makes a change to the text view's text. It gives you a chance to validate the text and, potentially, stop the user from making changes.
  2. First, you grab the current value of the text. Then, you'll grab the future text value by replacing the provided range of text with the new text you get as the method's parameter.
  3. You switch on a tuple that checks if the current and new texts are empty.
  4. If the current text is empty and the future text isn't, that means the user has begun to type something.
  5. Otherwise, if some text is currently entered, but the future text is empty, the user has stopped typing.
  6. You return true to update the text field with the new text.

Now you're sending the typing state to CometChat!

There's one final line of code you need to add. Scroll up to sendMessage, and add this line right before the call to ChatService.shared.send:

ChatService.shared.stopTyping(to: receiver)

This makes sure to reset the typing state when your user sends a message.

Run the project on two devices (or two simulator instances). On one device, log in as superhero1 and start chatting with Captain America. On the other device, log in as superhero2 and start chatting with Iron Man.

A fully animated typing indicator in your iOS chat app!

As you start typing, you should see a typing indicator pop up on the other device!

Conclusion

You can find the finished project on GitHub.

Using a combination of CometChat, UIView animation, Core Animation and your chops, you made a nice looking animated typing indicator! It animates into view, has a pulsing ellipsis animation and shows the username of the person that's typing.

Armed with this knowledge, you can go even further and explore other ways to improve your UX:

  • Change the design of the typing indicator to make it your own.
  • If you're making a group chat app, consider tracking a list of users that are currently typing and show all of their names.
  • Go even further by adding read receipts to your chat app to make your user experience even better.

If you want a more detailed look at how to build a chat app with CometChat, take a look at the iOS one-on-one chat app tutorial or the iOS group chat app tutorial.

If you're ahead of the curve and want to build a SwiftUI chat app, you can go through our SwiftUI course on building a chat app in SwiftUI.

I hope this iOS typing indicator tutorial helped give your users a better chat experience!