Update note: Ryan Ackermann updated this to iOS 12, Swift 4.2, Xcode 10, MessageKit, and Cloud Firestore. Tom Elliott wrote the original . You can safely ignore the warnings about updating to Swift 4.2 since MessageKit is not yet updated.

It seems like every major app out there has a feature — and yours should be no different! This tutorial will show you how.

However, creating a chat tool can seem like a daunting task. There’s no native UIKit controls designed specifically for chat, and you’ll need a server to coordinate the messages and conversations between users.

Fortunately, there are some great frameworks out there to help you: Firebase lets you synchronize real time data without writing a line of server code, while MessageKit gives you a messaging UI that’s on par with the native Messages app.

In this Firebase tutorial, you’ll build RWRC ( Relay Chat) — an anonymous chat application. If you’ve used IRC or Slack, this sort of application should already be familiar to you.

Along the way, you’ll learn how to do the following:

  • Set up the Firebase SDK and MessageKit with CocoaPods.
  • Synchronize data in real time with the Cloud Firestore.
  • Authenticate anonymously with Firebase.
  • Leverage MessageKit for a complete chat UI.
  • Create multiple message threads.
  • Use Firebase Storage to send pictures.

Getting Started

Use the Download Materials button at the top or bottom of this tutorial to download the starter project. To get you started the project contains a simple dummy login screen, where the credentials are saved to User Defaults.

The starter project has a few helper classes that handle sending data to Firebase and saving data to User Defaults. Feel free to browse the starter project a bit to get familiar with the code.

In the starter project you’ll find ChannelsViewController.swift which listens to changes in a Firebase Firestore database and updates a table view whenever the user adds a new channel. You’ll build a similar implementation for displaying chat messages instead of channels.

You’ll use CocoaPods to install both the Firebase SDK and MessageKit. If you’re new to CocoaPods, check out our Cocoapods with Swift tutorial to get up and running.

Open Terminal at the project’s folder location and run the following command to install your dependencies:


pod install

This may take a few minutes, but once the packages have installed, open RWRC.xcworkspace in Xcode. Before you can run the app, you’ll need to configure Firebase.

If you’re new to Firebase you’ll need to create an account. Don’t worry — this is easy and totally free.

Create a Firebase Account

Head to the Firebase signup site, create an account, and then create a new Firebase project.

In Xcode, click on the target and change the Bundle Identifier to any value you like, and select a Team in the Signing section.

Follow Steps 1 and 2 of the instructions to add Firebase to an iOS app, starting here:

Add Firebase to iOS App

Next, enter in the app’s bundle ID into the form, after which you will download and add the GoogleService-Info.plist config file to your project under the Supporting Files group as shown in the Firebase instructions. This .plist file contains the configuration information needed for Firebase integration with your app.

Warning: Do only Steps 1 and 2 of the instructions. The rest is already done in the starter project and your app will crash if you duplicate the steps.

Now build and run the app. You should see the following:

Login Screen

Enabling Anonymous Authentication

Firebase lets users log in through email or social accounts, but it can also authenticate users anonymously, giving you a unique identifier for a user without knowing any information about them.

To set up anonymous authentication, open the Firebase App Dashboard, select the Authentication option on the left, click Set Up Sign-In Method, then select the Anonymous option, switch Enable so that it’s on, then click Save.

Firebase Auth Console

Just like that, you’ve enabled super secret stealth mode! Okay, so it’s really just anonymous authentication, but hey — it’s still cool. :]

Stealth Swift

Super secret stealth mode achieved!

Logging In

Open LoginViewController.swift and add the following underneath import UIKit:


import FirebaseAuth

To log in to chat, the app will need to authenticate using the Firebase authentication service. Add the following code to the bottom of signIn:


Auth.auth().signInAnonymously(completion: nil)

That line of code from the FirebaseAuth framework will post the Notification.Name.AuthStateDidChange notification that AppController is listening for. Once the notification is fired AppController will update the root view controller for you.

