What do Instagram, Tinder, Uber, and eBay all have in common? While they all serve completely different purposes, they all include one-on-one chat. That's because one-on-one chat is the best way to connect two app users, be it a buyer to a seller, a user to their driver, two romantic partners, or even two friends. Adding chat to your app is a surefire way to increase engagement for your users.

The iOS one-on-one chat app you’ll build in this tutorial.

In this tutorial, you'll learn how to implement a one-on-one chat app on iOS with Swift. You'll start by logging into your chat app and displaying a list of users. You'll then implement sending and receiving messages to and from specific users. You can see the full completed project on GitHub.

Let's get started!

Group chat?

In this tutorial we look at how to add one-on-one chat. Previously I wrote about coding a group chat app using Swift and CometChat.

Start by downloading or cloning the start-here branch of the accompanying repository. Open the .xcworkspace file in Xcode.

The project already includes some boilerplate and UI code for you, so you can focus on learning about chat and not fiddling with table views. If you open up Main.storyboard, you'll see a few screens already built. There's a simple welcome screen that leads you to a login form. Once logged in, you’re taken to the contacts screen. This is a table view with pre-made cells that you'll implement in a bit. If the user taps on a contact, they'll enter an empty chat screen, which you'll fill up with messages. The project also includes the CometChat Swift chat SDK pre-installed.

Adding chat to your app involves many moving parts. To help us with this, we'll use CometChat, a poweful SDK that takes care of sending and receiving messages for you. It also offers nifty features like fetching a list of users, seeing who's online, typing indicators, calling and more.

To start with CometChat, you'll first need to create a free account. Head to the registration page to create one. Once you're logged in, you'll be taken to your personal dashboard.

Create a new v1 app and name it whatever you like. Once created, click Explore and in the sidebar click API Keys. Note your App ID and full access API Key.

Hold your horses ⚠️🐴!
To follow this tutorial or run the example source code you'll need to create a V1 application.

v2 will be out of beta soon at which point we will update this tutorial.

Note down the API key and app ID under “fullAccess”.

Back in Xcode, create a new Swift file called ChatService.swift. This file will hold everything related to CometChat like initializing the SDK, logging in, fetching users and sending and receiving messages.

Replace the contents of the file with the following code:

import Foundation
import CometChatPro

extension String: Error {}

final class ChatService {

  private enum Constants {
    // Don't forget to set your API key and app ID here!
    static let cometChatAPIKey = "API_KEY"
    static let cometChatAppID = "APP_ID"
  }

  static let shared = ChatService()
  private init() {}

  static func initialize() {
    CometChat(
      appId: Constants.cometChatAppID,
      onSuccess: { isSuccess in
        print("CometChat connected successfully: \(isSuccess)")
      },
      onError: { error in
        print(error)
      })
  }
}

This code first sets up a few constants that you'll use later on. Make sure to replace the API key and App ID with the ones for your app! You’ll make the class a singleton by adding a static property and a private initializer.

Since we’re making a fairly simple app and multiple objects will be interacting with CometChat, a singleton will make our lives easier. You’ll also add an initialize function that passes the app ID to CometChat Pro.

Now you can call this function from AppDelegate.swift at the top of application(_:didFinishLaunchingWithOptions:):

ChatService.initialize()

This makes sure the SDK is initialized when the app launches. Next, you'll add a way for the user to log into your app.

Logging in

You'll use the user's email as a unique identifier to log the user in.

Back in ChatService.swift, add a new user property and a login method to the class:

private var user: User?

func login(
  email: String, 
  onComplete: @escaping (Result<User, Error>)-> Void) {

  CometChat.login(
    UID: email,
    apiKey: Constants.cometChatAPIKey,
    onSuccess: { [weak self] cometChatUser in
      guard let self = self else { return }
      self.user = User(cometChatUser)
      DispatchQueue.main.async {
        onComplete(.success(self.user!))
      }
    },
    onError: { error in
      print("Error logging in:")
      print(error.errorDescription)
      DispatchQueue.main.async {
        onComplete(.failure("Error logging in"))
      }
    })
}

