Skip to content
Building Scalable UI for iOS using Agora - Featured

Building Scalable UI for iOS using Agora

By Author: Max Cobb In Developer

One of the biggest challenges facing any developer is building applications that can scale. With a video-conferencing application using Agora the main scaling issue is the bandwidth of your local device, especially if there are many incoming video streams. And as the number of participants rises, it becomes increasingly difficult to make sure that your application can keep up.

In this tutorial, you will see how to use Agora to build an application that can scale to up to 17 users by optimizing the bandwidth usage of the incoming video streams.


  • An Agora developer account (see How to Get Started with Agora)
  • Xcode 9.0 or later
  • iOS device with minimum iOS 10.0
  • A basic understanding of iOS development
  • CocoaPods


Create an iOS project in Xcode, then install the CocoaPod AgoraRtcEngine_iOS:

target 'Your App' do
pod 'AgoraRtcEngine_iOS', '3.3.0'

Run pod init, and open the .xcworkspace file to get started.

Connecting to Agora

As a first step in this app, we request permission for accessing the camera and microphone, to get that out of the way.

First, add both NSCameraUsageDescription and NSMicrophoneUsageDescription to Info.plist, along with text descriptions. See here for more information on these, along with code samples of how to request and get authorisation states.

Next we are going to get connected to Agora using the already installed SDK. Import AgoraRtcKit at the top of your swift file to get started, then add the following code to your ViewController:

class ViewController: UIViewController {
/// local user ID, initially set as zero.
var myUserID: UInt = 0

/// videoUsers is a list of all joined members
var videoUsers: [UInt] = []
/// usersCanvasMap finds the AgoraRtcVideoCanvas for a user ID
var usersCanvasMap: [UInt: AgoraRtcVideoCanvas] = [:]

/// collectionView will display our camera streams later
var collectionView: UICollectionView?
/// agkit defines our AgoraRtcEngineKit
var agkit: AgoraRtcEngineKit {
// the next line will fail if ViewController
// does not have the AgoraRtcEngineDelegate protocol
let agoraEngine = AgoraRtcEngineKit.sharedEngine(
withAppId: "<#App ID#>",
delegate: self
// We are using the live broadcasting mode for this example
// dual stream mode is essential for scaling this application
{ "": {
return agoraEngine

override func viewDidLoad() {
// Uncomment the next line once you get to the UI layout section
// self.setupViews()
// Connect to a channel named "test"
byToken: nil, channelId: "test", info: nil, uid: 0
) { _, uid, _ in
// set the user ID that Agora has assigned this user
self.myUserID = uid

In the above code snippet, we set up the Agora engine with dual-stream mode enabled, and with the low-bitrate stream being very small and only 5 frames per second. This is the key part of making our app scalable to 17 users. A lower bitrate means less traffic will be coming in for many of the users streaming to us, and we can optimise which users take up more bandwidth.

Now, we will add some of the delegate methods, which will keep track of our and others’ state in the Agora RTC channel:

extension ViewController: AgoraRtcEngineDelegate {
func rtcEngine(
_ engine: AgoraRtcEngineKit,
remoteVideoStateChangedOfUid uid: UInt,
state: AgoraVideoRemoteState,
reason: AgoraVideoRemoteStateReason,
elapsed: Int
) {
switch state {
case .decoding, .starting:
if self.usersCanvasMap[uid] == nil {
// One canvas is created for each remote user
let newCanvas = AgoraRtcVideoCanvas()
newCanvas.uid = uid
newCanvas.view = UIView()
// Records of each user's canvas is added
self.usersCanvasMap[uid] = newCanvas
// userIDs are kept in an array, because the
// order of a dictionary is not guaranteed.
} else {
usersCanvasMap[uid]?.view?.isHidden = false
case .stopped: usersCanvasMap[uid]?.view?.isHidden = true
default: break

func rtcEngine(
_ engine: AgoraRtcEngineKit, didOfflineOfUid uid: UInt,
reason: AgoraUserOfflineReason
) {
usersCanvasMap[uid]?.view = nil
usersCanvasMap.removeValue(forKey: uid)
self.videoUsers.removeAll{ $0 == uid }

Above, we are using a list of user IDs to keep track of all users whose videos are currently streaming to us, and creating canvases to render all the video streams. We are also calling self.collectionView?.reloadData() whenever the items in videoUsers and usersCanvasMap change, to update the UI once it is created below.

However, we are missing our local user stream, so add the following to the joinChannel callback:

let localCanvas = AgoraRtcVideoCanvas()
localCanvas.uid = self.myUserID
localCanvas.view = UIView()
self.usersCanvasMap[self.myUserID] = localCanvas

In the next section, we will see how to take those video canvases to display them in a UICollectionView, and choose which users are assigned the higher- or lower-quality streams.

Setting Up the UI for Scale

There will be only one screen to our application, where all the streams in the channel will be shown.

We start by adding a UICollectionView, which will contain all our video streams, and we’ll scroll through them vertically. For this example, we will have the collection view fill the entire screen, leaving room at the bottom for some simple buttons for pausing the camera, muting the microphone, etc.

extension ViewController {
func setupViews() {
let flowLayout = UICollectionViewFlowLayout()
flowLayout.scrollDirection = .vertical

// Each collection view item will be a square, which is
// just a little bit smaller than half the width
// of our parent view.
flowLayout.itemSize = CGSize(
width: self.view.bounds.width / 2 - 40,
height: self.view.bounds.width / 2 - 40
flowLayout.sectionInset = UIEdgeInsets(
top: 10, left: 10, bottom: 10, right: 10
let scrollView = UICollectionView(
frame: .zero, collectionViewLayout: flowLayout
scrollView.frame = self.view.bounds

// leaving room for buttons at the bottom
scrollView.frame.size.height -= 50
scrollView.autoresizingMask = [.flexibleWidth, .flexibleHeight]

self.collectionView = scrollView

With this UICollectionView, we are going to define a custom UICollectionViewCell and make sure to register it with the collection view. We will also set the dataSource and delegate for this UICollectionView:

/// Item in the collection view to contain the user's video feed.
class AgoraCollectionItem: UICollectionViewCell {
/// View for the video frame.
var canvas: AgoraRtcVideoCanvas?

var uid: UInt? { self.canvas?.uid }

override init(frame: CGRect) {
super.init(frame: frame)
self.backgroundColor = .systemGray
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")

// Add to the first snippet
extension ViewController {
// ...

func setupViews() {
// ...
forCellWithReuseIdentifier: "collectionItem"
scrollView.dataSource = self
scrollView.delegate = self
// ...
// ...

Above, the delegate and dataSource have been set, and the AgoraCollectionItem has been declared and registered with the identifier "collectionItem".

Next, we set the dataSource protocol to the viewController. There are two required methods in the UICollectionViewDataSource protocol: one for declaring the number of items in a section and one for getting an instance of a cell. We have only one section, so just return the videoUsers.count for the number of items and dequeue a reusable cell for the other method. The deque method either creates a new cell or fetches a recycled one from the UICollectionView class instance:

extension ViewController: UICollectionViewDataSource {
func collectionView(
_ collectionView: UICollectionView,
numberOfItemsInSection section: Int
) -> Int {

func collectionView(
_ collectionView: UICollectionView,
cellForItemAt indexPath: IndexPath
) -> UICollectionViewCell {
return collectionView.dequeueReusableCell(
withReuseIdentifier: "collectionItem", for: indexPath
) as! AgoraCollectionItem

Using two delegate methods from UICollectionViewDelegate ( willDisplay didEndDisplaying ), we can determine which cell is on-screen at any time, and thus base what bitrate to set the videos to:

extension ViewController: UICollectionViewDelegate {
func collectionView(
_ collectionView: UICollectionView,
willDisplay cell: UICollectionViewCell,
forItemAt indexPath: IndexPath
) {
guard let agoraCell = cell as? AgoraCollectionItem else {
fatalError("cell is not agoracollectionitem")
let videoUID = self.videoUsers[indexPath.row]
guard let canvas = self.usersCanvasMap[videoUID],
let canvasView = canvas.view else {
fatalError("could not get canvas and view for \(videoUID)")
// set the cell's new canvas and set the view
agoraCell.canvas = canvas
canvasView.frame = agoraCell.bounds
canvasView.autoresizingMask = [.flexibleWidth, .flexibleHeight]

if videoUID != self.myUserID {
// if not local user, set the stream to high quality
self.agkit.setRemoteVideoStream(videoUID, type: .high)

func collectionView(
_ collectionView: UICollectionView,
didEndDisplaying cell: UICollectionViewCell,
forItemAt indexPath: IndexPath
) {
guard let agoraCell = cell as? AgoraCollectionItem,
let userID = agoraCell.canvas?.uid else {
fatalError("cell is not agoracollectionitem")
// release rendering camera view from its parent
agoraCell.canvas = nil
if userID != self.myUserID {
// cell is moving off screen, only receive low bitrate
// from this remote user.
self.agkit.setRemoteVideoStream(userID, type: .low)

This is the most important part of the example. Ensuring that we only receive a high-quality stream for users who are on-screen means that we are optimising the bandwidth that our device is capable of, by prioritising the most relevant streams.

At this point, we are connected to the channel, and all our video feeds will appear in a scrolling grid formation, with a small gap at the bottom, where a button will be placed:

Building scaleable ui for ios using agora - Screenshot #1

Leaving the Channel

Let’s add a button at the bottom of our page to leave and then rejoin the call.

extension ViewController {
func addJoinLeave() {

// Create and style the button
let btn = UIButton(type: .roundedRect)
btn.setTitle("Leave", for: .normal)
btn.backgroundColor = .systemRed
btn.backgroundColor = .secondarySystemBackground
btn.layer.cornerRadius = 10

// set the button action
self, action: #selector(leaveChannel),
for: .touchUpInside

// Positioning the button
btn.translatesAutoresizingMaskIntoConstraints = false
equalTo: self.view.safeAreaLayoutGuide.widthAnchor
).isActive = true
btn.heightAnchor.constraint(equalToConstant: 50).isActive = true
equalTo: self.view.safeAreaLayoutGuide.bottomAnchor
).isActive = true

// reference to leaveButton to be added to ViewController
self.leaveButton = btn

When leaving the channel; we need to clear all the canvases and the views they are sending video frames to, remove them from the UICollectionView, as well as telling the Agora engine that we wish to leave the channel. This is an example of that logic in this case:

extension ViewController {
@objc func leaveChannel() {
if self.agkit.leaveChannel() == 0 {
for (_, item) in usersCanvasMap.enumerated() {
item.value.view = nil

self.leaveButton?.isHidden = true
Building scaleable ui for ios using agora - Screenshot #2


You now have a video chat application that can scale to up to 17 users by optimising settings for incoming streams.

You can find a complete application using all of the above code on GitHub:

Building scaleable ui for ios using agora - Screenshot #3

Other Resources

For more information about building applications using SDKs, take a look at the Agora Video Call Quickstart Guide and Agora API Reference.

I also invite you to join the Agora Developer Slack community.