Build and run your app, enter a display name and tap Get Started.

Empty Channel List

Once the user signs in, they navigate to the ChannelsViewController, whose job it is to show the user a list of current channels and allow creating new channels. The table has a single section to display all available channels. There is a toolbar at the bottom with a sign out button, a label displaying your name, and an add button.

Firebase Data Structure

Before you dive into sending messages in , take a moment and think about the data structure first.

Cloud Firestore is a NoSQL JSON data store. Essentially, everything in the Cloud Firestore is a JSON object, and each key of this JSON object has its own URL.

Here’s a sample of how your data could look as a JSON object:


{
  "channels": [{
    "MOuL1sdbrnh0x1zGuXn7": { // channel id
      "name": "Puppies",
      "thread": [{
        "3a6Fo5rrUcBqhUJcLsP0": { // message id
          "content": "Wow, that's so cute!",
          "created": "May 12, 2018 at 10:44:11 PM UTC-5",
          "senderID": "YCrPJF3shzWSHagmr0Zl2WZFBgT2",
          "senderName": "naturaln0va",
        },
        "4LXlVnWnoqyZEuKiiubh": { // message id
          "content": "Yes he is.",
          "created": "May 12, 2018 at 10:40:05 PM UTC-5",
          "senderID": "f84PFeGl2yaqUDaSiTVeqe9gHfD3",
          "senderName": "lumberjack16",
        },
      }]
    },
  }]
}

Cloud Firestore favors a denormalized data structure, so it’s okay to include senderId and senderName for each message item. A denormalized data structure means you’ll duplicate a lot of data, but the upside is faster data retrieval. Tradeoffs — we haz them! :]

Chat Interface Setup

MessageKit is a souped-up UICollectionViewController that’s customized for chat, so you don’t have to create your own! :]

In this section of the tutorial, you’ll focus on four things:

  1. Handling input from the message bar.
  2. Creating message data.
  3. Styling message bubbles.
  4. Removing avatar support.

Almost everything you’ll need to do requires that you override methods. MessageKit provides the MessagesDisplayDelegate, MessagesLayoutDelegate, and MessagesDataSource protocols, so you only need to override the default implementations.

Note: For more information on customizing and working with MessagesViewController, check out the full the documentation here.

Open ChatViewController.swift and, at the top of ChatViewController, define the following properties:


private var messages: [Message] = []
private var messageListener: ListenerRegistration?

These properties are similar to those added to the channels view controller. The messages array is the data model and the listener handles clean up.

Now you can start configuring the data source. Above the MessageInputBarDelegate section, add the following:


// MARK: - MessagesDataSource

extension ChatViewController: MessagesDataSource {

  // 1
  func currentSender() -> Sender {
    return Sender(id: user.uid, displayName: AppSettings.displayName)
  }

  // 2
  func numberOfMessages(in messagesCollectionView: MessagesCollectionView) -> Int {
    return messages.count
  }

  // 3
  func messageForItem(at indexPath: IndexPath, 
    in messagesCollectionView: MessagesCollectionView) -> MessageType {

    return messages[indexPath.section]
  }

  // 4
  func cellTopLabelAttributedText(for message: MessageType, 
    at indexPath: IndexPath) -> NSAttributedString? {

    let name = message.sender.displayName
    return NSAttributedString(
      string: name,
      attributes: [
        .font: UIFont.preferredFont(forTextStyle: .caption1),
        .foregroundColor: UIColor(white: 0.3, alpha: 1)
      ]
    )
  }
}

There’s a bit going on here:

  1. A sender is a simple struct that has an id and name property. You create an instance of a sender from the anonymous Firebase user id and the chosen display name.
  2. The number of messages in the collection view will be equal to the local array of messages.
  3. Your Message model object conforms to MessageType so you can just return the message for the given index path.
  4. The last method returns the attributed text for the name above each message bubble. You can modify the text you’re returning here to your liking, but these are some good defaults.