The login method logs the user in and calls a completion handler with either a new User or an Error. The User struct is created for you in the Model folder of the starter project.

You call CometChat's login function and pass in your API key and the user's email. If the user logs in successfully, you'll store the user for later and call the completion handler. Because logging in works in the background, you need to dispatch to the main queue before you call the completion handler.

If something goes wrong, you'll print out an error and call the completion handler with a failed result. Earlier, you extended String to conform to Error. In a real production app, you'd probably want to use a more descriptive type than a plain String.

Now that you coded the login function, it's time to call it from your login screen. Open up LoginViewController.swift and change the contents of loginButtonTapped to the following:

@IBAction func loginButtonTapped(_ sender: Any) {
  let email = emailTextField.text!
  guard !email.isEmpty else {
    return
  }
  ChatService.shared.login(email: email) { [weak self] result in
    switch result {
    case .success:
      self?.performSegue(withIdentifier: Constants.showContacts, sender: nil)
    case .failure:
      self?.showError("An error occurred.")
    }
  }
}

First, check that the email isn't empty. Then, call the login function, and if the user is logged in, perform a segue that leads them to the contacts screen. Otherwise, if an error occurs, call a function that will display an alert with the error.

Run the project and navigate to the login screen. As the email, enter superhero1 and tap Login. If everything works correctly, you'll be taken to an empty contacts screen.

To log into your chat app, enter “superhero1” as the email.

You're probably wondering "who the heck is superhero1?" superhero1 is Iron Man — but that probably doesn’t answer your question.

superhero1 trough superhero5 are pre-made users that come with every CometChat app. You can see this if you go to back to your CometChat app's dashboard and click on Users. You'll see five different comic book heroes.

CometChat apps come with these test users.

Your next step is to display this same list of superheroes in the contacts screen. In a real app, you’d have a registration screen where you’d create new users programmatically using CometChat’s API. For now, though, we’ll work with plain old superheroes.

Populating Contacts

Before you start loading real superheroes, let's work on displaying fake contacts first. Open ContactsViewController.swift. This view controller already has an outlet to a table view that you'll add contacts to.

First, assign the object as the table view's data source at the bottom of viewDidLoad:

tableView.dataSource = self

Next, add a new property to the class to store some fake contacts:

private var contacts: [User] = [
  User(id: "1", name: "Iron Man", image: nil, isOnline: false),
  User(id: "1", name: "Captain America", image: nil, isOnline: true),
  User(id: "1", name: "Black Widow", image: nil, isOnline: false)
]

Now that we have the contacts, let's extend ContactsViewController to implement UITableViewDataSource. Add the following extension to the end of the file:

// MARK: - UITableViewDataSource
extension ContactsViewController: UITableViewDataSource {

  func tableView(
    _ tableView: UITableView,
    numberOfRowsInSection section: Int) -> Int {
    return contacts.count
  }

  func tableView(
    _ tableView: UITableView,
    cellForRowAt indexPath: IndexPath) -> UITableViewCell {

    guard
      let cell = tableView.dequeueReusableCell(
      withIdentifier: Constants.cellIdentifier,
      for: indexPath) as? ContactsTableViewCell
    else {
        return UITableViewCell()
    }

    let contact = contacts[indexPath.row]
    cell.contact = contact

    return cell
  }
}

This is the standard table view data source code. First, tell the table view how many contacts there are. Then, for each index path, you dequeue an instance of ContactsTableViewCell, a cell that's already been created for you in the project.

Run the project to see your three fake contacts on the screen.

Hey — imaginary or not, a friend is a friend!

Not bad for now, but let’s get rid of these fake contacts and fetch the real users.

Fetching users

You'll add a new method in ChatService to load all the users of an app. Open ChatService.swift and add this code to the class:

private var usersRequest: UsersRequest?
func getUsers(onComplete: @escaping ([User])-> Void) {
  usersRequest = UsersRequest.UsersRequestBuilder().build()
  usersRequest?.fetchNext(
    onSuccess: { cometChatUsers in
      let users = cometChatUsers.map(User.init)
      DispatchQueue.main.async {
        onComplete(users)
      }
    },
    onError: { error in
      DispatchQueue.main.async {
        onComplete([])
      }
      print("Fetching users failed with error:")
      print(error?.errorDescription ?? "unknown")
    })
}

