How To Build A Drop-in Video Chat Application in iOS

zontan

zontan

Posted on July 9, 2020

How To Build A Drop-in Video Chat Application in iOS

Many cities and states have been under lockdown since the outbreak of the coronavirus epidemic. During this difficult time, we’re all looking for new ways to stay connected and support each other. This is when social networking applications such as Houseparty become especially relevant and helpful.

These applications let users meet up and have fun with their friends without having to leave their homes. Users can enter their friend’s virtual room by just clicking a button. Houseparty, in particular, also provides some built-in games that users can play together.

If you’ve ever wondered how these cool applications are made, read on! This blog post will help get you started on the basics of building a similar social networking application in iOS.

Prerequisites

  1. A basic understanding of Swift and the iOS SDK.
  2. An Agora.io developer account.
  3. Xcode and an iOS device.
  4. CocoaPods (If you don’t have CocoaPods installed already, you can find instructions here).

Overview

This guide will go over the steps for building a social networking application similar to Houseparty. This is a list of the core features that will be included in our app:

  • Users can create and login into their account. User account information will be saved in Google Firebase Realtime Database.
  • Users can set up virtual rooms to host video calls.
  • Users can configure the accessibility of their virtual rooms. “Public” rooms are open for all friends to join and “private” rooms are invitation-only.
  • During a video call, users can send private messages to another user in the same room by double-clicking on that user’s remote video.
  • Users can chat with friends who are not in the room by clicking a button next to their names in the friend list.

You can find my Github demo app as a reference for this article.

Setting Up a New Project

To start, let's open up Xcode and create a new, blank project.

  1. Open Xcode and select New Project.
  2. Select Single View App.
  3. Name and finalize your project. Make sure the language is set as Swift.

Setting up CocoaPods

  1. In Terminal, navigate to the root directory of your project and run pod init to initialize CocoaPods.
  2. Open the Podfile that was created and add the pods for the Agora library, as well as the Firebase libraries we'll use for user management:
target 'Your App' do
  use_frameworks!

  pod 'AgoraRtcEngine_iOS'
  pod 'AgoraRtm_iOS'
  pod 'Firebase/Analytics'
  pod 'Firebase/Auth'
  pod 'Firebase/Database'
  pod 'FirebaseUI'
end
Enter fullscreen mode Exit fullscreen mode
  1. Run pod install in Terminal to install the libraries.
  2. From now on, open YourApp.xcworkspace to edit and run your app.

Setting up Firebase

Go to https://console.firebase.google.com and create a new Firebase project. Follow the instructions there to set up Firebase within your existing app. We're going to be using Firebase for authentication, analytics, and user management.

Once you've finished going through Firebase's setup, you should have completed the following steps:

  1. Register your app's Bundle ID with Firebase. (As a reminder, you can find your Bundle ID in your project settings, under General)
  2. Download the GoogleService-Info.plist file and add it to your app.
  3. Import Firebase to your AppDelegate, and call FirebaseApp.configure() in didFinishLaunchingWithOptions.
  4. Run your application and have Firebase verify communication.

You will then be presented with the Firebase dashboard. Go to the Develop pane, where you'll find the Authentication section.

Click on the "Set up sign-in method" button to move to the sign-in method pane. Enable the Email/Password and Google sign-in options. You'll need to set your public-facing app name and support email to do so.

In Xcode, you'll need to set up a URL scheme to handle Google sign-in. Copy the REVERSED_CLIENT_ID field from your GoogleService-Info.plist, and open up the URL Types pane in the Info section of your project settings:

URL Schemes

Add a new URL type and paste the reversed client ID into the URL Schemes field. We'll also need to write some code so our app knows how to handle that URL. We'll be using Firebase UI, so for us it's as simple as just telling Firebase to handle it. Add the following to your AppDelegate.swift:

import FirebaseUI

func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
    let sourceApplication = options[UIApplication.OpenURLOptionsKey.sourceApplication] as! String?
    if FUIAuth.defaultAuthUI()?.handleOpen(url, sourceApplication: sourceApplication) ?? false {
        return true
    }

    return false
}
Enter fullscreen mode Exit fullscreen mode

There are plenty of other sign-in options that you may want to allow, but we won't be covering them here.

Setting up the View

Storyboard Layout

