Creating a subsession (breakout room) in Zoom Video SDK for iOS

The Zoom Video SDK gives you access to splits the main session into multiple smaller independent sessions known as subsessions (similar to the Zoom Meeting SDK's breakout room). Participants in the main session can choose to join any one of the subsessions available and later on return back to the main session.

The iOS (UIKit) quickstart blog covered integrating the Zoom Video SDK with audio, video, and basic in-session controls. If you haven’t completed that guide, start there first.

This post builds on the same UIKit sample app to show how to:

  • Understand the 3 roles for subsession
  • (Host) Configuring the subsession prelist
  • (Manager) Managing subsession
  • (Participant) Joining/Leave subsession and other control.

The result is to introduce subsession to split up participants into smaller sessions to have their own discussion/gathering. The completed project is available on GitHub.

Subsession control 1

Subsession control 2

SDK contents

The Zoom Video SDK for iOS includes several XCFramework bundles located under /Sample-Libs/lib. You only need to include the frameworks required for your specific use case.

Required for this tutorial:

  • ZoomVideoSDK.xcframework
  • ZoomTask.xcframework

We will use the same XCFrameworks from the UIKit quickstart sample app. No additional Zoom libraries are required for this implementation.

Subsession roles

There are 3 subsession roles (host, manager and participant) and it's the same with the roles in main session.

For the host role, the functions available are as followed:

  • Add subsessions to prelist
  • Get the prelist
  • Remove 1 or many subsessions from the prelist created
  • Clear the entire prelist
  • Commit (save) the created prelist
  • Get the committed prelist
  • Withdraw (destory) the committed prelist

For the manager role, the functions available are as followed:

  • Start/Stop subsession
  • Broadcast a message to all subsessions

For the participant role, the functions available are as followed:

  • Join into a specific subsession
  • Return to main session
  • Request for host's help

It is important to know that a host also has access to the manager and participant control while the manager has access to the participant control.

Add subsession to bottom navigation bar

Before we allow participants to join into any of the available subsessions, let's add the subsession buttons and the necessary changes into SessionViewController.swift and SessionViewControllerExtension.swift

Device A showing a red-tinted video stream

/// Under SessionViewControllerExtension.swift, add/update the following changes
private func setupTabBar() {
    tabBar.delegate = self
    tabBar.isHidden = true
    let subsessionBarItem = UITabBarItem(title: "Subsessions", image: UIImage(systemName: "rectangle.3.group"), tag: ControlOption.subsession.rawValue)
    let leaveSessionBarItem = UITabBarItem(title: "Leave Session", image: UIImage(systemName: "phone.down"), tag: ControlOption.leaveSession.rawValue)
    tabBar.items = [toggleVideoBarItem, toggleAudioBarItem, subsessionBarItem, leaveSessionBarItem]
}
/// Under SessionViewController.swift, add/update the following changes
enum ControlOption: Int {
    case toggleVideo, toggleAudio, subsession, leaveSession // Add subsession
}
extension SessionViewController: UITabBarDelegate {
    func tabBar(_ tabBar: UITabBar, didSelect item: UITabBarItem) {
        tabBar.selectedItem = nil
        switch item.tag {
        case ControlOption.toggleVideo.rawValue:
            handleVideoToggle(tabBar)
        case ControlOption.toggleAudio.rawValue:
            handleAudioToggle(tabBar)
        case ControlOption.subsession.rawValue: // Add subsession
            handleSubsessionToggle()
        case ControlOption.leaveSession.rawValue:
            // Let's also add a isInSubSession check to see if user can leave session
            if ZoomVideoSDK.shareInstance()?.getSession()?.getMySelf()?.isInSubSession() == true {
                showError(message: "You must leave the subsession before leaving the session.", dismiss: false)
                return
            }
            tabBar.isUserInteractionEnabled = false
            ZoomVideoSDK.shareInstance()?.leaveSession(false)
        default:
            break
        }
    }
    // ...
    private func handleSubsessionToggle() {
        let subsessionVC = SubsessionViewController()
        subsessionVC.subsessionManager = subsessionManager
        subsessionVC.subsessionParticipant = subsessionParticipant
        subsessionVC.onRequireSessionVideoRefresh = { [weak self] in
            self?.scheduleVideoCanvasRefresh(forceRestartLocalVideo: true, refreshRemoteUsers: true, minInterval: 0.5)
        }
        let navController = UINavigationController(rootViewController: subsessionVC)
        navController.modalPresentationStyle = .fullScreen
        present(navController, animated: true)
    }
}

Host: Configuring the subsession prelist

Next, we will create the UI and logic in 2 new files SubsessionViewController.swift and SubsessionViewControllerExtension.swift. We will be creating table view of the entire available subsessions features and grouped it accordingly to status info, committed subsession, pre-list management, manager controls and lastly participant controls.

We will first focus on the host controls since the prelist management is the very first thing of a subsession.

/// SubsessionViewController.swift
class SubsessionViewController: UIViewController {
    // MARK - Properties
    var tableView: UITableView = .init(frame: .zero, style: .insetGrouped)
    var committedSubsessions: [ZoomVideoSDKSubSessionKit] = []
    var preListNames: [String] = []
    var currentStatus: ZoomVideoSDKSubSessionStatus = .none
    var subsessionManager: ZoomVideoSDKSubSessionManager?
    var subsessionParticipant: ZoomVideoSDKSubSessionParticipant?
    var previousDelegate: ZoomVideoSDKDelegate?
    var onRequireSessionVideoRefresh: (() -> Void)?
    var currentSubSession: ZoomVideoSDKSubSessionKit?
    // MARK - Helper Methods
    var subsessionHelper: ZoomVideoSDKSubSessionHelper? {
        ZoomVideoSDK.shareInstance()?.getsubSessionHelper()
    }
    var session: ZoomVideoSDKSession? {
        ZoomVideoSDK.shareInstance()?.getSession()
    }
    var myUser: ZoomVideoSDKUser? {
        session?.getMySelf()
    }
    var isHost: Bool {
        myUser?.isHost() ?? false
    }
    var isManager: Bool {
        subsessionManager != nil
    }
    var isInSubSession: Bool {
        myUser?.isInSubSession() ?? false
    }
    // MARK: - Lifecycle
    override func viewDidLoad() {
        super.viewDidLoad()
        title = "Subsessions"
        view.backgroundColor = .systemGroupedBackground
        setupUI()
    }
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        previousDelegate = ZoomVideoSDK.shareInstance()?.delegate
        ZoomVideoSDK.shareInstance()?.delegate = self
        reloadHandles()
        reloadData()
    }
    // MARK: - Data
    func reloadData() {
        let freshList = subsessionHelper?.getCommittedSubSessionList() ?? []
        if !freshList.isEmpty {
            committedSubsessions = freshList
        } else if committedSubsessions.isEmpty {
            if let sessionVC = previousDelegate as? SessionViewController,
               !sessionVC.committedSubsessions.isEmpty {
                committedSubsessions = sessionVC.committedSubsessions
            }
        }
        preListNames = subsessionHelper?.getSubSessionPreList() ?? []
        if currentStatus == .none && !committedSubsessions.isEmpty {
            // If the current user is in a subsession, or any subsession already has users,
            // the rooms must be active (started). Otherwise they are committed but not started.
            let subsessionsAreActive = isInSubSession || committedSubsessions.contains {
                ($0.getSubSessionUserList()?.isEmpty == false)
            }
            currentStatus = subsessionsAreActive ? .started : .committed
        }
        if isInSubSession && currentSubSession == nil {
            if let id = (previousDelegate as? SessionViewController)?.currentSubSessionID {
                currentSubSession = committedSubsessions.first { $0.getSubSessionID() == id }
            }
            if currentSubSession == nil, let myName = myUser?.getName() {
                currentSubSession = committedSubsessions.first {
                    $0.getSubSessionUserList()?.contains(where: { $0.getName() == myName }) ?? false
                }
            }
        }
        tableView.reloadData()
    }
    func reloadHandles() {
        if let sessionVC = previousDelegate as? SessionViewController {
            if subsessionManager == nil {
                subsessionManager = sessionVC.subsessionManager
            }
            if subsessionParticipant == nil {
                subsessionParticipant = sessionVC.subsessionParticipant
            }
            // Seed the known-good status and list that SessionViewController captured via its
            // onSubSessionStatusChanged callback, so we start with accurate state before the
            // inference heuristic runs in reloadData().
            if currentStatus == .none && sessionVC.subsessionStatus != .none {
                currentStatus = sessionVC.subsessionStatus
            }
            if committedSubsessions.isEmpty && !sessionVC.committedSubsessions.isEmpty {
                committedSubsessions = sessionVC.committedSubsessions
            }
        }
    }
    // MARK: - Helper UI alerts
    func showResult(_ error: ZoomVideoSDKError, action: String) {
        let success = error == .Errors_Success
        let alert = UIAlertController(
            title: success ? "Success" : "Failed",
            message: "\(action) \(success ? "succeeded" : "failed: \(error.rawValue)")",
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in self.reloadData() })
        present(alert, animated: true)
    }
    func showInfo(_ message: String) {
        let alert = UIAlertController(title: "Info", message: message, preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "OK", style: .default))
        present(alert, animated: true)
    }
    // MARK: - Pre-list Management (Host Only)
    func handleAddToPreList() {
        let alert = UIAlertController(title: "Add Sub-Sessions", message: "Enter room names (comma-separated)", preferredStyle: .alert)
        alert.addTextField { $0.placeholder = "e.g. Room A, Room B, Room C" }
        alert.addAction(UIAlertAction(title: "Add", style: .default) { _ in
            guard let input = alert.textFields?.first?.text, !input.isEmpty else { return }
            let names = input.components(separatedBy: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
            guard !names.isEmpty else { return }
            let error = self.subsessionHelper?.addSubSession(toPreList: names) ?? .Errors_Wrong_Usage
            self.showResult(error, action: "Add \(names.count) room(s) to pre-list")
        })
        alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
        present(alert, animated: true)
    }
    func handleRemoveFromPreList() {
        guard !preListNames.isEmpty else {
            showInfo("The pre-list is empty.")
            return
        }
        let alert = UIAlertController(title: "Remove from Pre-list", message: "Select a room to remove", preferredStyle: .actionSheet)
        for name in preListNames {
            alert.addAction(UIAlertAction(title: name, style: .destructive) { _ in
                let error = self.subsessionHelper?.removeSubSession(fromPreList: [name]) ?? .Errors_Wrong_Usage
                self.showResult(error, action: "Remove '\(name)' from pre-list")
            })
        }
        alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
        present(alert, animated: true)
    }
    func handleClearPreList() {
        guard !preListNames.isEmpty else {
            showInfo("The pre-list is already empty.")
            return
        }
        let alert = UIAlertController(title: "Clear Pre-list", message: "Remove all \(preListNames.count) room(s) from the pre-list?", preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "Clear", style: .destructive) { _ in
            let error = self.subsessionHelper?.clearSubSessionPreList() ?? .Errors_Wrong_Usage
            self.showResult(error, action: "Clear pre-list")
        })
        alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
        present(alert, animated: true)
    }
    func handleViewPreList() {
        preListNames = subsessionHelper?.getSubSessionPreList() ?? []
        let message = preListNames.isEmpty
            ? "The pre-list is empty. Use 'Add Sub-Sessions' to add subsessions."
            : "Subsessions in pre-list (\(preListNames.count)):\n\n" + preListNames.enumerated().map { "\($0.offset + 1). \($0.element)" }.joined(separator: "\n")
        showInfo(message)
    }
    func handleCommitSubSessionList() {
        guard !preListNames.isEmpty else {
            showInfo("The pre-list is empty. Add room names first.")
            return
        }
        let alert = UIAlertController(
            title: "Commit Sub-Session List",
            message: "This will create \(preListNames.count) subsession(s):\n\n" + preListNames.joined(separator: ", "),
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "Commit", style: .default) { _ in
            let error = self.subsessionHelper?.commitSubSessionList() ?? .Errors_Wrong_Usage
            self.showResult(error, action: "Commit sub-session list")
        })
        alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
        present(alert, animated: true)
    }
    func handleWithdrawSubSessionList() {
        guard !committedSubsessions.isEmpty else {
            showInfo("No committed sub-sessions to withdraw.")
            return
        }
        let alert = UIAlertController(
            title: "Withdraw Sub-Session List",
            message: "This will remove all \(committedSubsessions.count) committed subsession(s).",
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "Withdraw", style: .destructive) { _ in
            let error = self.subsessionHelper?.withdrawSubSessionList() ?? .Errors_Wrong_Usage
            self.showResult(error, action: "Withdraw sub-session list")
        })
        alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
        present(alert, animated: true)
    }
}