First, add a new property to store the request, so it doesn't get deallocated. Then, inside the method, create a new UsersRequest by using UsersRequestBuilder. In this case, we're interested in all the users. If you want more control over which users to fetch, you can call set methods on the builder to set filters. For instance, you can use set(searchKeyword:) to search for a user by name, set(status:) to show only online users, or set(limit:) to limit the number of returned users.

Next, call fetchNext on the request to load the users. If the fetch completes successfully, you'll get an array of CometChat users that you'll convert to an array of User and call the completion handler.

Otherwise, if an error occurs, you'll call the completion handler with an empty array and print out the error.

Now that you have this method, you can call it from the contacts screen to fetch contacts. Go to ContactsViewController.swift and add the following code to the end of viewWillAppear:

ChatService.shared.getUsers { [weak self] users in
  self?.contacts = users
  self?.tableView.reloadData()
}

Call the method you just created and, when it completes, reload the table view with new users.

Finally, remove the fake contacts because you're now loading the real deal:

private var contacts: [User] = []

Run the project and navigate to the contacts screen.

Our heroes, fetched and all assembled.

Everything looks great — until Captain America logs in and you notice that, on your end, Captain America still seems offline. Right now the online status doesn’t update in real-time. Let's add a way to track that.

Seeing Who's Online

Thankfully, CometChat Pro gives us an easy way to track who's online in real-time. Open ChatService.swift and add a new property to the class:

var onUserStatusChanged: ((User)-> Void)?

This is a closure that you'll call whenever a user becomes online or offline. You'll do that by adding the following extension to the class:

extension ChatService: CometChatUserDelegate {

  func onUserOnline(user cometChatUser: CometChatPro.User) {
    DispatchQueue.main.async {
      self.onUserStatusChanged?(User(cometChatUser))
    }
  }

  func onUserOffline(user cometChatUser: CometChatPro.User) {
    DispatchQueue.main.async {
      self.onUserStatusChanged?(User(cometChatUser))
    }
  }
}

This makes ChatService a CometChatUserDelegate by implementing two functions. One gets called when a user becomes online and the other when they go offline. In both functions, you'll dispatch to the main queue and call the closure you declared earlier.

There's one final change to ChatService: At the top of login set the chat service as the user delegate:

CometChat.userdelegate = self

Once that's set up, head back to ContactsViewController.swift and set the closure from earlier at the bottom of viewDidLoad:

ChatService.shared.onUserStatusChanged = { [weak self] user in
  guard let self = self else { return }
  guard let index = self.contacts.firstIndex(of: user) else {
    return
  }

  self.contacts[index] = user
  self.tableView.reloadData()
}

Since onUserStatusChanged receives a User, you can find the index of that user in the array by using firstIndex(of:). Once you have the index, you can set the changed user to that index and reload the table view.

Run the project and navigate to the contacts screen.

Captain America is now online!

If you now run another simulator or run the app on a different device and log in as, say, superhero2, you'll see Captain America show a green dot indicating that he's come online.

Great! We built a fully functioning contacts screen that fetches and displays our users and whether or not they’re online. But, we still have a long way ahead of us. We haven’t even got to the actual chatting! Before we start building out that screen, let's first add a way to navigate to it.

Begin Chatting

To navigate to the chat screen, you'll perform a segue when the user taps on a contact cell. The chat screen needs to know which person you're chatting with, so you'll need to pass the contact to ChatViewController.

Still in ContactsViewController.swift, add the following extension to the bottom of the file:

// MARK: - UITableViewDelegate
extension ContactsViewController: UITableViewDelegate {
  func tableView(
    _ tableView: UITableView, 
    didSelectRowAt indexPath: IndexPath) {

    tableView.deselectRow(at: indexPath, animated: true)
    let contact = contacts[indexPath.row]
    performSegue(withIdentifier: Constants.showChatIdentifier, sender: contact)
  }
}