Build and run the app, add a channel named Cooking and then navigate to it. It should now look like:

Empty Message Thread

So far, so good. Next, you’ll need to implement a few more delegates before you start sending messages.

Setting Up the Display and Layout Delegates

Now that you’ve seen your new awesome chat UI, you probably want to start displaying messages. But before you do that, you have to take care of a few more things.

First, you’ll fine tune some layout parameters from the MessagesLayoutDelegate. Add the following section below the MessagesDisplayDelegate section:


// MARK: - MessagesLayoutDelegate

extension ChatViewController: MessagesLayoutDelegate {

  func avatarSize(for message: MessageType, at indexPath: IndexPath, 
    in messagesCollectionView: MessagesCollectionView) -> CGSize {

    // 1
    return .zero
  }

  func footerViewSize(for message: MessageType, at indexPath: IndexPath, 
    in messagesCollectionView: MessagesCollectionView) -> CGSize {

    // 2
    return CGSize(width: 0, height: 8)
  }

  func heightForLocation(message: MessageType, at indexPath: IndexPath, 
    with maxWidth: CGFloat, in messagesCollectionView: MessagesCollectionView) -> CGFloat {

    // 3
    return 0
  }
}

Here’s the break down:

  1. Returning zero for the avatar size will hide it from the view.
  2. Adding a little padding on the bottom of each message will help the readability of the chat.
  3. At the time of writing, MessageKit doesn’t have a default implementation for the height of a location message. Since you won’t be sending a location message in this tutorial, return zero as the default.

The messages displayed in the collection view are simply images with text overlaid. There are two types of messages: outgoing and incoming. Outgoing messages are displayed to the right and incoming messages on the left.

In ChatViewController, replace the MessagesDisplayDelegate extension with the following:


extension ChatViewController: MessagesDisplayDelegate {
  
  func backgroundColor(for message: MessageType, at indexPath: IndexPath, 
    in messagesCollectionView: MessagesCollectionView) -> UIColor {
    
    // 1
    return isFromCurrentSender(message: message) ? .primary : .incomingMessage
  }

  func shouldDisplayHeader(for message: MessageType, at indexPath: IndexPath, 
    in messagesCollectionView: MessagesCollectionView) -> Bool {

    // 2
    return false
  }

  func messageStyle(for message: MessageType, at indexPath: IndexPath, 
    in messagesCollectionView: MessagesCollectionView) -> MessageStyle {

    let corner: MessageStyle.TailCorner = isFromCurrentSender(message: message) ? .bottomRight : .bottomLeft

    // 3
    return .bubbleTail(corner, .curved)
  }
}

Taking the above code step-by-step:

  1. For the given message, you check and see if it’s from the current sender. If it is, you return the app’s primary green color; if not, you return a muted gray color. MessageKit uses this color when creating the background image for the message.
  2. You return false to remove the header from each message. You can use this to display thread specific information, such as a timestamp.
  3. Finally, based on who sent the message, you choose a corner for the tail of the message bubble.

To tie this all together, add the following to the bottom of viewDidLoad():


messageInputBar.delegate = self
messagesCollectionView.messagesDataSource = self
messagesCollectionView.messagesLayoutDelegate = self
messagesCollectionView.messagesDisplayDelegate = self

Check that your app builds and you can navigate to one of your channels

Another Empty Thread

Believe it or not, that’s all it takes to configure a MessagesViewController subclass to display messages! Well, it would be more exciting to see some messages, wouldn’t it?

Time to get this conversation started!

Creating Messages

Create the following method below viewDidLoad() in ChatViewController:


// MARK: - Helpers