Manager: Managing subsession

Next, let's add the manager control.

/// SubsessionViewController.swift, under host controls
// MARK: - Manager Controls
func handleStartSubSession() {
    guard let manager = subsessionManager else {
        showInfo("No manager handle available. This is delivered via the 'onSubSessionManagerHandle' callback after committing rooms.")
        return
    }
    let alert = UIAlertController(title: "Start Sub-Sessions", message: "Open all subsessions and move assigned users?", preferredStyle: .alert)
    alert.addAction(UIAlertAction(title: "Start", style: .default) { _ in
        let error = manager.startSubSession()
        self.showResult(error, action: "Start sub-sessions")
    })
    alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
    present(alert, animated: true)
}
func handleStopSubSession() {
    guard let manager = subsessionManager else {
        showInfo("No manager handle available.")
        return
    }
    let alert = UIAlertController(title: "Stop Sub-Sessions", message: "Close all subsessions and return everyone to the main session?", preferredStyle: .alert)
    alert.addAction(UIAlertAction(title: "Stop", style: .destructive) { _ in
        let error = manager.stopSubSession()
        self.showResult(error, action: "Stop sub-sessions")
    })
    alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
    present(alert, animated: true)
}
func handleBroadcastMessage() {
    guard let manager = subsessionManager else {
        showInfo("No manager handle available. Broadcast is only available to the sub-session manager.")
        return
    }
    let alert = UIAlertController(title: "Broadcast Message", message: "Send a message to all subsessions", preferredStyle: .alert)
    alert.addTextField { $0.placeholder = "Enter your message" }
    alert.addAction(UIAlertAction(title: "Send", style: .default) { _ in
        guard let msg = alert.textFields?.first?.text, !msg.isEmpty else { return }
        let error = manager.broadcastMessage(msg)
        if error == .Errors_Success {
            self.showToast(title: "Broadcast Sent", message: msg)
        } else {
            self.showResult(error, action: "Broadcast message")
        }
    })
    alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
    present(alert, animated: true)
}

