Firebase Tutorial: Real-Time Chat

Learn to build a chat app with Firebase and MessageKit! By Yusuf Tör.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

Creating Messages

In ChatViewController, below viewDidLoad(), add:

// MARK: - Helpers
private func insertNewMessage(_ message: Message) {
  if messages.contains(message) {
    return
  }

  messages.append(message)
  messages.sort()

  let isLatestMessage = messages.firstIndex(of: message) == (messages.count - 1)
  let shouldScrollToBottom =
    messagesCollectionView.isAtBottom && isLatestMessage

  messagesCollectionView.reloadData()

  if shouldScrollToBottom {
    messagesCollectionView.scrollToLastItem(animated: true)
  }
}

This method adds a new message. It first makes sure the message isn’t already present, then adds it to the collection view. Then, if the new message is the latest and the collection view is at the bottom, it scrolls to reveal the new message.

Now add the following underneath viewDidLoad():

override func viewDidAppear(_ animated: Bool) {
  super.viewDidAppear(animated)
  
  let testMessage = Message(
    user: user,
    content: "I love pizza; what is your favorite kind?")
  insertNewMessage(testMessage)
}

This adds a simple test message when the view appears.

Build and run. You’ll see your message appear in the conversation view:

A test message

Boom! That’s one good-looking chat app! Time to make it work for real with Firebase.

Sending Messages

First, delete viewDidAppear(_:) to remove the test message in ChatViewController. Then, add the following properties at the top of the class:

private let database = Firestore.firestore()
private var reference: CollectionReference?

Below viewDidLoad(), add:

private func listenToMessages() {
  guard let id = channel.id else {
    navigationController?.popViewController(animated: true)
    return
  }

  reference = database.collection("channels/\(id)/thread")
}

First the id on the channel is checked for nil because you might not have synced the channel yet. It shouldn’t be possible to send messages if the channel doesn’t exist in Firestore yet, so returning to the channel list makes the most sense. Then the reference is set up to reference the thread array on the channel in the database.

Then, in viewDidLoad(), add the following below super.viewDidLoad():

listenToMessages()

Next, add the following method to the top of the Helpers section:

private func save(_ message: Message) {
  reference?.addDocument(data: message.representation) { [weak self] error in
    guard let self = self else { return }
    if let error = error {
      print("Error sending message: \(error.localizedDescription)")
      return
    }
    self.messagesCollectionView.scrollToLastItem()
  }
}

This method uses the reference you just set up. addDocument on the reference takes a dictionary with the keys and values representing that data. The message data structure implements DatabaseRepresentation, which defines a dictionary property to fill out.

Back in ChatViewController.swift, add the following delegate method inside InputBarAccessoryViewDelegate:

func inputBar(
  _ inputBar: InputBarAccessoryView,
  didPressSendButtonWith text: String
) {
  // 1
  let message = Message(user: user, content: text)

  // 2
  save(message)

  // 3
  inputBar.inputTextView.text = ""
}

Here you:

  1. Create a Message from the contents of the input bar and the current user.
  2. Save the message to the Firestore database.
  3. Clear the input bar’s text view after you send the message, ready for the user to send the next message.

Next, you need to create a database.

Creating the Database

Open your app’s Firebase console. Click Firestore Database on the left and Create database:

Creating a Firestore database

When you create a database for a real-world setup, you’ll want to configure security rules but they’re not necessary for this tutorial. Select Start in test mode and click Next. You can read more about security rules in the Firestore documentation.

Configuring security rules for Firestore

You can configure Firestore to store data in different regions across the world. For now, leave the location as the default setting and click Enable to create your database:

Setting the Firestore database location

Build and run. Select a channel and send a message.

You’ll see the messages appear in the dashboard in real-time. You may need to refresh the page if you don’t see any updates when you add the first message:

Saving a message to the database

High five! You’re saving messages to Firestore like a pro. The messages don’t appear on the screen, but you’ll take care of that next.

Synchronizing the Data Source

In ChatViewController, add the following below insertNewMessage(_:):

private func handleDocumentChange(_ change: DocumentChange) {
  guard let message = Message(document: change.document) else {
    return
  }

  switch change.type {
  case .added:
    insertNewMessage(message)
  default:
    break
  }
}

For simplicity in this tutorial, the only change type you handle in the switch statement is added.

Next, add the following code to the bottom of listenToMessages():

messageListener = reference?
  .addSnapshotListener { [weak self] querySnapshot, error in
    guard let self = self else { return }
    guard let snapshot = querySnapshot else {
      print("""
        Error listening for channel updates: \
        \(error?.localizedDescription ?? "No error")
        """)
      return
    }

    snapshot.documentChanges.forEach { change in
      self.handleDocumentChange(change)
    }
  }

Firestore calls this snapshot listener whenever there’s a change to the database.

You need to clean up that listener. So add this above viewDidLoad():

deinit {
  messageListener?.remove()
}

Build and run. You’ll see any messages sent earlier along with any new ones you enter:

Messages showing in the channel

Congrats! You have a real-time chat app! Now it’s time to add one final finishing touch.

Sending Images