In your Xcode project, update the Main.storyboard and add a NavigationController. We're going to be using a UICollectionView to manage our video streams, so update your root view controller by adding a UICollectionView with a custom UICollectionViewCell. We're keeping it simple, so all you need in this custom class is a view to show video:

class VideoCollectionViewCell: UICollectionViewCell {
    @IBOutlet weak var videoView: UIView!    
}
Enter fullscreen mode Exit fullscreen mode

We'll also need a bunch of buttons to handle all the user's actions:

@IBAction func didToggleMute(_ sender: Any) {

}

@IBAction func didTapSwitchCamera(_ sender: Any) {

}

@IBAction func didTapX(_ sender: Any) {

}

@IBAction func didTapInvite(_ sender: Any) {

}

@IBAction func didTapAddFriend(_ sender: Any) {

}
Enter fullscreen mode Exit fullscreen mode

Logging in with FirebaseUI

In this tutorial, we'll be using Firebase's built-in UI to handle sign-in for us. If you already have a login page, or simply want to be more flexible with your UI, you can find the docs for logging in programmatically with email and Google here and here, respectively.

We're going to be using FirebaseUI to log in to our app. We'll have our initial entry screen - our AgoraVideoViewController - handle showing the default FUIAuth View Controller. All we need to do is tell it what providers we want to allow, and who to tell when the user successfully logs in:

import FirebaseAuth
import FirebaseUI

extension AgoraVideoViewController: FUIAuthDelegate {
    func showFUIAuthScreen() {
        let authUI = FUIAuth.defaultAuthUI()
        authUI?.delegate = self

        let providers: [FUIAuthProvider] = [
            FUIGoogleAuth(),
            FUIEmailAuth()
        ]
        authUI?.providers = providers

        if let authViewController = authUI?.authViewController() {
            self.present(authViewController, animated: false)
        }
    }

    func authUI(_ authUI: FUIAuth, didSignInWith authDataResult: AuthDataResult?, error: Error?) {

    }
}

Enter fullscreen mode Exit fullscreen mode

We could call this function on startup, but it would get pretty annoying to have to log in every time we open the app. To solve this, we can use something provided to us by FirebaseAuth - an AuthStateDidChangeListener. It will tell us whenever the user's authentication state changes, and allow us to only show the login page if there's no user already logged in. Adding one is pretty simple:

var handle: AuthStateDidChangeListenerHandle?
var currentUser: User?

override func viewWillAppear(_ animated: Bool) {
    handle = Auth.auth().addStateDidChangeListener { (auth, user) in
        self.currentUser = user
        if user == nil {
            self.showFUIAuthScreen()
        }
    }
}

override func viewWillDisappear(_ animated: Bool) {
    if let handle = handle {
        Auth.auth().removeStateDidChangeListener(handle)
    }
}
Enter fullscreen mode Exit fullscreen mode

We now have a functional login page that will appear if the current user is nil.

Creating a User Database

Firebase will track our users for us - you can see this for yourself on the Authentication tab of the Firebase dashboard, once we implement signing in. However, this list of users isn't very useful to us. While we can get information from it about the currently logged-in user, it won't allow us to get any info about other users. We'll need our own database for that.

Go to the Database tab on the Firebase dashboard, and create a new Realtime Database. Start it in test mode for now, so we can easily modify it without having to worry about security while we're working on it. We could add data manually here, but it'll be easier to do it automatically in code.

Creating a Realtime Database

Adding users on login

Head back to our FUIAuthDelegate extension. We're going to make use of that didSignInWith callback to add a user to our database whenever they log in:

func authUI(_ authUI: FUIAuth, didSignInWith authDataResult: AuthDataResult?, error: Error?) {
    if let error = error {
        print(error.localizedDescription)
    } else {

        //Save the user to our list of users.
        if let user = authDataResult?.user {
            let ref = Database.database().reference()
            ref.child("users").child(user.uid).setValue(["username" : user.displayName?.lowercased(),
                                                         "displayname" : user.displayName,
                                                         "email": user.email])
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This code gets a reference to our main database, and adds an entry in a new "users" node. Each child of a node needs to have a unique key, so we use the unique UID Firebase gives us, and we store the user's email, their display name, and a lowercased version of their display name that will make it easier to search for it later.

Note that this code will overwrite our user node every time the user logs in. If you want to add additional fields to our user database, this code will need to be adjusted so it doesn't delete things.

Joining a Video Call

Once the user has logged in, they should automatically be placed into their own personal room so that their friends can drop by. To do that, we'll be using the Agora SDK to quickly and easily handle video calls for us.

Add Camera and Microphone Permissions

In order to use the microphone and camera, we’ll need to ask the user for permission to do so. In your Info.plist add the following keys:

Privacy - Microphone Usage Description
Privacy - Camera Usage Description
Enter fullscreen mode Exit fullscreen mode

Make sure you add a value for each. These values are user-facing, and will be displayed when the app asks for these permissions from the user.

Initialize the Agora Engine

In order to do anything with Agora, we need an AgoraRtcEngineKit object initialized with our appID (acquired from the Agora Developer Console). Let's add a helper function that will give us this object when we need it, creating it first if necessary.

import AgoraRtcEngineKit

let appID = "YourAppIDHere"
var agoraKit: AgoraRtcEngineKit?
let tempToken: String? = nil //If you have a token, put it here.
var userID: UInt = 0 //This tells Agora to generate an id for you. If you have unique user IDs already, you can use those.

...

private func getAgoraEngine() -> AgoraRtcEngineKit {
    if agoraKit == nil {
        agoraKit = AgoraRtcEngineKit.sharedEngine(withAppId: appID, delegate: self)
    }
    return agoraKit!
}
Enter fullscreen mode Exit fullscreen mode

Enable Video

Next, we need to tell Agora we want to enable the video, and set up the video configuration.

override func viewDidLoad() {
    super.viewDidLoad()
    // Do any additional setup after loading the view.    
    getAgoraEngine().setChannelProfile(.communication)

    setUpVideo()
}

func setUpVideo() {
    getAgoraEngine().enableVideo()
    let configuration = AgoraVideoEncoderConfiguration(size:
                        AgoraVideoDimension640x360, frameRate: .fps15, bitrate: 400,
                        orientationMode: .fixedPortrait)
    getAgoraEngine().setVideoEncoderConfiguration(configuration)
}
Enter fullscreen mode Exit fullscreen mode

Join a Call

We want users to join a call as soon as they log in. To make sure each one is placed into their own personal channel, we'll use their uid as the channel name.

Update your viewWillAppear and add a new function for joining a call:

var inCall = false
var callID: UInt = 0
var channelName: String?

override func viewWillAppear(_ animated: Bool) {
    handle = Auth.auth().addStateDidChangeListener { (auth, user) in
        self.currentUser = user
        if user == nil {
            self.showFUIAuthScreen()
        } else {
            self.joinChannel(channelName: user!.uid)
        }
    }
}

func joinChannel(channelName: String) {
    getAgoraEngine().joinChannel(byToken: tempToken, channelId: channelName, info: nil, uid: callID) { [weak self] (sid, uid, elapsed) in
        self?.inCall = true
        self?.callID = uid
        self?.channelName = channelName
    }
}
Enter fullscreen mode Exit fullscreen mode

Note: Agora uses UInt IDs to identify individual users within a call. Our Firebase UIDs won't work for this, because they're strings, and it's not important that they stay the same from call to call, so we just pass in 0 and save whatever Agora assigns us for later use.

Displaying Video

It's finally time to put our UICollectionView to good use. First, we're going to implement some Agora delegate functions so we can detect when other users join our call, and keep track of their in-call ids.

var remoteUserIDs: [UInt] = []

...

extension AgoraVideoViewController: AgoraRtcEngineDelegate {
    func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinChannel channel: String, withUid uid: UInt, elapsed: Int) {
        callID = uid
    }

    func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinedOfUid uid: UInt, elapsed: Int) {
        print("Joined call of uid: \(uid)")
        remoteUserIDs.append(uid)
        collectionView.reloadData()
    }

    func rtcEngine(_ engine: AgoraRtcEngineKit, didOfflineOfUid uid: UInt, reason: AgoraUserOfflineReason) {
        if let index = remoteUserIDs.firstIndex(where: { $0 == uid }) {
            remoteUserIDs.remove(at: index)
            collectionView.reloadData()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And then we need to set up our collection view cells to display video:

func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
    return remoteUserIDs.count + 1
}

func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "videoCell", for: indexPath)

    if indexPath.row == remoteUserIDs.count { //Put our local video last
        if let videoCell = cell as? VideoCollectionViewCell {
            let videoCanvas = AgoraRtcVideoCanvas()
            videoCanvas.uid = callID
            videoCanvas.view = videoCell.videoView
            videoCanvas.renderMode = .fit
            getAgoraEngine().setupLocalVideo(videoCanvas)
        }
    } else {
        let remoteID = remoteUserIDs[indexPath.row]
        if let videoCell = cell as? VideoCollectionViewCell {
            let videoCanvas = AgoraRtcVideoCanvas()
            videoCanvas.uid = remoteID
            videoCanvas.view = videoCell.videoView
            videoCanvas.renderMode = .fit
            getAgoraEngine().setupRemoteVideo(videoCanvas)
            print("Creating remote view of uid: \(remoteID)")
        }
    }

    return cell
}

func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {

    let numFeeds = remoteUserIDs.count + 1

    let totalWidth = collectionView.frame.width - collectionView.adjustedContentInset.left - collectionView.adjustedContentInset.right
    let totalHeight = collectionView.frame.height - collectionView.adjustedContentInset.top - collectionView.adjustedContentInset.bottom

    if numFeeds == 1 {
        return CGSize(width: totalWidth, height: totalHeight)
    } else if numFeeds == 2 {
        return CGSize(width: totalWidth, height: totalHeight / 2)
    } else {
        if indexPath.row == numFeeds {
            return CGSize(width: totalWidth, height: totalHeight / 2)
        } else {
            return CGSize(width: totalWidth / CGFloat(numFeeds - 1), height: totalHeight / 2)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Note: Make sure you set your view controller as a UICollectionViewDelegateFlowLayout. Learn from my mistakes.

Working With Friends

It's time to start keeping track of a user's friends. We're going to add two popover views to our app - one for searching for users to add as friends, and one for displaying a friends list that we can use to join other user's rooms.

Searching for Users

We're going to create a UserSearchViewController to search through our user database for possible new friends.

UserSearchViewController

We need a UISearchBar, and a UITableView to display the results. We'll make a very simple UserTableViewCell class for our cells:

class UserTableViewCell: UITableViewCell {

    @IBOutlet weak var displayName: UILabel!
    @IBOutlet weak var detailLabel: UILabel!

    override func awakeFromNib() {
        super.awakeFromNib()
        // Initialization code
    }

    override func setSelected(_ selected: Bool, animated: Bool) {
        super.setSelected(selected, animated: animated)

        // Configure the view for the selected state
    }

    override func prepareForReuse() {
        detailLabel.alpha = 0
    }

}
Enter fullscreen mode Exit fullscreen mode

Make sure to hook everything up to our UserSearchViewController class, and make sure to set the ViewController as the initial view controller. Then let's show our new View Controller when our user hits the "Add Friends" button. In our main AgoraVideoViewController class:

@IBAction func didTapAddFriend(_ sender: Any) {
    let storyboard = UIStoryboard(name: "UserSearchViewController", bundle: nil)
    let searchVC = storyboard.instantiateInitialViewController()!

    // Use the popover presentation style for your view controller.
    searchVC.modalPresentationStyle = .popover

    // Present the view controller (in a popover).
    self.present(searchVC, animated: true) {

    }
}
Enter fullscreen mode Exit fullscreen mode

We set up some basic stuff when we load:

import UIKit
import Firebase

class UserSearchViewController: UIViewController, UITableViewDelegate, UITableViewDataSource, UISearchBarDelegate {

    @IBOutlet weak var tableView: UITableView!

    var userRef: DatabaseReference!
    var friendsRef: DatabaseReference!
    var resultsArray = [[String:String]]()

    var handle: AuthStateDidChangeListenerHandle?
    var currentUser: User?

    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
        userRef = Database.database().reference(withPath: "users")
        friendsRef = Database.database().reference(withPath: "friends")
    }

    override func viewWillAppear(_ animated: Bool) {
        handle = Auth.auth().addStateDidChangeListener { (auth, user) in
            self.currentUser = user
            if user == nil {
                DispatchQueue.main.async {
                    self.dismiss(animated: true, completion: nil)
                }
            }
        }
    }

    override func viewWillDisappear(_ animated: Bool) {
        if let handle = handle {
            Auth.auth().removeStateDidChangeListener(handle)
        }
    }
Enter fullscreen mode Exit fullscreen mode

Most of our complex logic comes when the user actually searches:

func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
    resultsArray.removeAll()
    tableView.reloadData()
}

func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
    if let searchText = searchBar.text?.lowercased(), searchText != "" {
        resultsArray.removeAll()
        queryText(searchText, inField: "username")
    } else {
        let alert = UIAlertController(title: "Error", message: "Please enter a username.", preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil))
        present(alert, animated: true, completion: nil)
    }
}

func queryText(_ text: String, inField child: String) {
    userRef.queryOrdered(byChild: child)
        .queryStarting(atValue: text)
        .queryEnding(atValue: text+"\u{f8ff}")
        .observeSingleEvent(of: .value) { [weak self] (snapshot) in
            for case let item as DataSnapshot in snapshot.children {
                //Don't show the current user in search results
                if self?.currentUser?.uid == item.key {
                    continue
                }

                if var itemData = item.value as? [String:String] {
                    itemData["uid"] = item.key
                    self?.resultsArray.append(itemData)
                }
            }
            self?.tableView.reloadData()
    }
}

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

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "userCell", for: indexPath)

    if let userCell = cell as? UserTableViewCell {
        let userData = resultsArray[indexPath.row]
        userCell.displayName.text = userData["displayname"]
    }

    return cell
}
Enter fullscreen mode Exit fullscreen mode