private func insertNewMessage(_ message: Message) {
  guard !messages.contains(message) else {
    return
  }
  
  messages.append(message)
  messages.sort()
  
  let isLatestMessage = messages.index(of: message) == (messages.count - 1)
  let shouldScrollToBottom = messagesCollectionView.isAtBottom && isLatestMessage
  
  messagesCollectionView.reloadData()
  
  if shouldScrollToBottom {
    DispatchQueue.main.async {
      self.messagesCollectionView.scrollToBottom(animated: true)
    }
  }
}

This helper method is similar to the one that’s in ChannelsViewController. It makes sure the messages array doesn’t already contain the message, then adds it to the collection view. Then, if the new message is the latest and the collection view is at the bottom, scroll to reveal the new message.

Add a test message by overriding viewDidAppear(_:):


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

Build and run the app; you’ll see your message appear in the conversation view:

Test Message

Boom — that’s one nice looking chat app! Time to make it work (for real) with Firebase.

Sending Messages

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


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

At the top of viewDidLoad add the following:


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

reference = db.collection(["channels", id, "thread"].joined(separator: "/"))

The reference property is the point in the database where the messages are stored. The id property on the channel is optional because you might not yet have synced the channel. If the channel doesn’t exist in Firestore yet messages cannot be sent, so returning to the channel list makes the most sense.

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


private func save(_ message: Message) {
  reference?.addDocument(data: message.representation) { error in
    if let e = error {
      print("Error sending message: (e.localizedDescription)")
      return
    }
    
    self.messagesCollectionView.scrollToBottom()
  }
}

This method uses the reference that was just setup. The addDocument method on the reference takes a dictionary with the keys and values that represent that data. The message data struct implements DatabaseRepresentation, which defines a dictionary property to fill out.

Open Message.swift and examine the implementation of DatabaseRepresentation. As you can see, it maps its properties to readable keys and only sets the content of the message if there is no download URL.

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


func messageInputBar(_ inputBar: MessageInputBar, didPressSendButtonWith text: String) {

  // 1
  let message = Message(user: user, content: text)

  // 2
  save(message)

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

Here’s what’s going on:

  1. Create a message from the contents of the message bar and the current user.
  2. Save the message to Cloud Firestore using save(_:).
  3. Clear the message bar’s input field after you send the message.

Build and run; open up your Firebase App Dashboard and click on the Database tab. Select a channel, then send a message in the app and you should see the messages appear in the dashboard in real time:

Note: The first time you view the database on the console, it will prompt you to select a database type. For this tutorial, you’re using Cloud Firestore. After clicking the Create Database button, select the test mode option. For a real world setup, you’ll want to configure security rules for Firestore. You can read more about security rules here.

Firebase Messaging

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

Synchronizing the Data Source

Add the following to below insertNewMessage(_:) in ChatViewController:


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

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

  default:
    break
  }
}

This is very similar to how ChannelsViewController observes new database changes. For brevity, the only change type you handle in the switch statement is add.

Next, add the following code below the reference initialization in viewDidLoad():


messageListener = reference?.addSnapshotListener { querySnapshot, error in
  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 is a change to the database.

To clean things up add a deinit towards the top of the file:


deinit {
  messageListener?.remove()
}

Build and run your app; you should see any messages sent earlier along with any new ones you enter:

Previous Messages

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

Sending Images

To send images, you’re going to 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.

To start, you need to add the Photos import to ChatViewController.swift:


import Photos

Add the following above the Helpers section:


// 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, completion: nil)
}

This method will present an image picker controller to allow the user to select an image.

Next, add the following to viewDidLoad():


// 1
let cameraItem = InputBarButtonItem(type: .system)
cameraItem.tintColor = .primary
cameraItem.image = #imageLiteral(resourceName: "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)

Going through this:

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

Sending a photo message is a little different then sending a plain text message. Saving a photo to Firebase Storage returns a URL, but this 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.

Add the following properties at the top of ChatViewController:


private var isSendingPhoto = false {
  didSet {
    DispatchQueue.main.async {
      self.messageInputBar.leftStackViewItems.forEach { item in
        item.isEnabled = !self.isSendingPhoto
      }
    }
  }
}

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