To send images, you’ll follow mostly the same principle as sending text with one key difference. Rather than storing the image data directly with the message, you’ll use Firebase Storage, which is better suited to storing large files like audio, video or images.

Add the following at the top of ChatViewController.swift:

import Photos

Then above the Helpers section add:

// MARK: - Actions
@objc private func cameraButtonPressed() {
  let picker = UIImagePickerController()
  picker.delegate = self

  if UIImagePickerController.isSourceTypeAvailable(.camera) {
    picker.sourceType = .camera
  } else {
    picker.sourceType = .photoLibrary
  }

  present(picker, animated: true)
}

This method presents an image picker controller to let the user select an image.

Then, add the following code below removeMessageAvatars():

private func addCameraBarButton() {
  // 1
  let cameraItem = InputBarButtonItem(type: .system)
  cameraItem.tintColor = .primary
  cameraItem.image = UIImage(named: "camera")

  // 2
  cameraItem.addTarget(
    self,
    action: #selector(cameraButtonPressed),
    for: .primaryActionTriggered)
  cameraItem.setSize(CGSize(width: 60, height: 30), animated: false)
  messageInputBar.leftStackView.alignment = .center
  messageInputBar.setLeftStackViewWidthConstant(to: 50, animated: false)

  // 3
  messageInputBar
    .setStackViewItems([cameraItem], forStack: .left, animated: false)
}

Here you:

  1. Create a new InputBarButtonItem with a tint color and an image.
  2. Connect the new button to cameraButtonPressed().
  3. Add the item to the left side of the message bar.

Next, add the following to the bottom of viewDidLoad():

addCameraBarButton()

Sending a photo message is a little different than sending a plain text message. Uploading a photo to Firebase Storage may take a couple of seconds, perhaps longer if the network connection is poor.

Rather than blocking the user interface during this time, which will make your app feel slow, you’ll start sending the message and disable the camera message bar item.

At the top of ChatViewController add:

private var isSendingPhoto = false {
  didSet {
    messageInputBar.leftStackViewItems.forEach { item in
      guard let item = item as? InputBarButtonItem else {
        return
      }
      item.isEnabled = !self.isSendingPhoto
    }
  }
}

private let storage = Storage.storage().reference()

isSendingPhoto updates the camera button to be enabled only when there is no photo sending in progress. storage is a reference to the root of Firebase Storage.

Then, add this method to the bottom of the Helpers section:

private func uploadImage(
  _ image: UIImage,
  to channel: Channel,
  completion: @escaping (URL?) -> Void
) {
  guard
    let channelId = channel.id,
    let scaledImage = image.scaledToSafeUploadSize,
    let data = scaledImage.jpegData(compressionQuality: 0.4)
  else {
    return completion(nil)
  }

  let metadata = StorageMetadata()
  metadata.contentType = "image/jpeg"

  let imageName = [UUID().uuidString, String(Date().timeIntervalSince1970)]
    .joined()
  let imageReference = storage.child("\(channelId)/\(imageName)")
  imageReference.putData(data, metadata: metadata) { _, _ in
    imageReference.downloadURL { url, _ in
      completion(url)
    }
  }
}

This method uploads an image to the specified channel in the Firebase Storage.

Below uploadImage(_:to:completion:), add:

private func sendPhoto(_ image: UIImage) {
  isSendingPhoto = true

  uploadImage(image, to: channel) { [weak self] url in
    guard let self = self else { return }
    self.isSendingPhoto = false

    guard let url = url else {
      return
    }

    var message = Message(user: self.user, image: image)
    message.downloadURL = url

    self.save(message)
    self.messagesCollectionView.scrollToLastItem()
  }
}

This method first updates isSendingPhoto to update the UI. Then it kicks off the upload and once the photo upload completes and returns the URL to that photo, it saves a new message with that photo URL to the database.

Before you can use sendPhoto(_:), you need to add some image picker delegate methods. To UIImagePickerControllerDelegate add:

func imagePickerController(
  _ picker: UIImagePickerController,
  didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
) {
  picker.dismiss(animated: true)

  // 1
  if let asset = info[.phAsset] as? PHAsset {
    let size = CGSize(width: 500, height: 500)
    PHImageManager.default().requestImage(
      for: asset,
      targetSize: size,
      contentMode: .aspectFit,
      options: nil
    ) { result, _ in
      guard let image = result else {
        return
      }
      self.sendPhoto(image)
    }

  // 2
  } else if let image = info[.originalImage] as? UIImage {
    sendPhoto(image)
  }
}

func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
  picker.dismiss(animated: true)
}

These two methods handle cases when the user either selects an image or cancels the selection process. When choosing an image, the user can either get one from the photo library or take an image directly with the camera.

Here’s a breakdown:

  1. If the user selected an asset, you request to download it from the user’s photo library at a fixed size. Once it’s successfully retrieved, you send it.
  2. If there’s an original image in the info dictionary, send that. You don’t need to worry about the original image being too large because the storage helper handles resizing the image for you. Look at UIImage+Additions.swift for the resizing implementation.

Nearly there! You’ve set up your app to save the image data to Firebase Storage and save the URL to the message data. But you haven’t updated the app to display those photos yet.

Time to fix that.