When the user hits the search button, we perform a database query to return all users whose username starts with the text entered.

Tip: If you don't have multiple phones to test with, you can always add dummy users into your database directly in the Firebase console.

If you run your app and search for another user, they will now appear in your list! Very cool. However, you may also notice Firebase complaining at you in the console:

[Firebase/Database][I-RDB034028] Using an unspecified index. Your data will be downloaded and filtered on the client. Consider adding ".indexOn": "username" at /users to your security rules for better performance

This is Firebase telling us that it's not indexing our users by our search fields, because we haven't told it to. With as few users as we have now, it's not a big deal, but if we want to release to a large userbase, we should fix this. Fortunately, adding the rule is easy. Head to the Database tab in your Firebase dashboard, and open up your Rules. Add the .indexOn field to your users database and hit Publish:

Indexing on Username

Adding a Friend

Finally, we need to add a user to our friends list when the user selects them. To do that, we're going to create a "friends" database alongside our user database and add a node to it:

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    if let user = currentUser, let friendID = resultsArray[indexPath.row]["uid"] {
        friendsRef.child("\(user.uid)/\(friendID)").setValue("true")
        if let userCell = tableView.cellForRow(at: indexPath) as? UserTableViewCell {
            UIView.animate(withDuration: 0.2) {
                userCell.detailLabel.alpha = 1
            }
        }
    }

    tableView.deselectRow(at: indexPath, animated: true)
}
Enter fullscreen mode Exit fullscreen mode