and 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 else {
    completion(nil)
    return
  }
  
  guard let scaledImage = image.scaledToSafeUploadSize,
    let data = scaledImage.jpegData(compressionQuality: 0.4) else {
    completion(nil)
    return
  }
  
  let metadata = StorageMetadata()
  metadata.contentType = "image/jpeg"
  
  let imageName = [UUID().uuidString, String(Date().timeIntervalSince1970)].joined()
  storage.child(channelID).child(imageName).putData(data, metadata: metadata) { meta, error in
    completion(meta?.downloadURL())
  }
}

The isSendingPhoto property takes care of updating the camera item when it changes and the storage property is a reference to the root of Firebase Storage. uploadImage(_:to:completion:) 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.scrollToBottom()
  }
}

This method takes care of updating the isSendingPhoto property to update the UI. Once the photo upload completes and the URL to that photo is returned, save a new message with that photo URL to the database.

Next, to use sendPhoto(_:), add the following image picker delegate methods to the UIImagePickerControllerDelegate extension:


func imagePickerController(_ picker: UIImagePickerController, 
                           didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
  picker.dismiss(animated: true, completion: nil)
  
  // 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, info 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, completion: nil)
}

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

Here’s what this does:

  1. If the user selected an asset, the selected image needs to be downloaded from iCloud. Request it at a fixed size. Once it’s successfully retrieved, send it.
  2. If there is an original image in the info dictionary, send that. You don’t need to worry about the original image being too large here because the storage helper handles resizing the image for you. Have a look at UIImage+Additions.swift to see how the resizing is done.

Nearly there! You’ve now set up your app to save the image data to Firebase Storage and save the URL to the message data, but you’ve not yet updated the app to display those photos. Time to fix that.

Get started by adding the following to the bottom of the Helpers section:


private func downloadImage(at url: URL, completion: @escaping (UIImage?) -> Void) {
  let ref = Storage.storage().reference(forURL: url.absoluteString)
  let megaByte = Int64(1 * 1024 * 1024)
  
  ref.getData(maxSize: megaByte) { data, error in
    guard let imageData = data else {
      completion(nil)
      return
    }
    
    completion(UIImage(data: imageData))
  }
}

This method asynchronously downloads an image at the specified path from Firebase Storage.

Next, change the guard statement from a constant to a variable in the handleDocumentChange(_:) method:


guard var message = Message(document: change.document) else {
  return
}

Then, in handleDocumentChange(_:), replace the content of the .added case with the following:


if let url = message.downloadURL {
  downloadImage(at: url) { [weak self] image in
    guard let self = self else {
      return
    }
    guard let image = image else {
      return
    }
    
    message.image = image
    self.insertNewMessage(message)
  }
} else {
  insertNewMessage(message)
}

Note: You’ll need to open the Firebase Console and enable Storage. To do this, first select storage on the left, click Get Started, then choose default security rules.

Build and run the app; tap on the little camera icon and send a photo message in your chat. Notice how the camera icon is disabled when your app is saving the photo data to Firebase Storage.

Photo Message

Kaboom! You just made a big, bad, real time, photo and text sending chat app. Go grab yourself your favorite beverage, you earned it!

Where to Go From Here?

Use the Download Materials button at the top or bottom of this tutorial to download the completed project.

You now know the basics of Cloud Firestore and MessageKit, but there’s plenty more you can do, including one-to-one messaging, social authentication, and avatar display.

To take this app even further, you could take a look at the Firebase iOS documentation. You can also take a look at our 22 part video course on Beginning Firebase!

I hope you’ve enjoyed this Firebase tutorial; if you have any questions feel free to leave them in the non-anonymous yet avatar-enabled discussion below! :]



Source link https://www.raywenderlich.com/5359-firebase-tutorial-real-time-chat

LEAVE A REPLY

Please enter your comment!
Please enter your name here