Whenever a cell becomes selected (that is, the user taps the cell) you'll find the contact and perform a segue, passing the contact as the sender of the segue. You'll also deselect the cell since you don't want it to stay looking selected in the UI.

Next, add the following line to the end of viewDidLoad:

tableView.delegate = self

To store the receiving user, add a new property to the class in ChatViewController.swift:

public var receiver: User!

Now, head back to ContactsViewController.swift and override prepare(for:sender:) to set the property you just added:

override func prepare(for segue: UIStoryboardSegue, sender: Any?) {
  switch segue.identifier {
  case Constants.showChatIdentifier:
    guard
      let chatVC = segue.destination as? ChatViewController,
      let contact = sender as? User
    else {
      return
    }

    chatVC.receiver = contact
  default:
    break
  }
}

If the segue is the one for showing the chat identifier, grab the chat view controller and the receiving user, and set the user on the controller.

Finally, head back to ChatViewController.swift and add the following line to the bottom of viewDidLoad:

title = receiver.name

Run the project and select one of the contacts.

It’s starting to look like a chat app.

You should be able to see their username on the top of the screen, as well as some fake chat messages.

ChatViewController already includes some boilerplate for you. The messages are, like most things in iOS, shown in a table view. There are two existing table view cell types in the project, for incoming and outgoing messages. ChatViewController holds an array of messages and implements table view data source methods to show one of those two cells for each message.

Underneath the table view is a text view and a Send button. This is, naturally, where you write your chat messages.

Currently, the messages are hard-coded — you'll receive real ones in a bit. Before you get to that, you need to make a couple of improvements to your UI.

Wrangling the keyboard

If you tap on the text view, you might notice an issue. When the keyboard pops up, it covers half of your screen, including the text view! I’m guessing you want your users to see the message they're writing. Let's take care of that.

You can fix this by utilizing the Notification Center. iOS sends out a notification when the keyboard appears. You can subscribe to this notification and get the keyboard's height when it appears. You can then raise the table view and the text view by that height so that it always appears on top of the keyboard.

In ChatViewController.swift, add the following method:

private func startObservingKeyboard() {
  let notificationCenter = NotificationCenter.default
  notificationCenter.addObserver(
    forName: UIResponder.keyboardWillShowNotification,
    object: nil,
    queue: nil,
    using: keyboardWillAppear)
  notificationCenter.addObserver(
    forName: UIResponder.keyboardWillHideNotification,
    object: nil,
    queue: nil,
    using: keyboardWillDisappear)
}

This method adds two observers for notifications when the keyboard appears or disappears, which will call keyboardWillAppear or keyboardWillDisappear, respectively.

Next, you’ll implement those two functions. First, add keyboardWillAppear to the class:

private func keyboardWillAppear(_ notification: Notification) {
  let key = UIResponder.keyboardFrameEndUserInfoKey
  guard let keyboardFrame = notification.userInfo?[key] as? CGRect else {
    return
  }

  let safeAreaBottom = view.safeAreaLayoutGuide.layoutFrame.maxY
  let viewHeight = view.bounds.height
  let safeAreaOffset = viewHeight - safeAreaBottom

  let lastVisibleCell = tableView.indexPathsForVisibleRows?.last
}

Each notification can pass additional info via the userInfo dictionary.
.keyboardFrameEndUserInfoKey will give you the frame of the keyboard, which you'll use to get the keyboard's height.

You'll also need to calculate the safe area offset between the bottom of the screen and the text view. You'll subtract that offset from the keyboard's height, so the text view doesn't float above the keyboard.

Now that you have the height, you can animate the text view rising together with the keyboard. Add the following code to the method:

UIView.animate(
  withDuration: 0.3,
  delay: 0,
  options: [.curveEaseInOut],
  animations: {
    self.textAreaBottom.constant = -keyboardFrame.height + safeAreaOffset
    self.view.layoutIfNeeded()
    if let lastVisibleCell = lastVisibleCell {
      self.tableView.scrollToRow(
        at: lastVisibleCell, at: .bottom, animated: false)
    }
})