Viewing Your Friends

Now that we have friends on our friends list, it's time to use that to join other people's rooms. Let's make one more view controller.

Join Friend

We need a UITableView and that's about it. We can even re-use our UserTableViewCell class. Again, we show it in a popover when the user taps a button. This time, though, we're going to create a protocol for our new view controller to tell our AgoraVideoViewController which friend's room we want to join.

@IBAction func didTapInvite(_ sender: Any) {
    let storyboard = UIStoryboard(name: "JoinFriendViewController", bundle: nil)
    let joinVC = storyboard.instantiateInitialViewController()!

    // Use the popover presentation style for your view controller.
    joinVC.modalPresentationStyle = .popover

    if let joinFriendVC = joinVC as? JoinFriendViewController {
        joinFriendVC.delegate = self
    }

    // Present the view controller (in a popover).
    self.present(joinVC, animated: true) {

    }
}
Enter fullscreen mode Exit fullscreen mode

And in our new class:

import UIKit
import Firebase

protocol JoinFriendViewControllerDelegate: NSObject {
    func didJoinFriend(uid: String)
}

class JoinFriendViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {

    @IBOutlet weak var tableView: UITableView!

    var userRef: DatabaseReference!
    var friendsRef: DatabaseReference!
    var resultsArray = [String]()

