```html How to Build a Video Call App in SwiftUI (2026)

How to Build a Video Call App in SwiftUI

A video call app lets users join real-time, face-to-face sessions over the internet using their iPhone camera and microphone. It's a strong fit for indie developers building telehealth consults, tutoring platforms, team check-in tools, or family communication apps.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Advanced Estimated time: 2–4 weeks Updated: May 12, 2026

Prerequisites

Architecture overview

The app follows MVVM. SwiftData persists call history via a CallSession model. CallViewModel owns the WebRTC peer connection lifecycle, bridges to a SignalingClient over WebSocket, and publishes local and remote RTCVideoTrack references that SwiftUI views consume via @StateObject. RTCVideoView is wrapped in a UIViewRepresentable so it can slot into the SwiftUI hierarchy alongside native controls.

VideoCallApp/
├── Models/
│   ├── CallSession.swift      # SwiftData @Model
│   └── Participant.swift      # Contact info struct
├── Views/
│   ├── CallListView.swift     # Recent calls + dial pad
│   ├── ActiveCallView.swift   # Live video UI
│   └── CallControlsBar.swift  # Mute / camera / hang-up
├── ViewModels/
│   └── CallViewModel.swift    # WebRTC state machine
├── WebRTC/
│   └── SignalingClient.swift  # WebSocket SDP relay
└── PrivacyInfo.xcprivacy

Step-by-step

1. Data model

Store call history with SwiftData so users can review past sessions and redial recent contacts.

import SwiftData
import Foundation

@Model
final class CallSession {
    var id: UUID
    var participantName: String
    var participantID: String      // username or phone number
    var startedAt: Date
    var endedAt: Date?
    var callType: String           // "video" | "audio"

    init(participantName: String,
         participantID: String,
         callType: String = "video") {
        self.id              = UUID()
        self.participantName = participantName
        self.participantID   = participantID
        self.startedAt       = Date()
        self.callType        = callType
    }

    var durationFormatted: String {
        guard let end = endedAt else { return "Active" }
        let secs = Int(end.timeIntervalSince(startedAt))
        return String(format: "%d:%02d", secs / 60, secs % 60)
    }
}

2. Core UI

Layer remote video full-bleed, a draggable local PiP tile, and a controls bar — all composited in a single ZStack.

struct ActiveCallView: View {
    @StateObject private var vm: CallViewModel
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        ZStack {
            RemoteVideoView(track: vm.remoteVideoTrack)
                .ignoresSafeArea()
                .background(Color.black)

            VStack {
                Spacer()
                HStack {
                    Spacer()
                    LocalVideoView(track: vm.localVideoTrack)
                        .frame(width: 96, height: 136)
                        .clipShape(RoundedRectangle(cornerRadius: 12))
                        .overlay(
                            RoundedRectangle(cornerRadius: 12)
                                .stroke(Color.white.opacity(0.3), lineWidth: 1)
                        )
                        .padding(16)
                }
                CallControlsBar(
                    isMuted: vm.isMuted,
                    isCameraOff: vm.isCameraOff,
                    onMute: { vm.toggleMute() },
                    onCamera: { vm.toggleCamera() },
                    onHangUp: { vm.endCall(); dismiss() }
                )
                .padding(.bottom, 48)
            }
        }
        .task { await vm.connect() }
        .onDisappear { vm.endCall() }
    }
}

3. WebRTC video conferencing

Initialise RTCPeerConnectionFactory, configure ICE servers, attach local tracks, and send the SDP offer through your signaling client.

import WebRTC

@MainActor
final class CallViewModel: NSObject, ObservableObject {
    @Published var remoteVideoTrack: RTCVideoTrack?
    @Published var localVideoTrack: RTCVideoTrack?
    @Published var isMuted = false
    @Published var isCameraOff = false

    private var pc: RTCPeerConnection?
    private let factory: RTCPeerConnectionFactory

    override init() {
        RTCInitializeSSL()
        factory = RTCPeerConnectionFactory(
            encoderFactory: RTCDefaultVideoEncoderFactory(),
            decoderFactory: RTCDefaultVideoDecoderFactory())
        super.init()
    }

    func connect() async {
        let cfg = RTCConfiguration()
        cfg.iceServers = [RTCIceServer(urlStrings: [
            "stun:stun.l.google.com:19302"])]
        cfg.sdpSemantics = .unifiedPlan
        let constraints = RTCMediaConstraints(
            mandatoryConstraints: nil, optionalConstraints: nil)
        pc = factory.peerConnection(with: cfg,
                                    constraints: constraints,
                                    delegate: self)
        attachLocalMedia()
        // Send offer via your SignalingClient after this
    }

    private func attachLocalMedia() {
        let audio = factory.audioTrack(withTrackId: "a0")
        let videoSrc = factory.videoSource()
        localVideoTrack = factory.videoTrack(with: videoSrc, trackId: "v0")
        pc?.add(audio, streamIds: ["s0"])
        if let vt = localVideoTrack { pc?.add(vt, streamIds: ["s0"]) }
    }

    func toggleMute() {
        isMuted.toggle()
        pc?.transceivers.first { $0.mediaType == .audio }?
            .sender.track?.isEnabled = !isMuted
    }
}

4. Privacy Manifest setup

Add PrivacyInfo.xcprivacy to your app target declaring camera, microphone, and audio data collection — missing this will trigger an App Store rejection.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>NSPrivacyTracking</key><false/>
  <key>NSPrivacyCollectedDataTypes</key>
  <array>
    <dict>
      <key>NSPrivacyCollectedDataType</key>
      <string>NSPrivacyCollectedDataTypeAudioData</string>
      <key>NSPrivacyCollectedDataTypeLinked</key><true/>
      <key>NSPrivacyCollectedDataTypePurposes</key>
      <array>
        <string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string>
      </array>
    </dict>
  </array>
  <key>NSPrivacyAccessedAPITypes</key>
  <array>
    <dict>
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array><string>CA92.1</string></array>
    </dict>
  </array>
</dict>
</plist>

Common pitfalls

Adding monetization: Subscription

Use StoreKit 2's Product.products(for:) to offer a free tier capped at 10-minute calls and a "Pro" subscription that unlocks unlimited duration, group calls up to four participants, and screen sharing. Call Transaction.currentEntitlements on app launch to silently restore purchases — App Store review requires subscriptions to restore without a manual button tap. Configure subscription groups in App Store Connect with a clear monthly-to-annual upgrade path, and enable Family Sharing to lower churn among household users.

Shipping this faster with Soarias

Soarias scaffolds the full Xcode project with your SwiftData models pre-wired, PrivacyInfo.xcprivacy populated for camera and microphone access, NSCameraUsageDescription and NSMicrophoneUsageDescription strings in Info.plist, and fastlane lanes configured for TestFlight uploads and App Store screenshots. For a video call app it also generates the UIViewRepresentable wrappers for RTCVideoView so you skip the bridge boilerplate entirely.

At advanced complexity, setup work — project config, Privacy Manifest, entitlements, ASC metadata — routinely consumes 4–6 hours before you write a line of product code. Soarias compresses that to under 30 minutes, freeing you to spend all 2–4 weeks on the WebRTC integration and call UX that actually differentiates your app.

Related guides

FAQ

Do I need a paid Apple Developer account?

Yes. A $99/year Apple Developer Program membership is required to distribute on TestFlight or the App Store. A free account lets you sideload the app onto your own device for local testing, but you cannot distribute to others or submit for review without the paid membership.

How do I submit this to the App Store?

Archive the app in Xcode via Product → Archive, then use the Organizer to validate and upload to App Store Connect. Fill in the app metadata, privacy nutrition labels, and screenshots for every supported device size. First-time communication apps typically clear review in one to three business days, though Apple may request a demo account if your app requires authentication to test the core call feature.

Do I need to build my own signaling server?

WebRTC handles media transport but not signaling — you need a separate mechanism to exchange SDP offers, answers, and ICE candidates between peers. A self-hosted WebSocket server in Node.js takes around 50 lines and is fine for development. For production, a managed SDK like Twilio Programmable Video or Daily.co bundles signaling, TURN infrastructure, and recording, which is usually worth the cost compared to operating your own relay servers.

Last reviewed: 2026-05-12 by the Soarias team.

```