Skip to main content
Build a fully customized control panel for your call interface by hiding the default controls and implementing your own UI with call actions. This guide walks you through creating a custom control panel with essential call controls.

Overview

Custom control panels allow you to:
  • Match your app’s branding and design language
  • Simplify the interface by showing only relevant controls
  • Add custom functionality and workflows
  • Create unique user experiences
This guide demonstrates building a basic custom control panel with:
  • Mute/Unmute audio button
  • Pause/Resume video button
  • Switch camera button
  • End call button

Prerequisites


Step 1: Hide Default Controls

Configure your session settings to hide the default control panel:
let sessionSettings = CometChatCalls.sessionSettingsBuilder
    .hideControlPanel(true)
    .build()
You can also hide individual buttons while keeping the control panel visible. See SessionSettingsBuilder for all options.

Step 2: Create Custom Layout

Create a custom view for your controls programmatically or in Interface Builder:
class CallViewController: UIViewController {
    
    // Call container view
    private let callContainer = UIView()
    
    // Custom control panel
    private let controlPanel = UIStackView()
    private let btnToggleAudio = UIButton(type: .system)
    private let btnToggleVideo = UIButton(type: .system)
    private let btnSwitchCamera = UIButton(type: .system)
    private let btnEndCall = UIButton(type: .system)
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupUI()
        setupControlListeners()
    }
    
    private func setupUI() {
        view.backgroundColor = .black
        
        // Setup call container
        callContainer.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(callContainer)
        
        // Setup control panel
        controlPanel.axis = .horizontal
        controlPanel.distribution = .equalSpacing
        controlPanel.alignment = .center
        controlPanel.spacing = 20
        controlPanel.translatesAutoresizingMaskIntoConstraints = false
        controlPanel.backgroundColor = UIColor.black.withAlphaComponent(0.8)
        controlPanel.layoutMargins = UIEdgeInsets(top: 16, left: 32, bottom: 16, right: 32)
        controlPanel.isLayoutMarginsRelativeArrangement = true
        view.addSubview(controlPanel)
        
        // Configure buttons
        configureButton(btnToggleAudio, imageName: "mic.fill", backgroundColor: .darkGray)
        configureButton(btnToggleVideo, imageName: "video.fill", backgroundColor: .darkGray)
        configureButton(btnSwitchCamera, imageName: "camera.rotate.fill", backgroundColor: .darkGray)
        configureButton(btnEndCall, imageName: "phone.down.fill", backgroundColor: .systemRed)
        
        // Add buttons to control panel
        controlPanel.addArrangedSubview(btnToggleAudio)
        controlPanel.addArrangedSubview(btnToggleVideo)
        controlPanel.addArrangedSubview(btnSwitchCamera)
        controlPanel.addArrangedSubview(btnEndCall)
        
        // Layout constraints
        NSLayoutConstraint.activate([
            callContainer.topAnchor.constraint(equalTo: view.topAnchor),
            callContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            callContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            callContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            
            controlPanel.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            controlPanel.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            controlPanel.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
            controlPanel.heightAnchor.constraint(equalToConstant: 80)
        ])
    }
    
    private func configureButton(_ button: UIButton, imageName: String, backgroundColor: UIColor) {
        button.setImage(UIImage(systemName: imageName), for: .normal)
        button.tintColor = .white
        button.backgroundColor = backgroundColor
        button.layer.cornerRadius = 28
        button.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            button.widthAnchor.constraint(equalToConstant: 56),
            button.heightAnchor.constraint(equalToConstant: 56)
        ])
    }
}

Step 3: Implement Control Actions

Set up button actions and call the appropriate SDK methods:
private var isAudioMuted = false
private var isVideoPaused = false

private func setupControlListeners() {
    btnToggleAudio.addTarget(self, action: #selector(toggleAudio), for: .touchUpInside)
    btnToggleVideo.addTarget(self, action: #selector(toggleVideo), for: .touchUpInside)
    btnSwitchCamera.addTarget(self, action: #selector(switchCamera), for: .touchUpInside)
    btnEndCall.addTarget(self, action: #selector(endCall), for: .touchUpInside)
}

@objc private func toggleAudio() {
    if isAudioMuted {
        CallSession.shared.unMuteAudio()
    } else {
        CallSession.shared.muteAudio()
    }
}

@objc private func toggleVideo() {
    if isVideoPaused {
        CallSession.shared.resumeVideo()
    } else {
        CallSession.shared.pauseVideo()
    }
}

@objc private func switchCamera() {
    CallSession.shared.switchCamera()
}

@objc private func endCall() {
    CallSession.shared.leaveSession()
    navigationController?.popViewController(animated: true)
}

Step 4: Handle State Updates

Use MediaEventsListener to keep your UI synchronized with the actual call state:
extension CallViewController: MediaEventsListener {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // ... other setup
        CallSession.shared.addMediaEventsListener(self)
    }
    
    deinit {
        CallSession.shared.removeMediaEventsListener(self)
    }
    
    func onAudioMuted() {
        DispatchQueue.main.async {
            self.isAudioMuted = true
            self.btnToggleAudio.setImage(UIImage(systemName: "mic.slash.fill"), for: .normal)
        }
    }

    func onAudioUnMuted() {
        DispatchQueue.main.async {
            self.isAudioMuted = false
            self.btnToggleAudio.setImage(UIImage(systemName: "mic.fill"), for: .normal)
        }
    }

    func onVideoPaused() {
        DispatchQueue.main.async {
            self.isVideoPaused = true
            self.btnToggleVideo.setImage(UIImage(systemName: "video.slash.fill"), for: .normal)
        }
    }

    func onVideoResumed() {
        DispatchQueue.main.async {
            self.isVideoPaused = false
            self.btnToggleVideo.setImage(UIImage(systemName: "video.fill"), for: .normal)
        }
    }
    
    // Other MediaEventsListener callbacks
    func onRecordingStarted() {}
    func onRecordingStopped() {}
    func onScreenShareStarted() {}
    func onScreenShareStopped() {}
    func onAudioModeChanged(audioModeType: AudioModeType) {}
    func onCameraFacingChanged(cameraFacing: CameraFacing) {}
}
Use SessionStatusListener to handle session end events:
extension CallViewController: SessionStatusListener {
    
    func onSessionLeft() {
        DispatchQueue.main.async {
            self.navigationController?.popViewController(animated: true)
        }
    }

    func onConnectionClosed() {
        DispatchQueue.main.async {
            self.navigationController?.popViewController(animated: true)
        }
    }
    
    func onSessionJoined() {}
    func onSessionTimedOut() {}
    func onConnectionLost() {}
    func onConnectionRestored() {}
}