Participant: Join/leave subsession and other controls

Next, We will add the participant control in.

/// SubsessionViewController.swift, under manager controls
// MARK: - Participant Controls
func handleReturnToMainSession() {
    guard let participant = subsessionParticipant else {
        showInfo("No participant handle available. This is delivered via 'onSubSessionParticipantHandle' when you are in a sub-session.")
        return
    }
    let alert = UIAlertController(title: "Return to Main Session", message: "Leave your current subsession?", preferredStyle: .alert)
    alert.addAction(UIAlertAction(title: "Return", style: .destructive) { _ in
        let error = participant.returnToMainSession()
        if error == .Errors_Success {
            self.currentSubSession = nil
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
                self.onRequireSessionVideoRefresh?()
            }
        }
        self.showResult(error, action: "Return to main session")
    })
    alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
    present(alert, animated: true)
}
func handleRequestForHelp() {
    guard let participant = subsessionParticipant else {
        showInfo("No participant handle available. You must be in a sub-session to request help.")
        return
    }
    let error = participant.requestForHelp()
    showResult(error, action: "Request for help")
}
// MARK: - Join Sub-Session
func handleJoinSubSession(_ subSession: ZoomVideoSDKSubSessionKit) {
    let name = subSession.getSubSessionName() ?? "this room"
    let alert = UIAlertController(title: "Join Sub-Session", message: "Join '\(name)'?", preferredStyle: .alert)
    alert.addAction(UIAlertAction(title: "Join", style: .default) { _ in
        let error = subSession.joinSubSession()
        if error == .Errors_Success {
            self.currentSubSession = subSession
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
                self.onRequireSessionVideoRefresh?()
            }
        }
        self.showResult(error, action: "Join \(name)")
    })
    alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
    present(alert, animated: true)
}
// MARK: - Sub-Session Detail
func showSubSessionDetail(_ subSession: ZoomVideoSDKSubSessionKit, at indexPath: IndexPath) {
    let name = subSession.getSubSessionName() ?? "Unnamed Room"
    let users = subSession.getSubSessionUserList() ?? []
    let userList = users.isEmpty ? "No users" : users.map { $0.getName() }.joined(separator: ", ")
    let alert = UIAlertController(title: name, message: "Users (\(users.count)): \(userList)", preferredStyle: .actionSheet)
    alert.addAction(UIAlertAction(title: "Join This Room", style: .default) { _ in
        self.handleJoinSubSession(subSession)
    })
    alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
    if let popover = alert.popoverPresentationController,
        let cell = tableView.cellForRow(at: indexPath) {
        popover.sourceView = cell
        popover.sourceRect = cell.bounds
    }
    present(alert, animated: true)
}