    var handle: AuthStateDidChangeListenerHandle?
    var currentUser: User?

    weak var delegate: JoinFriendViewControllerDelegate?

    override func viewDidLoad() {
        super.viewDidLoad()

        // Do any additional setup after loading the view.
        userRef = Database.database().reference(withPath: "users")
        friendsRef = Database.database().reference(withPath: "friends")
    }
Enter fullscreen mode Exit fullscreen mode

When the view appears, we'll get our usual reference to the current user, and use that to get each friend's ID:

override func viewWillAppear(_ animated: Bool) {
    handle = Auth.auth().addStateDidChangeListener { (auth, user) in
        self.currentUser = user
        self.resultsArray.removeAll()
        self.tableView.reloadData()
        if let user = user {
            //Create an observer that will let us know when friends are added.
            self.friendsRef.child(user.uid).observe(.childAdded) { (snapshot) in
                self.resultsArray.append(snapshot.key)
                self.tableView.insertRows(at: [IndexPath(row: self.resultsArray.count-1, section: 0)], with: UITableView.RowAnimation.none)

            }
        } else {
            DispatchQueue.main.async {
                self.dismiss(animated: true, completion: nil)
            }
        }
    }
}

override func viewWillDisappear(_ animated: Bool) {
    if let handle = handle {
        Auth.auth().removeStateDidChangeListener(handle)
    }
}
Enter fullscreen mode Exit fullscreen mode

And then we display each friend's username, and pass the ID back to the video view when selected:

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

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "userCell", for: indexPath)

    if let userCell = cell as? UserTableViewCell {
        let uid = resultsArray[indexPath.row]
        userRef.child(uid).child("displayname").observeSingleEvent(of: .value) { (snapshot) in
            userCell.displayName.text = snapshot.value as? String
        }
    }

    return cell
}

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    let uid = resultsArray[indexPath.row]

    delegate?.didJoinFriend(uid: uid)

    self.dismiss(animated: true, completion: nil)
}
Enter fullscreen mode Exit fullscreen mode

Finally, back in our AgoraVideoViewController we handle the message and change rooms accordingly.

func didJoinFriend(uid: String) {
    joinFriendCallWithUID(uid: uid)
}

func joinFriendCallWithUID(uid: String) {
    leaveChannel()
    joinChannel(channelName: uid)
}

func leaveChannel() {
    getAgoraEngine().leaveChannel(nil)
    inCall = false
    remoteUserIDs.removeAll()
    collectionView.reloadData()
}
Enter fullscreen mode Exit fullscreen mode

Locking Rooms

Sometimes, a user wants to set his own virtual room to become a “private” room so that no one else can join his room except those who are already in the room. So how should we achieve that?

First, we'll need to know whether the current room belongs to us. Then, we'll allow the host to lock the room with a button, and save that state to the database.

var isLocalCall = true {
    didSet {
        updateLockTitle()
    }
}
var callLocked = false {
    didSet {
        updateLockTitle()
    }
}

func updateLockTitle() {
    if isLocalCall {
        if callLocked {
            lockButton.setTitle("Unlock", for: .normal)
        } else {
            lockButton.setTitle("Lock", for: .normal)
        }
    } else {
        lockButton.setTitle("Exit", for: .normal)
    }
}
Enter fullscreen mode Exit fullscreen mode

Add the following line to the success callback of joinChannel:

self?.isLocalCall = channelName == self?.currentUser?.uid
Enter fullscreen mode Exit fullscreen mode

And then implement the handler for the button:

@IBAction func didTapX(_ sender: Any) {
    if inCall {
        if (isLocalCall) {
            //Toggle lock on the room
            callLocked = !callLocked
            if (callLocked) {
                userRef.child("\(currentUser!.uid)/locked").setValue("true")
            } else {
                userRef.child("\(currentUser!.uid)/locked").setValue("false")
            }
        } else {
            leaveChannel()
            if let user = currentUser {
                joinChannel(channelName: user.uid)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Our channels can now be locked and that state is saved. But it doesn't do anything yet. We need to check whether a room is locked when we try to join it. In our JoinFriendViewController let's first show the user if a room is locked by adding the following to cellForRowAt:

userRef.child(uid).child("locked").observe(.value) { (snapshot) in
    if let lockState = snapshot.value as? String, lockState == "true" {
        userCell.detailLabel.alpha = 1
    } else {
        userCell.detailLabel.alpha = 0
    }
}
Enter fullscreen mode Exit fullscreen mode

Unlike with observeSingleEvent, the observe function will be called whenever the value being observed changes. Which is great for us, because it means the cell will automatically update as soon as any of our friends locks or unlocks their room.

We can then update our didSelectRowAt function to show an alert if the user tries to join a locked room:

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
  let uid = resultsArray[indexPath.row]

  userRef.child(uid).child("locked").observeSingleEvent(of: .value) { (snapshot) in
      if let lockState = snapshot.value as? String, lockState == "true" {
          DispatchQueue.main.async {
              self.tableView.deselectRow(at: indexPath, animated: true)
              let alert = UIAlertController(title: "Locked", message: "That user's room is currently locked.", preferredStyle: .alert)
              alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil))
              self.present(alert, animated: true, completion: nil)
          }
      } else {
          self.delegate?.didJoinFriend(uid: uid)

          self.dismiss(animated: true, completion: nil)
      }
  }
}
Enter fullscreen mode Exit fullscreen mode

And that's all there is to it!

Adding Text Chat

We’re going to use Agora’s Real-Time Messaging (RTM) SDK in order to allow users to chat with each other while they’re in a video call. First, let’s set up the AgoraRtmKit.

var agoraRtm: AgoraRtmKit?

override func viewDidLoad() {
    ...

    agoraRtm = AgoraRtmKit.init(appId: appID, delegate: self)
}

override func viewWillAppear(_ animated: Bool) {
    handle = Auth.auth().addStateDidChangeListener { (auth, user) in
        self.currentUser = user
        if let user = user {
            self.joinChannel(channelName: user.uid)
            self.agoraRtm?.login(byToken: nil, user: user.displayName ?? user.uid) { (error) in
                if (error != .ok) {
                    print("Failed to login to RTM: ", error.rawValue)
                }
            }
        } else {
            self.agoraRtm?.logout(completion: nil)
            self.showFUIAuthScreen()
        }
    }
}

...

extension AgoraVideoViewController: AgoraRtmDelegate {
    func rtmKit(_ kit: AgoraRtmKit, connectionStateChanged state: AgoraRtmConnectionState, reason: AgoraRtmConnectionChangeReason) {
        if state == .connected {
            chatButton.isEnabled = true
        } else {
            chatButton.isEnabled = false
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

We use the same Agora app ID we already have, and then we login and logout of RTM when our user does. We also set up the AgoraRtmDelegate protocol. We’re only going to use it to make sure our chat button isn’t clickable until we’ve finished logging in.

For the chat room itself, we’ll need one final view controller.

Chat View Controller

We need a UITextField to send messages, and a table view to display them. We again have a very simple cell:

class ChatTableViewCell: UITableViewCell {

    @IBOutlet weak var messageLabel: UILabel!

}
Enter fullscreen mode Exit fullscreen mode

Like before, we’re going to present this view as a popover when the user hits the ‘Chat’ button.

Joining a Text Channel

In our ChatViewController, we’re going to do some very familiar setup to make sure we have a user at all times, and then join an RTM channel in viewWillAppear:

class ChatViewController: UIViewController, UITableViewDelegate, UITableViewDataSource, UITextFieldDelegate {

    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var textField: UITextField!

    weak var agoraRtm: AgoraRtmKit?
    var channelName: String?
    var channel: AgoraRtmChannel?

    var handle: AuthStateDidChangeListenerHandle?
    var currentUser: User?

    var messageList: [String] = []

    override func viewWillAppear(_ animated: Bool) {
        handle = Auth.auth().addStateDidChangeListener { (auth, user) in
            self.currentUser = user
            if user != nil, let channelName = self.channelName {
                self.channel = self.agoraRtm?.createChannel(withId: channelName, delegate: self)
                self.channel?.join(completion: { (error) in
                    if error != .channelErrorOk {
                        print("Error joining channel: ", error.rawValue)
                    }
                })
            } else {
                DispatchQueue.main.async {
                    self.dismiss(animated: true, completion: nil)
                }
            }
        }
    }

    override func viewWillDisappear(_ animated: Bool) {
        if let handle = handle {
            Auth.auth().removeStateDidChangeListener(handle)
        }
        if let channel = self.channel {
            channel.leave(completion: nil)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The createChannel method will create a channel if one doesn’t exist, or join an existing one if it does. Like with Agora video channels, everyone that joins a channel with the same name will be able to chat with each other.

Showing Messages

In order to display a conversation, we need to do two things: Send messages when the local user types them, and receive messages from everyone else.

var messageList: [String] = []

func addMessage(user: String, message: String) {
    let message = "\(user): \(message)"
    messageList.append(message)
    let indexPath = IndexPath(row: self.messageList.count-1, section: 0)
    self.tableView.insertRows(at: [indexPath], with: UITableView.RowAnimation.automatic)
    self.tableView.scrollToRow(at: indexPath, at: .bottom, animated: true)
}

func textFieldShouldReturn(_ textField: UITextField) -> Bool {
    if let text = textField.text, text != "" {
        channel?.send(AgoraRtmMessage(text: text), completion: { (error) in
            if error != .errorOk {
                print("Failed to send message: ", error)
            } else {
                self.addMessage(user: self.currentUser!.displayName ?? self.currentUser!.uid, message: text)
            }
        })
        textField.text = ""
    }
    return true
}

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

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    let cell = tableView.dequeueReusableCell(withIdentifier: "chatCell", for: indexPath)

    if let chatCell = cell as? ChatTableViewCell {
        let message = messageList[indexPath.row]
        chatCell.messageLabel.text = message
    }

    return cell
}

...

extension ChatViewController: AgoraRtmChannelDelegate {
    func channel(_ channel: AgoraRtmChannel, messageReceived message: AgoraRtmMessage, from member: AgoraRtmMember) {
        addMessage(user: member.userId, message: message.text)
    }
}
Enter fullscreen mode Exit fullscreen mode

Whenever the user sends or receives a message, we add it to our message data, insert a new table row, and then scroll to the bottom to make sure we see it.

Handling the Keyboard

If you try to test the app now, you’ll notice an immediate problem: Our text field is at the bottom of the screen, and the keyboard covers it up when you select it. Let’s fix that.

@IBOutlet weak var bottomConstraint: NSLayoutConstraint!

override func viewDidLoad() {
    super.viewDidLoad()

    // Do any additional setup after loading the view.
    NotificationCenter.default.addObserver(self, selector: #selector(ChatViewController.keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil)
    NotificationCenter.default.addObserver(self, selector: #selector(ChatViewController.keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil)
    textField.becomeFirstResponder()
}

@objc func keyboardWillShow(notification: NSNotification) {
    guard let userInfo = notification.userInfo else { return }
    guard let keyboardSize = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue else { return }

    let keyboardFrame = keyboardSize.cgRectValue

    bottomConstraint.constant = 20 + keyboardFrame.height
}

@objc func keyboardWillHide(notification: NSNotification) {
    bottomConstraint.constant = 20
}
Enter fullscreen mode Exit fullscreen mode

Here, we add a reference to the NSLayoutConstraint attaching the text field to the bottom of the screen. Using Notification Center, we can then find out when the keyboard is shown or hidden, and adjust how far from the bottom of the screen our text field is automatically.

Finishing Touches

Finally, let's add the functionality to our final two buttons, both of which are a simple call to an Agora API. To switch the camera:

@IBAction func didTapSwitchCamera(_ sender: Any) {
    getAgoraEngine().switchCamera()
}
Enter fullscreen mode Exit fullscreen mode

And to mute our local audio:

var muted = false {
    didSet {
        if muted {
            muteButton.setTitle("Unmute", for: .normal)
        } else {
            muteButton.setTitle("Mute", for: .normal)
        }
    }
}

@IBAction func didToggleMute(_ sender: Any) {
    muted = !muted
    getAgoraEngine().muteLocalAudioStream(muted)
}
Enter fullscreen mode Exit fullscreen mode

Congratulations

If you've made it this far, congratulations! You have a working social app. The best way to test it is to build it and run on multiple phones, but you can also use Agora's web demo to substitute as additional users.

Thank you for following along. If you want to see more features such as push notifications or presence tracking, please leave a comment below! If you have any questions, you can also reach out via email at devrel@agora.io.

💖 💪 🙅 🚩
zontan
zontan

Posted on July 9, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related