Firebase Tutorial: Real-Time Chat
Learn to build a chat app with Firebase and MessageKit! By Yusuf Tör.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Firebase Tutorial: Real-Time Chat
30 mins
- Getting Started
- Creating a Firebase Account
- Enabling Anonymous Authentication
- Logging In
- Choosing a Firebase Database
- Firebase Data Structure
- Setting Up the Chat Interface
- Setting Up the Display and Layout Delegates
- Creating Messages
- Sending Messages
- Creating the Database
- Synchronizing the Data Source
- Sending Images
- Displaying Photos in Threads
- Where to Go From Here?
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:
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:
- Create a
Message
from the contents of the input bar and the current user. - Save the message to the Firestore database.
- 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:
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.
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:
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:
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:
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:
- Create a new
InputBarButtonItem
with a tint color and an image. - Connect the new button to
cameraButtonPressed()
. - 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:
- 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.
- 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.