Adding subsession callbacks

There are several subsession callbacks available and we will be implementing each of the callbacks to handle to handle all the necessary UI/UX changes.

/// SubsessionViewController.swift
// MARK: - ZoomVideoSDKDelegate
extension SubsessionViewController: ZoomVideoSDKDelegate {
    func onSubSessionStatusChanged(_ status: ZoomVideoSDKSubSessionStatus, subSession pSubSessionKitList: [ZoomVideoSDKSubSessionKit]) {
        currentStatus = status
        print("onSubSessionStatusChanged \(currentStatus.rawValue)")
        let roomsRemoved = status == .none || status == .withdrawn
        if !pSubSessionKitList.isEmpty || roomsRemoved {
            committedSubsessions = pSubSessionKitList
        }
        if !isInSubSession {
            currentSubSession = nil
        }
        tableView.reloadData()
    }
    func onSubSessionManagerHandle(_ pManager: ZoomVideoSDKSubSessionManager?) {
        subsessionManager = pManager
        tableView.reloadSections(IndexSet(integer: 3), with: .automatic)
    }
    func onSubSessionParticipantHandle(_ pParticipant: ZoomVideoSDKSubSessionParticipant?) {
        subsessionParticipant = pParticipant
        onRequireSessionVideoRefresh?()
        if pParticipant == nil && currentStatus == .stopping {
            currentStatus = .stopped
            currentSubSession = nil
        }
        tableView.reloadSections(IndexSet(integer: 4), with: .automatic)
        tableView.reloadSections(IndexSet(integer: 0), with: .automatic)
    }
    func onSubSessionUsersUpdate(_ pSubSessionKit: ZoomVideoSDKSubSessionKit) {
        if let index = committedSubsessions.firstIndex(where: { $0.getSubSessionID() == pSubSessionKit.getSubSessionID() }) {
            committedSubsessions[index] = pSubSessionKit
        }
        // If the user was moved into this subsession by the host, capture it
        if isInSubSession && currentSubSession == nil {
            if let myName = myUser?.getName(),
               pSubSessionKit.getSubSessionUserList()?.contains(where: { $0.getName() == myName }) == true {
                currentSubSession = pSubSessionKit
            }
        }
        tableView.reloadSections(IndexSet(integer: 1), with: .automatic)
        tableView.reloadSections(IndexSet(integer: 0), with: .automatic)
    }
    func onBroadcastMessage(fromMainSession sMessage: String, userName sUserName: String) {
        showToast(title: "Message from \(sUserName)", message: sMessage)
    }
    func onSubSessionUserHelpRequestHandler(_ pHandler: ZoomVideoSDKSubSessionUserHelpRequestHandler) {
        let userName = pHandler.getRequestUserName() ?? "Someone"
        let roomName = pHandler.getRequestSubSessionName() ?? "their subsession"
        DispatchQueue.main.async {
            let alert = UIAlertController(
                title: "Help Requested",
                message: "\(userName) in '\(roomName)' is asking for help.",
                preferredStyle: .alert
            )
            alert.addAction(UIAlertAction(title: "Join Room", style: .default) { _ in
                pHandler.joinSubSessionByUserRequest()
            })
            alert.addAction(UIAlertAction(title: "Decline", style: .cancel) { _ in
                pHandler.ignore()
            })
            self.present(alert, animated: true)
        }
    }
    func onSubSessionUserHelpRequestResult(_ result: ZoomVideoSDKUserHelpRequestResult) {
        let (title, message): (String, String) = {
            switch result {
            case .hostAlreadyInSubSession:
                return ("Help Is Already Here", "The host has joined your subsession.")
            case .busy:
                return ("Host Is Busy", "The host is currently busy and cannot join right now.")
            case .ignore:
                return ("Request Declined", "The host declined your help request.")
            default:
                return ("Help Request", "Your request status has been updated.")
            }
        }()
        showToast(title: title, message: message)
    }
}

Set up the subsession UI

Under the SessionViewControllerExtension.swift file, this is the place we will handle all the UI components and none of the VSDK related methods.