This sets the text view's bottom constraint to be equal to the height of the keyboard. Because everything is laid out with auto layout, the table view will also adjust its size to fit the new constant. You'll also make sure the user maintains their scroll position by scrolling to the cell that was visible before the keyboard popped up.

Next, implement keyboardWillDisappear:

private func keyboardWillDisappear(_ notification: Notification) {
  UIView.animate(
    withDuration: 0.3,
    delay: 0,
    options: [.curveEaseInOut],
    animations: {
      self.textAreaBottom.constant = 0
      self.view.layoutIfNeeded()
  })
}

This function is similar to the one you just wrote, but you don't need to calculate any heights, you know it's zero when the keyboard disappears.

Next, at the bottom of viewDidLoad, add a call to startObservingKeyboard:

startObservingKeyboard()

Finally, because you're observing notifications, you need to make sure you avoid memory leaks by unsubscribing the view controller when it's deinitialized. Add the following deinit to the class:

deinit {
  let notificationCenter = NotificationCenter.default
  notificationCenter.removeObserver(
    self,
    name: UIResponder.keyboardWillShowNotification,
    object: nil)
  notificationCenter.removeObserver(
    self,
    name: UIResponder.keyboardWillHideNotification,
    object: nil)
}

Run the project and open the chat screen.

The text view now follows the keyboard.

If you start typing, you'll see the text view float up with the keyboard. You can now actually see what you're writing!

I think that was enough setup. It's time to get to the meat of an iOS chat app: Sending and receiving messages.

Sending and Receiving Messages

Because we're using CometChat, the process of sending and receiving messages is not nearly as complicated as it might seem.

Receiving messages

Head back to ChatService.swift and add the following property:

var onReceivedMessage: ((Message)-> Void)?

Just like the closure for user status, you'll call this closure whenever there's a new message.

Next, add the following extension to the bottom of the file:

extension ChatService: CometChatMessageDelegate {
  func onTextMessageReceived(textMessage: TextMessage) {
    DispatchQueue.main.async {
      self.onReceivedMessage?(Message(textMessage, isIncoming: true))
    }
  }
}

CometChatMessageDelegate is responsible for deciding what happens when a new message arrives. While CometChat also supports media messages and even custom message types, for now, in this iOS chat tutorial, we're only interested in text messages.

When you receive a message, convert it to a Message which is a struct defined in the project. You can then pass this message to the closure on the main thread.

You also need to make sure ChatService is set as the message delegate. Add this line to the top of login:

CometChat.messagedelegate = self

You now have a way of receiving messages from CometChat! You'll have to trust me on this, though, because you still have no way of sending any messages. Let's take care of that.

Sending messages

To send messages, add the following function to ChatService:

func send(message: String, to receiver: User) {
  guard let user = user else {
    return
  }

  let textMessage = TextMessage(
    receiverUid: receiver.id,
    text: message,
    messageType: .text,
    receiverType: .user)
}

The function takes the contents of the message and a user that it's intended for. Using this information, you construct a TextMessage with .user as the receiver type.

Next, add the following code to the function to send the message:

CometChat.sendTextMessage(
  message: textMessage,
  onSuccess: { [weak self] _ in
    guard let self = self else { return }
    print("Message sent")
    DispatchQueue.main.async {
      self.onReceivedMessage?(Message(
        user: user,
        content: message,
        isIncoming: false))
    }
  },
  onError: { error in
    print("Error sending message:")
    print(error?.errorDescription ?? "")
})

First, call CometChat's sendTextMessage and pass it the message. If it's sent successfully, you'll convert the message to Message and pass it to the onReceivedMessage closure, so that the UI updates. If something goes wrong, you'll print out an error.

You've now updated ChatService to support both receiving and sending messages. It's time to connect this with the view controller.

In ChatViewController.swift, call your newly created method at the bottom of sendMessage:

ChatService.shared.send(message: message, to: receiver)

To receive messages, set ChatService's closure at the bottom of viewDidLoad:

ChatService.shared.onReceivedMessage = { [weak self] message in
  guard let self = self else { return }
  let isFromReceiver = message.user == self.receiver
  if !message.isIncoming || isFromReceiver {
    self.messages.append(message)
    self.scrollToLastCell()
  }
}

You can't just blindly show every new message on the screen. You need to check that the message is either from the current user or the receiver of this view controller. If that's the case, you can add the message to the array and scroll to the new message.

Finally, replace the declaration of messages with the following:

var messages: [Message] = [] {
  didSet {
    emptyChatView.isHidden = !messages.isEmpty
    tableView.reloadData()
  }
}

This removes the fake chat messages since you're now receiving real ones. You also add a property observer to reload the table view and show or hide the empty chat view whenever the array changes.

Run the project and start chatting with someone.

You'll see your messages pop up on the screen. If you run the app on another simulator or device, you can chat with yourself. Chatting with yourself — truly, technology has come far.

If you don’t feel like firing up another simulator, you can also test the app from the CometChat dashboard. Go back to your CometChat app’s dashboard and click on Messages in the sidebar. You’ll see a form that lets you send messages from one user to another user or group. Nifty!

There's just one small problem left. If you re-run the app, you'll see all of your messages are gone. Don’t worry — they’re still saved on CometChat, you’re just not fetching them. Let's fix that.

Fetching your message history

When your user enters a chat, you'll need to fetch previous messages from that chat. Go back to ChatService.swift and add a new method that will fetch message history between the logged in user and a contact:

private var messagesRequest: MessagesRequest?
func getMessages(
  from sender: User, 
  onComplete: @escaping ([Message])-> Void) {

  guard let user = user else {
    return
  }

  let limit = 50

  messagesRequest = MessagesRequest.MessageRequestBuilder()
    .set(limit: limit)
    .set(uid: sender.id)
    .build()
}

You'll notice a couple of similarities with the user’s request. You'll also have to store the messages request, so it doesn't get deallocated. Just like before, you use a builder, in this case, MessageRequestBuilder. Build out a request for the last 50 messages from a specific user.

Next, add the following code to the method to execute the request:

messagesRequest!.fetchPrevious(
  onSuccess: { fetchedMessages in
    print("Fetched \(fetchedMessages?.count ?? 0) older messages")
    guard let fetchedMessages = fetchedMessages else {
      onComplete([])
      return
    }

    let messages = fetchedMessages
      .compactMap { $0 as? TextMessage }
      .map { 
        Message($0, 
          isIncoming: $0.senderUid.lowercased() != user.id.lowercased()) 
       }

    DispatchQueue.main.async {
      onComplete(messages)
    }
  },
  onError: { error in
    print("Fetching messages failed with error:")
    print(error?.errorDescription ?? "unknown")
})

This code may seem complicated, so let's break it down piece by piece:

  1. Call fetchPrevious to load the last 50 messages.
  2. If everything is successful, grab an array of CometChat messages, filter out messages that aren’t a text message, and then convert those to an array of Message instances.
  3. If something goes wrong, print out the error.

Finally, back in ChatViewController.swift, call this method at the bottom of viewWillAppear:

ChatService.shared.getMessages(from: receiver) { 
  [weak self] messages in

  self?.messages = messages
  self?.scrollToLastCell()
}

Run the project one final time, and you'll see your message history appear when you open a chat!

You built a fully-featured one-on-one iOS chat app, using Swift and CometChat!

Conclusion

In the span of one tutorial, you went frofm having nothing to having a fully-functioning iOS chat app! You learned how to log into CometChat, fetch a list of users and send and receive chat messages to and from those users. The good news is you don't have to stop there.

Because you use CometChat, it's easy to add additional features. Here are a few ideas:

  • CometChat supports read receipts, so you can show when another user has read a message.
  • You can also show typing indicators while a user is typing.
  • With support for media messages, you can easily add photo sharing. With custom message types, you can even send files like PDF documents, archives, presentations, and other file types.
  • Don't limit yourself to text! CometChat also supports calling, for those hard-to-type situations.

You can also read my previous tutorial on building an iOS group chat with Swift and CometChat.

I hope this iOS chat tutorial was helpful. Good luck on your chat app journey!