Welcome to part two of SwiftUI Video Calling by Stream: Color, Image, Font, and Sound Theming Guide.
We will clone parts of the WhatsApp, Messenger, and Telegram video call UIs into a single SwiftUI app powered by Stream's Swift Video SDK. The purpose is to showcase how advanced customization support options of the SDK provide developers maximum flexibility in building unique VoIP apps.
You can apply the tips, tricks, and techniques from this tutorial to create personalized iOS live streaming, and audio room apps using our Video SDK.
Before You Start
- Check out part 1 of this tutorial
- Create a free Stream account: Try Stream Video & Audio to build apps using our SDK for free. Note: Your Stream account is optional to complete this tutorial.
- Get an overview of Stream’s Video & Audio SDKs.
- Explore the Stream Video Calling Demo App in your browser.
- Take an interactive tour of the Video Calling API.
Prerequisites
This tutorial focuses on the iOS SDK's customization rather than installation and getting started. Check our previous article or the documentation for guides on installing and setting up the SDK in Xcode. Alternatively, you can follow the tutorial by downloading the advanced customization starter project from GitHub.
UI Customization and View Slots Overview
When building video calling, audio calling, live streaming, and audio room iOS apps with Stream, the video SDK offers ready-made SwiftUI components you can implement directly for VoIP apps. Since all these components are standard SwiftUI views, you can design custom SwiftUI screens and inject them into the SDK's view slots of the default UI components.
The SDK also allows you to perform complete customization by using view swapping. You can accomplish that using our core low-level client SDK, which has no default UIs. Building completely custom UIs with the low-level client is beyond the scope of the article. Swapping views requires a custom view factory to replace the Video SDK's default ViewFcatory
protocol. Read more about view slots in our documentation.
Pinning Custom SwiftUI Views Into View Slots
The view slots in the SDK function similarly to ToolbarItem
placements in the iOS .toolbar
modifier or navigation bar. You define a set of views you want to place in the toolbar using placements, such as .topBarLeading
, .topBarTrailing
, and .keyboard
. In the Video SDK, you create standard SwiftUI views and instruct the SDK to place them into their corresponding view slots. The following are the available slots the Video SDK provides.
- Outgoing Call View: A screen for initiating a call.
- Call Controls View: A view for performing call operations, such as muting, accepting, and rejecting calls.
- Custom Label: A short description of call participants (for example, names)
- Video Layout: For custom video calling UIs, depending on the use case.
- Incoming Call View: A screen that presents a call initiated by another participant.
- Lobby View: A screen for configuring audio and video inputs before joining a meeting.
- Video Fallback: This screen presents a placeholder view when a participant’s video is disabled.
- Permission Request View: An alert view for setting different permissions for meeting participants.
- Audio Volume Indicator View: A visual indicator representing an active speaker.
- Network Quality Indicator: A view that notifies meeting participants about the quality of their networks.
- Speaking While Muted: A view that notifies participants about a disabled microphone while they try to speak.
- Top View: A top bar displaying call-related actions.
- Call View: An active or ongoing call screen.
- Video Participant View: A view that shows a participant’s name and profile image.
- Video Participants View: An adaptive grid view that shows the participants in a call.
You can perform advanced modifications to build custom video calling experiences using the following main steps.
- Create a composition for your custom views with SwiftUI.
- Create a
CustomViewFactory
conforming to the SDK'sViewFactory
protocol. - Add the custom composition to the
makeYourCustomUI
method. - Instruct the SDK to use the
CustomViewFactory
instead of the default.
Insert a Custom Outgoing Call View
Using IncomingCallView
or OutgoingCallView
, you can create standard SwiftUI views for representing incoming and outgoing calls. These screens give information about call participants and options to reject or accept a call. The image below depicts the Video SDK's default outgoing call screen.
In this section, we will swap the outgoing call screen with a custom SwiftUI view, similar to the outgoing call screen of Facebook Messenger, using the following steps.
Note: You can follow and apply the steps in this section for all other UI customization needs.
- Compose Your Custom Outgoing Call UI with Standard Views
Download the starter Xcode project, open it, and add a new Swift file in the Project navigator MessengerOutgoingCallView.swift. You can also use a new SwiftUI project with the iOS Video SDK installed and set up. Replace the content of MessengerOutgoingCallView.swift with the code below.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778// // MessengerOutgoingCallView.swift import SwiftUI import StreamVideo import StreamVideoSwiftUI struct MessengerOutgoingCallView: View { @ObservedObject var viewModel: CallViewModel @State var callCreated: Bool = false var body: some View { NavigationStack { ZStack { HostedViewController() .blur(radius: 2) VStack { Image(.leenarts) .resizable() .scaledToFit() .clipShape(Circle()) .frame(width: 120, height: 120) .padding(.top, 120) Text("Jeroen Leenarts") .font(.title.bold()) HStack(alignment: .bottom, spacing: 0) { Text("Calling") Image(systemName: "ellipsis") .font(.title) .bold() .symbolEffect( .variableColor.iterative.dimInactiveLayers.nonReversing ) } HStack { VStack(spacing: 32) { Image(systemName: "theatermask.and.paintbrush.fill") Image(systemName: "face.dashed.fill") Image(systemName: "lightbulb.fill") Image(systemName: "wand.and.stars.inverse") } .font(.title.bold()) Spacer() } .padding(.top, 64) .padding(.horizontal, 16) Spacer() MessengerControlsView(viewModel: viewModel) } Spacer() } .ignoresSafeArea() .toolbar { ToolbarItemGroup(placement: .topBarLeading) { Image(systemName: "chevron.down") Text("Jeroen Leenarts") .bold() } ToolbarItemGroup(placement: .topBarTrailing) { Image(systemName:"person.fill.badge.plus") .symbolEffect( .pulse ) Image(systemName:"ellipsis") } } } } }
The code above draws the custom outgoing call screen similar to Messenger's. It contains top navigation, the callee's profile image, video enhancement controls, call controls, and a background video from the iOS device's camera feed. The HostedViewController()
view contains the background video. You can find it in the Livecamera folder in the Project navigator. MessengerControlsView(viewModel: viewModel)
implements custom call controls like Messenger's. We will create that in the next section.
- Add a Custom ViewFactory
Add another Swift file, https://gist.github.com/amosgyamfi/90372460298265113526996fe121bef2. The function of this file is to provide all the available customization methods used for overriding the various UI components.
12345678910111213141516import SwiftUI import StreamVideo import StreamVideoSwiftUI class CustomViewFactory: ViewFactory { // 1. Custom Outgoing Call func makeOutgoingCallView(viewModel: CallViewModel) -> some View { // Here you can provide your custom view. // In this example, we are re-using the standard one, while also adding an overlay. let outgoingView = DefaultViewFactory.shared.makeOutgoingCallView(viewModel: viewModel) return outgoingView.overlay( MessengerOutgoingCallView(viewModel: viewModel) ) } }
-
Override the SDK’s
OutGoingCallView
with themakeOutgoingCallView
method
In the sample code above, we created aCustomViewFactory
class that conforms to the SDK'sViewFactory
protocol. Then, we used themakeOutgoingCallView
method to override the default outgoing call view with the content of MessengerOutgoingCallView.swift we created in step 1. -
Register the
CustomViewFactory
class as a parameter of the SDK’sCallContainer
, responsible for rendering fully featured calling UIs
Let's update our app'sScene
configuration to use theCustomViewFactory
class. You can add this implementation to your main app's file. In this example, we add it in theScene
section of BasicAdvancedThemingApp.swift.
The SDK uses theCallContainer
object to render different calling UIs. In the app'sScene
, if the call is successful, we tell theCallContainer
object to use theCustomViewFactory
implementation instead of the one the SDK providesCallContainer(viewFactory: CustomViewFactory(), viewModel: viewModel)
.
1234567891011121314151617181920var body: some Scene { WindowGroup { VStack { if viewModel.call != nil { //CallContainer(viewFactory: DefaultViewFactory.shared, viewModel: viewModel) CallContainer(viewFactory: CustomViewFactory(), viewModel: viewModel) } else { Text("loading...") } }.onAppear { Task { guard viewModel.call == nil else { return } //viewModel.joinCall(callType: .default, callId: callId) // Notify an outgoing call with a custom ringtone viewModel.startCall(callType: .default, callId: callId, members: [], ring: true) } } } }
Request and present the outgoing call screen
To present the outgoing call screen after initiating a call, you should set the boolean parameter ring: true
in the startCall
method of the view model viewModel.startCall(callType: .default, callId: callId, members: [], ring: true)
.
When you run the app on an iPhone, you will notice the default outgoing call screen (left) appears as the screen on the right in the image below.
Bravo! You have implemented a custom outgoing call screen using the Stream's iOS Video SDK. You can use this section's steps and techniques to implement a custom incoming call screen in the SDK.
Swap the Call Controls View
The CallControls
component allows you to implement different button controls to perform call-related actions, such as rejecting, answering, muting, toggling the camera on and off, adding reactions, and video effects. Implementing custom call controls follows similar steps as adding a personalized outgoing call screen in the previous section. We will add it using only the following two steps. The other remaining steps are the same for all UI components swapping.
The image above illustrates the default call controls. Let's change the call control symbols and background to Messenger's.
- Create a new MessengerControlsView.swift file and substitute its content with the code below.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455// // MessengerControlsView.swift import SwiftUI import StreamVideoSwiftUI struct MessengerControlsView: View { @ObservedObject var viewModel: CallViewModel var body: some View { HStack(spacing: 32) { Button { viewModel.toggleCameraEnabled() } label: { Image(systemName: "video.fill") .font(.title) .foregroundStyle(.secondary) } .padding(.horizontal, 16) .buttonStyle(.plain) Button { viewModel.toggleMicrophoneEnabled() } label: { Image(systemName: "mic.fill") .font(.title) } .buttonStyle(.plain) Button { viewModel.toggleCameraPosition() } label: { Image(systemName: "rectangle.stack.badge.play.fill") .font(.title) .foregroundStyle(.secondary) } .buttonStyle(.plain) Button { viewModel.toggleCameraPosition() } label: { Image(systemName: "arrow.triangle.2.circlepath.camera.fill") .font(.title) } .buttonStyle(.plain) HangUpIconView(viewModel: viewModel) } .frame(maxWidth: .infinity) .frame(height: 85) .background(.quaternary) } }
- In CustomUIFactory.swift, use the
makeCallControlsView
method to swap the default call controls.
1234// 2. Custom Call Controls public func makeCallControlsView(viewModel: CallViewModel) -> some View { MessengerControlsView(viewModel: viewModel) }
When you run the app, the call controls menu will look like the image below. It is similar to that of Facebook Messenger.
Make a Custom Top View
The CallTopView
component appears as a top navigation bar when a call has more than one participant. It displays symbols for inviting people to join a call, changing the call layout, and a back button. Let's modify it and add a similar implementation to WhatsApp.
- Add a new file, WhatsAppCallTopView.swift, and use the code below for its content.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556// // WhatsAppCallTopView.swift import SwiftUI import StreamVideoSwiftUI struct WhatsAppCallTopView: View { var body: some View { HStack { Button { } label: { Image(systemName: "chevron.backward") } .font(.title2) .bold() Button { } label: { Text("33") } Spacer() Image(.leenarts) .resizable() .scaledToFit() .clipShape(Circle()) .frame(width: 48, height: 48) Text("Jeroen Leenarts") .bold() Spacer() Spacer() Button { } label: { Image(systemName: "video") } .font(.title2) .bold() Button { } label: { Image(systemName: "phone") } .font(.title2) .bold() } .padding() } }
- Tell our
CustomViewFactory
class to replace the defaultCallTopView
with the content of WhatsAppCallTopView.swift using themakeCallTopView
method.
1234// 3. Custom CallTopView public func makeCallTopView(viewModel: CallViewModel) -> some View { WhatsAppCallTopView() }
Running the app will display a top view similar to the image below.
Use a Custom Active Call Screen
The CallView
object presents an active call screen after a connected call. It displays controls for call-related actions and participants' information. Let's use it to show a custom ongoing call screen that mimics Telegram. The image below illustrates the SDK's default active/ongoing call screen.
Note: To display an ongoing call screen, you should run the app on an iPhone and use the companion Stream Video Web app to join the call with the same callId
.
- Compose the active call views in TelegramActiveCallView.swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140// // TelegramActiveCallView.swift // BasicAdvancedTheming // // Created by amos.gyamfi@getstream.io on 12.9.2023. // import SwiftUI import StreamVideoSwiftUI struct TelegramActiveCallView: View { @ObservedObject var viewModel: CallViewModel let tv = "📺" let eightEmoji = "8️⃣" let castle = "🏰" let animal = "🐼" var body: some View { NavigationStack { ZStack { Image("martinmartz") .resizable() .scaledToFill() .ignoresSafeArea() VStack { VStack { Text("Hilder2") .font(.title) Text(Date.now.formatted()) .font(.caption) .foregroundStyle(.secondary) } Spacer() VStack(spacing: 64) { HStack(spacing: 32) { Button { viewModel.toggleCameraEnabled() } label: { VStack { Image(systemName: "video.circle.fill") .font(.largeTitle) Text("Camera") } } .padding(.horizontal, 16) .buttonStyle(.plain) Button { viewModel.toggleMicrophoneEnabled() } label: { VStack{ Image(systemName: "mic.circle.fill") .font(.largeTitle) .symbolRenderingMode(.hierarchical) Text("Mute") } } .buttonStyle(.plain) Button { viewModel.toggleCameraPosition() } label: { VStack { Image(systemName: "rectangle.stack.badge.play.fill") .font(.largeTitle) .symbolRenderingMode(.hierarchical) Text("Flip") } } .buttonStyle(.plain) Button { viewModel.toggleCameraPosition() } label: { VStack { Image(systemName: "speaker.wave.2.circle.fill") .font(.largeTitle) .symbolRenderingMode(.hierarchical) Text("Speaker") } } .buttonStyle(.plain) } .padding(.top) HangUpIconView(viewModel: viewModel) .scaleEffect(1.4) .padding(.bottom) } .frame(maxWidth: .infinity) //.frame(height: 85) .background(.quaternary) } .toolbar { ToolbarItem(placement: .topBarLeading) { Button { } label: { Image(systemName: "chevron.backward") } .buttonStyle(.plain) } ToolbarItemGroup(placement: .topBarTrailing) { Button { } label: { Text(tv) } .buttonStyle(.plain) Button { } label: { Text(eightEmoji) } .buttonStyle(.plain) Button { } label: { Text(castle) } .buttonStyle(.plain) Button { } label: { Text(animal) } .buttonStyle(.plain) } } } } } }
The sample code above creates a custom call header view and changes the layout of the call control menu.
Use the makeCallView
method to substitute the default one by adding the implementation in CustomUIFactory.swift.
1234// 4. Custom Active Call screen public func makeCallView(viewModel: CallViewModel) -> some View { TelegramActiveCallView(viewModel: viewModel) }
When you join a call with two or more people, the active/ongoing call screen will look similar to the image below.
Wrap Up
This article demonstrated advanced theming and customization techniques of the Stream's SwiftUI Video SDK. It allows development teams to effortlessly build bespoke video calling, live streaming, and audio room experiences using the customization techniques outlined here. If you want to create a SwiftUI VoIP app for other use cases, head to the UI Cookbook section of our documentation.