/// SessionViewControllerExtension.swift
import UIKit
import ZoomVideoSDK
extension SubsessionViewController {
    func setupUI() {
        setupViews()
        setupConstraints()
        setupNavigationBar()
    }
    private func setupViews() {
        tableView.delegate = self
        tableView.dataSource = self
        tableView.translatesAutoresizingMaskIntoConstraints = false
        tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell")
        view.addSubview(tableView)
    }
    private func setupConstraints() {
        NSLayoutConstraint.activate([
            tableView.topAnchor.constraint(equalTo: view.topAnchor),
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
        ])
    }
    private func setupNavigationBar() {
        navigationItem.leftBarButtonItem = UIBarButtonItem(
            image: UIImage(systemName: "chevron.left"),
            style: .plain,
            target: self,
            action: #selector(backTapped)
        )
    }
    @objc func backTapped() { dismiss(animated: true) }
    func showToast(title: String, message: String) {
        DispatchQueue.main.async {
            let hostView: UIView
            if let window = UIApplication.shared.connectedScenes
                .compactMap({ $0 as? UIWindowScene })
                .flatMap({ $0.windows })
                .first(where: { $0.isKeyWindow }) {
                hostView = window
            } else {
                hostView = self.navigationController?.view ?? self.view!
            }
            let container = UIView()
            container.backgroundColor = UIColor.systemGray6.withAlphaComponent(0.97)
            container.layer.cornerRadius = 12
            container.layer.shadowColor = UIColor.black.cgColor
            container.layer.shadowOpacity = 0.15
            container.layer.shadowRadius = 8
            container.layer.shadowOffset = CGSize(width: 0, height: 2)
            container.translatesAutoresizingMaskIntoConstraints = false
            container.alpha = 0
            let titleLabel = UILabel()
            titleLabel.text = title
            titleLabel.font = .systemFont(ofSize: 13, weight: .semibold)
            titleLabel.textColor = .label
            titleLabel.translatesAutoresizingMaskIntoConstraints = false
            let messageLabel = UILabel()
            messageLabel.text = message
            messageLabel.font = .systemFont(ofSize: 13)
            messageLabel.textColor = .secondaryLabel
            messageLabel.numberOfLines = 3
            messageLabel.translatesAutoresizingMaskIntoConstraints = false
            container.addSubview(titleLabel)
            container.addSubview(messageLabel)
            hostView.addSubview(container)
            NSLayoutConstraint.activate([
                titleLabel.topAnchor.constraint(equalTo: container.topAnchor, constant: 12),
                titleLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
                titleLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
                messageLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 2),
                messageLabel.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
                messageLabel.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
                messageLabel.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -12),
                container.leadingAnchor.constraint(equalTo: hostView.leadingAnchor, constant: 16),
                container.trailingAnchor.constraint(equalTo: hostView.trailingAnchor, constant: -16),
                container.bottomAnchor.constraint(equalTo: hostView.safeAreaLayoutGuide.bottomAnchor, constant: -10),
            ])
            UIView.animate(withDuration: 0.3) {
                container.alpha = 1
            } completion: { _ in
                UIView.animate(withDuration: 5, delay: 1) {
                    container.alpha = 0
                } completion: { _ in
                    container.removeFromSuperview()
                }
            }
        }
    }
}
// MARK: - UITableViewDataSource
extension SubsessionViewController: UITableViewDataSource {
    // Sections:
    // 0 — Current Status
    // 1 — Committed Sub-Sessions (subsessions)
    // 2 — Pre-list Management  (host helper)
    // 3 — Manager Controls     (start/stop/broadcast)
    // 4 — Participant Controls (return/help)
    func numberOfSections(in tableView: UITableView) -> Int { 5 }
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        switch section {
        case 0: return 3 // Current location, Role and Subsession Status
        case 1: return max(1, committedSubsessions.count) // Committed subsession
        case 2: return 6 // Pre-list - Add, Remove, Clear, View, Commit and WIthdraw Committed
        case 3: return 3 // Manager - Start, Stop subsession and Broadcast message to all
        case 4: return 2 // Participant - Return to main session, Request Help from host.
        default: return 0
        }
    }
    func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        switch section {
        case 0: return "Current Status"
        case 1: return "Committed Sub-Sessions (Subsessions)"
        case 2: return "Pre-list Management"
        case 3: return "Manager Controls"
        case 4: return "Participant Controls"
        default: return nil
        }
    }
    func tableView(_ tableView: UITableView, titleForFooterInSection section: Int) -> String? {
        switch section {
        case 2: return "Workflow: Add subsessions → Commit subsessions list → Start subsessions (Under Manager Controls). Only hosts can access the helper."
        case 3: return "Manager handle is delivered via the onSubSessionManagerHandle callback."
        case 4: return "Participant handle is delivered via the onSubSessionParticipantHandle callback."
        default: return nil
        }
    }
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        switch indexPath.section {
        case 0: return statusCell(for: indexPath)
        case 1: return subSessionCell(for: indexPath)
        case 2: return preListCell(for: indexPath)
        case 3: return managerCell(for: indexPath)
        case 4: return participantCell(for: indexPath)
        default: return UITableViewCell()
        }
    }
    // MARK: - Cell Builders
    private func statusCell(for indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.selectionStyle = .none
        var content = cell.defaultContentConfiguration()
        switch indexPath.row {
        case 0:
            content.text = "Location"
            if isInSubSession {
                let name = currentSubSession?.getSubSessionName() ?? "Subsession"
                content.secondaryText = name
                content.image = UIImage(systemName: "door.left.hand.open")
                content.imageProperties.tintColor = .systemOrange
            } else {
                content.secondaryText = "Main Session"
                content.image = UIImage(systemName: "house.fill")
                content.imageProperties.tintColor = .systemBlue
            }
        case 1:
            content.text = "Role"
            if isHost {
                content.secondaryText = "Host / Manager / Participant"
                content.image = UIImage(systemName: "crown.fill")
                content.imageProperties.tintColor = .systemYellow
            } else if isManager {
                content.secondaryText = "Manager / Participant"
                content.image = UIImage(systemName: "person.badge.key.fill")
                content.imageProperties.tintColor = .systemOrange
            } else {
                content.secondaryText = "Participant"
                content.image = UIImage(systemName: "person.fill")
                content.imageProperties.tintColor = .secondaryLabel
            }
        case 2:
            let statusText = statusDescription(for: currentStatus)
            content.text = "Sub-Session Status"
            content.secondaryText = statusText
            content.image = UIImage(systemName: "rectangle.3.group.fill")
            content.imageProperties.tintColor = .systemPurple
        default:
            break
        }
        cell.contentConfiguration = content
        return cell
    }
    private func subSessionCell(for indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        guard !committedSubsessions.isEmpty else {
            var content = cell.defaultContentConfiguration()
            content.text = "No committed sub-sessions"
            content.secondaryText = "Add names to the pre-list, then commit"
            content.textProperties.color = .secondaryLabel
            content.image = UIImage(systemName: "rectangle.slash")
            content.imageProperties.tintColor = .tertiaryLabel
            cell.contentConfiguration = content
            cell.selectionStyle = .none
            cell.accessoryType = .none
            return cell
        }
        let subSession = committedSubsessions[indexPath.row]
        let userCount = subSession.getSubSessionUserList()?.count ?? 0
        var content = cell.defaultContentConfiguration()
        content.text = subSession.getSubSessionName() ?? "Unnamed Room"
        content.secondaryText = "\(userCount) user\(userCount == 1 ? "" : "s")"
        content.image = UIImage(systemName: "person.2.fill")
        content.imageProperties.tintColor = .systemBlue
        cell.contentConfiguration = content
        cell.selectionStyle = .default
        cell.accessoryType = .disclosureIndicator
        return cell
    }
    private func preListCell(for indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.selectionStyle = .default
        cell.accessoryType = .none
        var content = cell.defaultContentConfiguration()
        switch indexPath.row {
        case 0:
            content.text = "Add Sub-Sessions to Pre-list"
            content.image = UIImage(systemName: "plus.rectangle.on.rectangle")
            content.imageProperties.tintColor = .systemBlue
        case 1:
            content.text = "Remove Sub-Session from Pre-list"
            content.image = UIImage(systemName: "minus.rectangle")
            content.imageProperties.tintColor = .systemOrange
        case 2:
            content.text = "Clear Pre-list"
            content.image = UIImage(systemName: "trash.fill")
            content.imageProperties.tintColor = .systemRed
        case 3:
            let count = preListNames.count
            content.text = "View Pre-list"
            content.secondaryText = count == 0 ? "Empty" : "\(count) room\(count == 1 ? "" : "s")"
            content.image = UIImage(systemName: "list.bullet.rectangle")
            content.imageProperties.tintColor = .systemGray
        case 4:
            content.text = "Commit Sub-Session List"
            content.secondaryText = "Creates the subsessions"
            content.image = UIImage(systemName: "checkmark.rectangle.fill")
            content.imageProperties.tintColor = .systemGreen
        case 5:
            content.text = "Withdraw Sub-Session List"
            content.secondaryText = "Removes all committed rooms"
            content.image = UIImage(systemName: "xmark.rectangle.fill")
            content.imageProperties.tintColor = .systemRed
        default:
            break
        }
        cell.contentConfiguration = content
        return cell
    }
    private func managerCell(for indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.selectionStyle = .default
        cell.accessoryType = .none
        let managerAvailable = subsessionManager != nil
        var content = cell.defaultContentConfiguration()
        switch indexPath.row {
        case 0:
            content.text = "Start Sub-Sessions"
            content.image = UIImage(systemName: "play.circle.fill")
            content.imageProperties.tintColor = managerAvailable ? .systemGreen : .systemGray
        case 1:
            content.text = "Stop Sub-Sessions"
            content.image = UIImage(systemName: "stop.circle.fill")
            content.imageProperties.tintColor = managerAvailable ? .systemRed : .systemGray
        case 2:
            content.text = "Broadcast Message to All"
            content.image = UIImage(systemName: "megaphone.fill")
            content.imageProperties.tintColor = managerAvailable ? .systemOrange : .systemGray
        default:
            break
        }
        if !managerAvailable {
            content.textProperties.color = .secondaryLabel
        }
        cell.contentConfiguration = content
        return cell
    }
    private func participantCell(for indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath)
        cell.selectionStyle = .default
        cell.accessoryType = .none
        let participantAvailable = subsessionParticipant != nil
        var content = cell.defaultContentConfiguration()
        switch indexPath.row {
        case 0:
            content.text = "Return to Main Session"
            content.image = UIImage(systemName: "arrow.uturn.backward.circle.fill")
            content.imageProperties.tintColor = participantAvailable ? .systemIndigo : .systemGray
        case 1:
            content.text = "Request Help from Host"
            content.image = UIImage(systemName: "hand.raised.fill")
            content.imageProperties.tintColor = participantAvailable ? .systemYellow : .systemGray
        default:
            break
        }
        if !participantAvailable {
            content.textProperties.color = .secondaryLabel
        }
        cell.contentConfiguration = content
        return cell
    }
    // MARK: - Map Subsession Status -> UI String
    private func statusDescription(for status: ZoomVideoSDKSubSessionStatus) -> String {
        switch status {
        case .none:           return "Not Started"
        case .committed:      return "Rooms Committed"
        case .withdrawn:      return "List Withdrawn"
        case .started:        return "Active"
        case .stopping:       return "Stopping..."
        case .stopped:        return "Stopped"
        case .commitFailed:   return "Commit Failed"
        case .withdrawFailed: return "Withdraw Failed"
        case .startFailed:    return "Start Failed"
        case .stopFailed:     return "Stop Failed"
        @unknown default:     return "Unknown"
        }
    }
}
// MARK: - UITableViewDelegate
extension SubsessionViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: true)
        switch indexPath.section {
        case 0: break
        case 1:
            guard !committedSubsessions.isEmpty else { return }
            showSubSessionDetail(committedSubsessions[indexPath.row], at: indexPath)
        case 2: handlePreListAction(at: indexPath)
        case 3: handleManagerAction(at: indexPath)
        case 4: handleParticipantAction(at: indexPath)
        default: break
        }
    }
    private func handlePreListAction(at indexPath: IndexPath) {
        switch indexPath.row {
        case 0: handleAddToPreList()
        case 1: handleRemoveFromPreList()
        case 2: handleClearPreList()
        case 3: handleViewPreList()
        case 4: handleCommitSubSessionList()
        case 5: handleWithdrawSubSessionList()
        default: break
        }
    }
    private func handleManagerAction(at indexPath: IndexPath) {
        switch indexPath.row {
        case 0: handleStartSubSession()
        case 1: handleStopSubSession()
        case 2: handleBroadcastMessage()
        default: break
        }
    }
    private func handleParticipantAction(at indexPath: IndexPath) {
        switch indexPath.row {
        case 0: handleReturnToMainSession()
        case 1: handleRequestForHelp()
        default: break
        }
    }
}

