
Module | Description |
The main call UI component. It automatically observes CallStore data, renders video streams, and supports UI customization such as layout switching, avatar, and icon configuration. | |
Manages the call lifecycle: make call, answer call, reject call, hang up. Provides real-time access to participant audio/video status, call timer, call records, and related data. | |
Controls audio/video devices: microphone (toggle/volume), camera (toggle/switch/quality), screen sharing, and real-time device status monitoring. |
pod 'AtomicXCore' to your project's Podfile.target 'YourProjectTarget' dopod 'AtomicXCore'end
.xcodeproj directory and run pod init to generate a Podfile.pod install --repo-update
YourProjectName.xcworkspace file.
import UIKitimport AtomicXCoreimport Combineclass ViewController: UIViewController {var cancellables = Set<AnyCancellable>()override func viewDidLoad() {super.viewDidLoad()// Initialize CallStorelet _ = CallStore.shared// Set your user infolet userID = "test_001" // Replace with your UserIDlet sdkAppID: Int = 1400000001 // Replace with your SDKAppID from the consolelet secretKey = "**************" // Replace with your SecretKey from the console// Generate UserSig (for local testing only; use server generation in production)let userSig = GenerateTestUserSig.genTestUserSig(userID: userID,sdkAppID: sdkAppID,secretKey: secretKey)// Log inLoginStore.shared.login(sdkAppID: sdkAppID,userID: userID,userSig: userSig) { result inswitch result {case .success:// Login succeededLog.info("login success")case .failure(let error):// Login failedLog.error("login failed, code: \\(error.code), error: \\(error.message)")}}}}
Parameter | Type | Description |
userID | String | Unique identifier for the current user. Only letters, numbers, hyphens, and underscores are allowed. Avoid simple IDs like 1 or 123 to prevent multi-device login conflicts. |
sdkAppID | int | |
secretKey | String | |
userSig | String | Authentication token for TRTC. Development: Use GenerateTestUserSig.genTestUserSig or UserSig Tool to generate a temporary UserSig. Production: Always generate UserSig server-side to prevent secret key leakage. See Server-side UserSig Generation for details. For more info, see How to Calculate and Use UserSig. |
import UIKitimport AtomicXCoreclass CallViewController: UIViewController {// 1. Create the call screenoverride func viewDidLoad() {super.viewDidLoad()view.backgroundColor = .black// 2. Attach CallCoreView to the call screencallCoreView = CallCoreView(frame: view.bounds)callCoreView?.autoresizingMask = [.flexibleWidth, .flexibleHeight]if let callCoreView = callCoreView {view.addSubview(callCoreView)}}}
Feature | Description | Reference |
Set layout mode | Switch between different layout modes. If not set, layout adapts automatically based on participant count. | Switch layout mode |
Set avatar | Set custom avatars for specific users by providing avatar resource paths. | Customize default avatar |
Set volume indicator icon | Display custom volume indicator icons for different volume levels. | Customize volume indicator icon |
Set network status icon | Show network status icons based on real-time network quality. | Customize network status icon |
Set waiting animation for users | In multi-party calls, display a GIF animation for users in the waiting state. | Customize loading animation |
import UIKitimport AtomicXCoreclass CallViewController: UIViewController {private var buttonReject: UIButton?private var buttonAccept: UIButton?override func viewDidLoad() {super.viewDidLoad()view.backgroundColor = .blackaddControlButtons()}private func addControlButtons() {let buttonWidth: CGFloat = 80let buttonHeight: CGFloat = 80let spacing: CGFloat = 60let bottomMargin: CGFloat = 100let totalWidth = buttonWidth * 2 + spacinglet startX = (view.bounds.width - totalWidth) / 2let buttonY = view.bounds.height - bottomMargin - buttonHeight// Answer buttonbuttonAccept = createButton(frame: CGRect(x: startX, y: buttonY, width: buttonWidth, height: buttonHeight),title: "Answer",backgroundColor: .systemGreen)buttonAccept?.addTarget(self, action: #selector(touchAcceptButton), for: .touchUpInside)view.addSubview(buttonAccept!)// Reject buttonbuttonReject = createButton(frame: CGRect(x: startX + buttonWidth + spacing, y: buttonY, width: buttonWidth, height: buttonHeight),title: "Reject",backgroundColor: .systemRed)buttonReject?.addTarget(self, action: #selector(touchRejectButton), for: .touchUpInside)view.addSubview(buttonReject!)}@objc private func touchAcceptButton() {CallStore.shared.accept(completion: nil)}@objc private func touchRejectButton() {CallStore.shared.reject(completion: nil)}private func createButton(frame: CGRect, title: String, backgroundColor: UIColor) -> UIButton {let button = UIButton(type: .system)button.frame = framebutton.setTitle(title, for: .normal)button.setTitleColor(.white, for: .normal)button.backgroundColor = backgroundColorbutton.layer.cornerRadius = frame.width / 2button.titleLabel?.font = UIFont.systemFont(ofSize: 16)return button}}
onCallEnded event is triggered. Listen for this event and close (dismiss) the call screen when the call ends.import UIKitimport AtomicXCoreimport Combineclass CallViewController: UIViewController {private var cancellables = Set<AnyCancellable>()override func viewDidLoad() {super.viewDidLoad()addListener()}private func addListener() {CallStore.shared.callEventPublisher.receive(on: DispatchQueue.main).sink { [weak self] event inif case .onCallEnded = event {self?.dismiss(animated: true)}}.store(in: &cancellables)}}
Info.plist file with appropriate usage descriptions. These will be displayed when the system requests permissions.<key>NSCameraUsageDescription</key><string>Camera access is required for video calls and group video calls.</string><key>NSMicrophoneUsageDescription</key><string>Microphone access is required for audio calls, group audio calls, video calls, and group video calls.</string>
import AVFoundationimport UIKitextension UIViewController {// Check microphone permissionfunc checkMicrophonePermission(completion: @escaping (Bool) -> Void) {let status = AVCaptureDevice.authorizationStatus(for: .audio)switch status {case .authorized:completion(true)case .notDetermined:AVCaptureDevice.requestAccess(for: .audio) { granted inDispatchQueue.main.async {completion(granted)}}case .denied, .restricted:completion(false)@unknown default:completion(false)}}// Check camera permissionfunc checkCameraPermission(completion: @escaping (Bool) -> Void) {let status = AVCaptureDevice.authorizationStatus(for: .video)switch status {case .authorized:completion(true)case .notDetermined:AVCaptureDevice.requestAccess(for: .video) { granted inDispatchQueue.main.async {completion(granted)}}case .denied, .restricted:completion(false)@unknown default:completion(false)}}// Show permission alertfunc showPermissionAlert(message: String) {let alert = UIAlertController(title: "Permission Request",message: message,preferredStyle: .alert)alert.addAction(UIAlertAction(title: "Go to Settings", style: .default) { _ inif let url = URL(string: UIApplication.openSettingsURLString) {UIApplication.shared.open(url)}})alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))present(alert, animated: true)}}
CallStore.shared.state.value.selfInfo to reactively monitor the current user's status.selfInfo.status is .waiting, play a ringtone or vibration. If the status changes to .accept, stop the notification.import UIKitimport AtomicXCoreimport Combineimport AVFoundationclass MainViewController: UIViewController {private var audioPlayer: AVAudioPlayer?private var cancellables = Set<AnyCancellable>()override func viewDidLoad() {super.viewDidLoad()observeSelfStatus()}private func observeSelfStatus() {CallStore.shared.state.subscribe(StatePublisherSelector<CallState, CallParticipantStatus>(keyPath: \\.selfInfo.status)).removeDuplicates().receive(on: DispatchQueue.main).sink { [weak self] status inself?.handleRingtoneByStatus(status)}.store(in: &cancellables)}private func handleRingtoneByStatus(_ status: CallParticipantStatus) {switch status {case .waiting:// Start ringtone while waiting to answer// playRingtone()case .accept:// Stop ringtone after answering// stopRingtone()default:// Stop ringtone for other statuses// stopRingtone()}}}
onCallReceived event.import UIKitimport AtomicXCoreimport Combineclass MainViewController: UIViewController {private var cancellables = Set<AnyCancellable>()override func viewDidLoad() {super.viewDidLoad()addListener()}private func addListener() {CallStore.shared.callEventPublisher.receive(on: DispatchQueue.main).sink { [weak self] event inif case .onCallReceived(_, let mediaType, _) = event {self?.openDeviceForMediaType(mediaType)}}.store(in: &cancellables)}private func openDeviceForMediaType(_ mediaType: CallMediaType) {DeviceStore.shared.openLocalMicrophone(completion: nil)if mediaType == .video {let isFront = trueDeviceStore.shared.openLocalCamera(isFront: isFront, completion: nil)}}}
Parameter | Type | Required | Description |
isFront | Bool | Yes | Whether to use the front camera. - true: Front camera - false: Rear camera |
completion | CompletionHandler | No | Completion callback, returns the result of enabling the camera. If it fails, returns error code and message. |
Parameter | Type | Required | Description |
completion | CompletionHandler | No | Completion callback, returns the result of enabling the microphone. If it fails, returns error code and message. |
onCallReceived.import UIKitimport AtomicXCoreimport Combineclass MainViewController: UIViewController {private var cancellables = Set<AnyCancellable>()override func viewDidLoad() {super.viewDidLoad()addListener()}private func addListener() {CallStore.shared.callEventPublisher.receive(on: DispatchQueue.main).sink { [weak self] event inif case .onCallReceived(_, _, _) = event {let callVC = CallViewController()callVC.modalPresentationStyle = .fullScreenself?.present(callVC, animated: true)}}.store(in: &cancellables)}}
Parameter | Type | Description |
callId | String | Unique identifier for the call. |
mediaType | Specifies whether the call is audio or video. CallMediaType.video: Video call CallMediaType.audio: Audio call |


// Set volume indicator iconslet volumeLevelIcons: [VolumeLevel: String] = [.mute: "path to the corresponding icon resource"]callCoreView.setVolumeLevelIcons(icons: volumeLevelIcons)
Parameter | Type | Required | Description |
icons | [VolumeLevel: String] | Yes | A dictionary mapping volume levels to icon resources. Key (VolumeLevel): VolumeLevel.mute: Microphone muted VolumeLevel.low: Volume (0 ~ 25] VolumeLevel.medium: Volume (25 ~ 50] VolumeLevel.high: Volume (50 ~ 75] VolumeLevel.peak: Volume (75 ~ 100] Value (String): Path to the icon resource for each level. |

// Set network quality iconslet networkQualityIcons: [NetworkQuality: String] = [.bad: "path to the corresponding icon"]callCoreView.setNetworkQualityIcons(icons: networkQualityIcons)
Parameter | Type | Required | Description |
icons | [NetworkQuality: String] | Yes | Network Quality Icon Mapping Table. The dictionary structure is defined as follows: Key ( NetworkQuality ) : NetworkQuality NetworkQuality.unknown :Network status is undetermined. NetworkQuality.excellent:Outstanding network connection.NetworkQuality.good : Stable and good network connection.NetworkQuality.poor : Weak network signal.NetworkQuality.bad : Very weak or unstable network. NetworkQuality.veryBad :Extremely poor network, near disconnection. NetworkQuality.down :Network is disconnected. Value ( String ) : The absolute path or resource name of the icon corresponding to each network status level. |
Icon | Description | Download |
![]() | Poor network indicator icon. Recommended for NetworkQuality.bad, NetworkQuality.veryBad, or NetworkQuality.down. |
// Set user avatarsvar avatars: [String: String] = [:]let userId = "" // User IDlet avatarPath = "" // Path to user's default avatar resourceavatars[userId] = avatarPathcallCoreView.setParticipantAvatars(avatars: avatars)
Parameter | Type | Required | Description |
avatars | [String: String] | Yes | A dictionary mapping userID to the absolute path of the user's avatar resource. |
Icon | Description | Download |
![]() | Default avatar. Use as the fallback when a user's avatar fails to load or is not set. |

// Set waiting animationlet waitingAnimationPath = "" // Path to the waiting animation GIF resourcecallCoreView.setWaitingAnimation(path: waitingAnimationPath)
Parameter | Type | Required | Description |
path | String | Yes | Absolute path to the GIF animation resource. |
Icon | Description | Download |
![]() | User waiting animation. Recommended for group calls; display when a user's status is waiting. |
CallStore.observerState.activeCall for updates.activeCall.duration to your timer UI. This field is reactive and updates automatically; you do not need to manage a timer manually.import UIKitimport AtomicXCoreimport Combineclass TimerView: UILabel {private var cancellables = Set<AnyCancellable>()override init(frame: CGRect) {super.init(frame: frame)setupView()}required init?(coder: NSCoder) {super.init(coder: coder)setupView()}private func setupView() {textColor = .whitetextAlignment = .centerfont = .systemFont(ofSize: 16)}override func didMoveToWindow() {super.didMoveToWindow()if window != nil {registerActiveCallObserver()} else {cancellables.removeAll()}}private func registerActiveCallObserver() {CallStore.shared.state.subscribe().map { $0.activeCall }.removeDuplicates { $0.duration == $1.duration }.receive(on: DispatchQueue.main).sink { [weak self] activeCall inself?.updateDurationView(activeCall: activeCall)}.store(in: &cancellables)}private func updateDurationView(activeCall: CallInfo) {let currentDuration = activeCall.durationlet minutes = currentDuration / 60let seconds = currentDuration % 60text = String(format: "%02d:%02d", minutes, seconds)}}
import AtomicXCorevar userProfile = UserProfile()userProfile.userID = "" // Your userIduserProfile.avatarURL = "" // Avatar URLuserProfile.nickname = "" // NicknameLoginStore.shared.setSelfInfo(userProfile: userProfile) { result inswitch result {case .success:// Successfully setcase .failure(let error):// Failed to set}}
Parameter | Type | Required | Description |
userProfile | Yes | User info struct: userID: User ID avatarURL: User avatar URL nickname: User nickname | |
completion | CompletionHandler | No | Completion callback, returns the result of the operation. |
Float mode for 1v1 calls and Grid mode for multi-party calls.Float Mode | Grid Mode | PIP Mode |
![]() | ![]() | ![]() |
Layout: While waiting, display your own video full screen. After answering, show the remote video full screen and your own video as a floating window. Interaction: Drag the small window or tap to swap big/small video. | Layout: All participant videos are tiled in a grid. Best for 2+ participants. Tap to enlarge a video. Interaction: Tap a participant to enlarge their video. | Layout: In 1v1, remote video is fixed; in multi-party, the active speaker is shown full screen. Interaction: Shows your own video while waiting, displays call timer after answering. |
// Create CallCoreViewlet callCoreView = CallCoreView(frame: view.bounds)callCoreView.autoresizingMask = [.flexibleWidth, .flexibleHeight]// Set layout modelet template = CallLayoutTemplate.gridcallCoreView.setLayoutTemplate(template)
Parameter | Type | Required | Description |
template | Yes | CallCoreView's layout mode CallLayoutTemplate.float :Layout: While waiting, display your own video full screen. After answering, show the remote video full screen and your own video as a floating window. Interaction: Drag the small window or tap to swap big/small video. CallLayoutTemplate.grid :Layout: All participant videos are tiled in a grid. Best for 2+ participants. Tap to enlarge a video. Interaction: Tap a participant to enlarge their video. CallLayoutTemplate.pip : Layout: In 1v1, remote video is fixed; in multi-party, the active speaker is shown full screen. Interaction: Shows your own video while waiting, displays call timer after answering. |
CallPipView component for in-app floating windows. If your call UI is covered by other pages (e.g., user presses back while a call is ongoing), a floating window can be shown so users can always see call status and quickly return to the call UI.import UIKitimport AtomicXCoreimport Combine/*** Floating Window Controller* * Used to display the call floating window, containing a CallCoreView internally.*/class FloatWindowViewController: UIViewController {var tapGestureAction: (() -> Void)?private var cancellables = Set<AnyCancellable>()private lazy var callCoreView: CallCoreView = {let view = CallCoreView(frame: self.view.bounds)view.autoresizingMask = [.flexibleWidth, .flexibleHeight]view.setLayoutTemplate(.pip) // Set to Picture-in-Picture (PiP) layout modeview.isUserInteractionEnabled = false // Disable interaction to allow touch events to pass through to the parent viewreturn view}()override func viewDidLoad() {super.viewDidLoad()view.backgroundColor = UIColor(white: 0.1, alpha: 1.0)view.layer.cornerRadius = 10view.layer.masksToBounds = trueview.addSubview(callCoreView)// Add tap gesture recognizerlet tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap))view.addGestureRecognizer(tapGesture)// Delay observing state changes to prevent the window from closing immediately upon creationDispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] inself?.observeCallStatus()}}@objc private func handleTap() {tapGestureAction?()}/*** Observe call status changes* Automatically closes the floating window when the call ends.*/private func observeCallStatus() {CallStore.shared.state.subscribe(StatePublisherSelector<CallState, CallParticipantStatus>(keyPath: \\.selfInfo.status)).removeDuplicates().receive(on: DispatchQueue.main).sink { [weak self] status inif status == .none {// Call ended, post a notification to hide the floating windowNotificationCenter.default.post(name: NSNotification.Name("HideFloatingWindow"), object: nil)}}.store(in: &cancellables)}deinit {cancellables.removeAll()}}
import UIKitimport AtomicXCoreclass MainViewController: UIViewController {private var floatWindow: UIWindow?override func viewDidLoad() {super.viewDidLoad()// Observe notification to display the floating windowNotificationCenter.default.addObserver(self,selector: #selector(showFloatingWindow),name: NSNotification.Name("ShowFloatingWindow"),object: nil)// Observe notification to hide the floating windowNotificationCenter.default.addObserver(self,selector: #selector(hideFloatingWindow),name: NSNotification.Name("HideFloatingWindow"),object: nil)}/*** Displays the in-app floating window*/@objc private func showFloatingWindow() {// Check if a call is currently active/acceptedlet selfStatus = CallStore.shared.state.value.selfInfo.statusguard selfStatus == .accept else {return}// Prevent duplicate creation if the floating window already existsguard floatWindow == nil else { return }// ⚠️ CRITICAL: The window must be created using the current windowSceneguard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else {return}// Define floating window dimensions (9:16 aspect ratio)let pipWidth: CGFloat = 100let pipHeight: CGFloat = pipWidth * 16 / 9let pipX = UIScreen.main.bounds.width - pipWidth - 20let pipY: CGFloat = 100// Initialize the floating UIWindow and associate it with the windowScenelet window = UIWindow(windowScene: windowScene)window.windowLevel = .alert + 1 // Ensure it stays on top of the main UIwindow.backgroundColor = .clearwindow.frame = CGRect(x: pipX, y: pipY, width: pipWidth, height: pipHeight)// Initialize the floating window controllerlet floatVC = FloatWindowViewController()floatVC.tapGestureAction = { [weak self] inself?.openCallViewController()}window.rootViewController = floatVCself.floatWindow = window// Make the window visiblewindow.isHidden = falsewindow.makeKeyAndVisible()// Immediately restore the main window as the key window to maintain proper focusif let mainWindow = windowScene.windows.first(where: { $0 != window }) {mainWindow.makeKey()}}/*** Hides the in-app floating window*/@objc private func hideFloatingWindow() {floatWindow?.isHidden = truefloatWindow = nil}/*** Opens the call interface (triggered by tapping the floating window)*/private func openCallViewController() {// Dismiss the floating window firsthideFloatingWindow()// Retrieve the current top-most ViewControllerguard let topVC = getTopViewController() else {return}let callVC = CallViewController()callVC.modalPresentationStyle = .fullScreentopVC.present(callVC, animated: true)}/*** Helper method to find the top-most ViewController in the current view hierarchy*/private func getTopViewController() -> UIViewController? {guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,let keyWindow = windowScene.windows.first(where: { $0.isKeyWindow }),let rootVC = keyWindow.rootViewController else {return nil}var topVC = rootVCwhile let presentedVC = topVC.presentedViewController {topVC = presentedVC}return topVC}deinit {NotificationCenter.default.removeObserver(self)}}
import UIKitimport AtomicXCoreclass CallViewController: UIViewController {override func viewWillAppear(_ animated: Bool) {super.viewWillAppear(animated)// Post a notification to hide the floating window when entering the call interface.NotificationCenter.default.post(name: NSNotification.Name("HideFloatingWindow"), object: nil)}override func viewWillDisappear(_ animated: Bool) {super.viewWillDisappear(animated)// When leaving the call interface, check if the call is still active.let selfStatus = CallStore.shared.state.value.selfInfo.statusif selfStatus == .accept {// If the call is still ongoing, post a notification to display the floating window.NotificationCenter.default.post(name: NSNotification.Name("ShowFloatingWindow"), object: nil)}}}
Background Modes capability in Xcode's Signing & Capabilities and enable Audio, AirPlay, and Picture in Picture.import AtomicXCore// Fill Mode Enumerationenum PictureInPictureFillMode: Int, Codable {case fill = 0 // Aspect Fill: Scales the content to fill the view (may crop)case fit = 1 // Aspect Fit: Scales the content to fit the view (no cropping)}// User Video Regionstruct PictureInPictureRegion: Codable {let userId: String // Unique User IDlet width: Double // Width (0.0 - 1.0, relative to the canvas)let height: Double // Height (0.0 - 1.0, relative to the canvas)let x: Double // X-coordinate (0.0 - 1.0, relative to the top-left of the canvas)let y: Double // Y-coordinate (0.0 - 1.0, relative to the top-left of the canvas)let fillMode: PictureInPictureFillMode // Content fill modelet streamType: String // Stream type ("high" for HD or "low" for SD)let backgroundColor: String // Background color (e.g., Hex string)}// Canvas Configurationstruct PictureInPictureCanvas: Codable {let width: Int // Canvas width in pixelslet height: Int // Canvas height in pixelslet backgroundColor: String // Background color}// Picture-in-Picture Parametersstruct PictureInPictureParams: Codable {let enable: Bool // Toggle PiP functionalitylet cameraBackgroundCapture: Bool? // Whether to continue camera capture in the backgroundlet canvas: PictureInPictureCanvas? // Canvas configuration (Optional)let regions: [PictureInPictureRegion]? // List of user video regions (Optional)}// Picture-in-Picture Requeststruct PictureInPictureRequest: Codable {let api: String // API name/identifierlet params: PictureInPictureParams // Parameter payload}
configPictureInPicture to enable or disable PiP.let params = PictureInPictureParams(enable: true,cameraBackgroundCapture: true,canvas: nil,regions: nil)let request = PictureInPictureRequest(api: "configPictureInPicture",params: params)// Encode the request into a JSON stringlet encoder = JSONEncoder()if let data = try? encoder.encode(request),let jsonString = String(data: data, encoding: .utf8) {// Invoke the experimental API via the TUICallEngine instanceTUICallEngine.createInstance().callExperimentalAPI(jsonObject: jsonString)}
UIApplication.shared.isIdleTimerDisabled = true when the call UI appears, and restore it when the UI disappears.class CallViewController: UIViewController {override func viewDidLoad() {super.viewDidLoad()UIApplication.shared.isIdleTimerDisabled = true}override func viewWillDisappear(_ animated: Bool) {super.viewWillDisappear(animated)UIApplication.shared.isIdleTimerDisabled = false}}
Signing & Capabilities, add Background Modes, and enable:Audio, AirPlay, and Picture in PictureVoice over IPRemote notifications (optional, for offline push)<key>UIBackgroundModes</key><array><string>audio</string><string>voip</string><string>remote-notification</string></array>
viewDidLoad or before making/answering a call).import AVFoundationprivate func setupAudioSession() {let audioSession = AVAudioSession.sharedInstance()do {try audioSession.setCategory(.playAndRecord, options: [.allowBluetooth, .allowBluetoothA2DP])try audioSession.setActive(true)} catch {// Handle audio session configuration failure}}
.playback mode.private func setAudioSessionForRingtone() {let audioSession = AVAudioSession.sharedInstance()do {try audioSession.setCategory(.playback, options: [.allowBluetooth, .allowBluetoothA2DP])try audioSession.overrideOutputAudioPort(.speaker)try audioSession.setActive(true)} catch {// Handle configuration failure}}private func restoreAudioSessionForCall() {let audioSession = AVAudioSession.sharedInstance()do {try audioSession.setCategory(.playAndRecord, options: [.allowBluetooth, .allowBluetoothA2DP])try audioSession.setActive(true)} catch {// Handle restore failure}}
Feedback