Important last step

Now that we have added all the necessary subsession UI and controls, we will need to handle the video feed and placeholder participant name whenever the local user joins a subsession or return back to the main session.

/// SessionViewController.swift
class SessionViewController: UIViewController {
    // ...
    // MARK: - Properties
    // ...
    // Add subsession proerties below
    // Subsession handles + state — captured here so  they survive before SubsessionViewController is presented
    var subsessionManager: ZoomVideoSDKSubSessionManager?
    var subsessionParticipant: ZoomVideoSDKSubSessionParticipant?
    var subsessionStatus: ZoomVideoSDKSubSessionStatus = .none
    var committedSubsessions: [ZoomVideoSDKSubSessionKit] = []
    var currentSubSessionID: String?
    private var pendingVideoRefreshWorkItem: DispatchWorkItem?
    private var localRebindRecoveryWorkItem: DispatchWorkItem?
    private var pendingRefreshWhenVisible = false
    private var isPerformingLocalRefresh = false
    private var lastLocalRefreshAt: Date = .distantPast
    private var localSubscribeRetryCount = 0
    private var hasJoinedSession = false
    // As we are using the same SessionViewController for all video view, we will need to handle when a refresh is required.
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        if !(ZoomVideoSDK.shareInstance()?.isInSession() ?? false) {
            presentJWTAlert()
        } else {
            if pendingRefreshWhenVisible {
                pendingRefreshWhenVisible = false
                scheduleVideoCanvasRefresh(forceRestartLocalVideo: true, refreshRemoteUsers: true, minInterval: 0)
            } else if !hasJoinedSession {
                scheduleVideoCanvasRefresh(forceRestartLocalVideo: true, refreshRemoteUsers: true, minInterval: 0)
            }
        }
    }
}
// MARK: - ZoomVideoSDKDelegate
extension SessionViewController: ZoomVideoSDKDelegate {
    func onSessionJoin() {
        hasJoinedSession = true // To prevent the displaying of the JWT request alert
        DispatchQueue.main.async {
            self.loadingLabel.isHidden = true
            self.tabBar.isHidden = false
            self.addLocalViewToGrid()
        }
        addLocalUserVideoView()
    }
    // ...
    // Add these methods below
    // Upon joining to subsession / return back to main session, handles the local user views.
    func addLocalUserVideoView(forceRestart: Bool = false) {
        Task { @MainActor in
            if self.isPerformingLocalRefresh { return }
            guard let myUser = ZoomVideoSDK.shareInstance()?.getSession()?.getMySelf(),
                  let canvas = myUser.getVideoCanvas() else { return }
            #if !targetEnvironment(simulator)
            self.isPerformingLocalRefresh = true
            defer {
                self.isPerformingLocalRefresh = false
                self.lastLocalRefreshAt = Date()
            }
            self.view.layoutIfNeeded()
            self.localRebindRecoveryWorkItem?.cancel()
            canvas.unSubscribe(with: self.localView)
            let isVideoOn = myUser.getVideoCanvas()?.videoStatus()?.on ?? false
            if let videoHelper = ZoomVideoSDK.shareInstance()?.getVideoHelper() {
                if forceRestart {
                    _ = videoHelper.stopVideo()
                    try? await Task.sleep(nanoseconds: 150_000_000)
                }
                let startError = videoHelper.startVideo()
                let started = startError == .Errors_Success
                self.localPlaceholder?.isHidden = started
                self.toggleVideoBarItem.title = started ? "Stop Video" : "Start Video"
                self.toggleVideoBarItem.image = UIImage(systemName: started ? "video.slash" : "video")
            } else {
                self.localPlaceholder?.isHidden = true
                self.toggleVideoBarItem.title = "Stop Video"
                self.toggleVideoBarItem.image = UIImage(systemName: "video.slash")
            }
            let subscribeError = canvas.subscribe(with: self.localView, aspectMode: .panAndScan, andResolution: ._Auto)
            if subscribeError != .Errors_Success {
                self.localPlaceholder?.isHidden = false
            } else {
                self.localSubscribeRetryCount = 0
            }
            let recoveryWorkItem = DispatchWorkItem { [weak self] in
                guard let self = self,
                      let user = ZoomVideoSDK.shareInstance()?.getSession()?.getMySelf(),
                      let recoveryCanvas = user.getVideoCanvas() else { return }
                recoveryCanvas.unSubscribe(with: self.localView)
                _ = recoveryCanvas.subscribe(with: self.localView, aspectMode: .panAndScan, andResolution: ._Auto)
            }
            self.localRebindRecoveryWorkItem = recoveryWorkItem
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.35, execute: recoveryWorkItem)
            #endif
        }
    }
    // Prevent over-refresh while preserving black-video recovery
    func scheduleVideoCanvasRefresh(forceRestartLocalVideo: Bool = true, refreshRemoteUsers: Bool = false, minInterval: TimeInterval = 0.5) {
        guard viewIfLoaded?.window != nil else {
            pendingRefreshWhenVisible = true
            return
        }
        let elapsed = Date().timeIntervalSince(lastLocalRefreshAt)
        if elapsed < minInterval { return }
        pendingVideoRefreshWorkItem?.cancel()
        let workItem = DispatchWorkItem { [weak self] in
            guard let self = self else { return }
            self.addLocalUserVideoView(forceRestart: forceRestartLocalVideo)
            if refreshRemoteUsers {
                self.refreshRemoteUserViews()
            }
        }
        pendingVideoRefreshWorkItem = workItem
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: workItem)
    }
    // Upon joining to subsession / return back to main session, refresh all remote user views.
    func refreshRemoteUserViews() {
        DispatchQueue.main.async {
            // Remove only remote view containers; leave the local container untouched
            for (_, views) in self.remoteUserViews {
                views.view.superview?.removeFromSuperview()
            }
            self.remoteUserViews.removeAll()
            // Add remote tiles after local (preserves order)
            guard let users = ZoomVideoSDK.shareInstance()?.getSession()?.getRemoteUsers() else { return }
            for user in users {
                let views = self.addRemoteUserView(for: user)
                self.remoteUserViews[user.getID()] = views
                if let canvas = user.getVideoCanvas() {
                    Task(priority: .background) {
                        let isVideoOn = canvas.videoStatus()?.on ?? false
                        views.placeholder.isHidden = isVideoOn
                        canvas.subscribe(with: views.view, aspectMode: .panAndScan, andResolution: ._Auto)
                    }
                }
            }
        }
    }
    func onSubSessionStatusChanged(_ status: ZoomVideoSDKSubSessionStatus, subSession pSubSessionKitList: [ZoomVideoSDKSubSessionKit]) {
        subsessionStatus = status
        if !pSubSessionKitList.isEmpty || status == .none || status == .withdrawn {
            committedSubsessions = pSubSessionKitList
        }
        if status == .started || status == .stopped || status == .stopping {
            scheduleVideoCanvasRefresh(forceRestartLocalVideo: true, refreshRemoteUsers: true, minInterval: 0)
        }
    }
    func onSubSessionManagerHandle(_ pManager: ZoomVideoSDKSubSessionManager?) {
        subsessionManager = pManager
    }
    func onSubSessionParticipantHandle(_ pParticipant: ZoomVideoSDKSubSessionParticipant?) {
        let wasInSubsession = subsessionParticipant != nil
        let isInSubsession = pParticipant != nil
        subsessionParticipant = pParticipant
        if wasInSubsession != isInSubsession {
            scheduleVideoCanvasRefresh(forceRestartLocalVideo: true, refreshRemoteUsers: true, minInterval: 0)
        }
    }
    func onVideoCanvasSubscribeFail(_ failReason: ZoomVideoSDKSubscribeFailReason, user: ZoomVideoSDKUser?, view: UIView?) {
        guard let myUser = ZoomVideoSDK.shareInstance()?.getSession()?.getMySelf(),
              let failedUser = user,
              failedUser.getID() == myUser.getID(),
              view === localView else { return }
        guard localSubscribeRetryCount < 2 else { return }
        localSubscribeRetryCount += 1
        scheduleVideoCanvasRefresh(forceRestartLocalVideo: true, refreshRemoteUsers: false, minInterval: 0)
    }
}

And that’s how you integrate the subsession features in your application. See the Add Features section in our Video SDK docs for additional capabilities.