class VideoWithChatViewFactory: ViewFactory {
static let shared = VideoWithChatViewFactory()
private init() {}
func makeCallControlsView(viewModel: CallViewModel) -> some View {
ChatCallControls(viewModel: viewModel)
}
}
UIKit Customizations
In order to enable a smoother video integration in your UIKit projects, we provide UIKit wrappers over our SwiftUI components. In the following section, we will see how we can customize them.
View Factory Customizations
As described in the customizing views section, we allow swapping of the default UI components with your custom ones. To achieve this, you will need to create your own implementation of the ViewFactory
protocol.
In our UIKit components, we expose a CallViewController
that can be easily used in UIKit based projects. The CallViewController
uses the default ViewFactory
implementation from the SwiftUI SDK. However, you can easily inject your own implementation, by subclassing the CallViewController
and providing your own implementation of the setupVideoView
method.
For example, let’s extend the video call controls with a chat icon. In order to do this, you will need to implement the makeCallControlsView
method in the ViewFactory
:
At the end of this guide, there’s a possible implementation of ChatCallControls
, that you can customize as you see fit.
Next, we need to inject our custom implementation into the StreamVideo UIKit components. In order to do this, we need to create a subclass of the CallViewController
.
class CallChatViewController: CallViewController {
override func setupVideoView() {
let videoView = makeVideoView(with: VideoWithChatViewFactory.shared)
view.embed(videoView)
}
}
Now, you can use the CallChatViewController
in your app. There are several options how you can add the view controller in your app’s view hierarchy.
One option is to use the standard navigation patterns, such as pushing or presenting the view controller over your app’s views. You can do that, if you don’t need the minimized call option. However, if you want to allow users to use your app while still being in call, we recommend to add the CallViewController
(or its subclasses) as a subview.
Here’s one example implementation that adds the view in the application window (this is needed in case you want to also navigate throughout your app while in a call):
@MainActor
class CallViewHelper {
static let shared = CallViewHelper()
private var callView: UIView?
private init() {}
func add(callView: UIView) {
guard self.callView == nil else { return }
guard let window = UIApplication.shared.windows.first else {
return
}
callView.isOpaque = false
callView.backgroundColor = UIColor.clear
self.callView = callView
window.addSubview(callView)
}
func removeCallView() {
callView?.removeFromSuperview()
callView = nil
}
}
Finally, in your app, you can add the CallViewController
with the following code:
@objc private func didTapStartButton() {
let next = CallChatViewController.makeCallChatController(with: self.callViewModel)
next.startCall(callType: "default", callId: text, members: selectedParticipants)
CallViewHelper.shared.add(callView: next.view)
}
You can also listen to call events, and show/hide the calling view depending on the state:
private func listenToIncomingCalls() {
callViewModel.$callingState.sink { [weak self] newState in
guard let self = self else { return }
if case .incoming(_) = newState, self == self.navigationController?.topViewController {
let next = CallChatViewController.makeCallChatController(with: self.callViewModel)
CallViewHelper.shared.add(callView: next.view)
} else if newState == .idle {
CallViewHelper.shared.removeCallView()
}
}
.store(in: &cancellables)
}
You can find fully working sample apps with our UIKit components in our sample apps repository.
ChatCallControls Implementation
For reference, here’s the ChatCallControls
mentioned above.
import SwiftUI
import struct StreamChatSwiftUI.ChatChannelView
import struct StreamChatSwiftUI.UnreadIndicatorView
import StreamVideo
import StreamVideoSwiftUI
struct ChatCallControls: View {
@Injected(\.streamVideo) var streamVideo
private let size: CGFloat = 50
@ObservedObject var viewModel: CallViewModel
@StateObject private var chatHelper = ChatHelper()
@Injected(\.images) var images
@Injected(\.colors) var colors
public init(viewModel: CallViewModel) {
self.viewModel = viewModel
}
public var body: some View {
VStack {
HStack {
Button(
action: {
withAnimation {
chatHelper.chatShown.toggle()
}
},
label: {
CallIconView(
icon: Image(systemName: "message"),
size: size,
iconStyle: chatHelper.chatShown ? .primary : .transparent
)
.overlay(
chatHelper.unreadCount > 0 ?
TopRightView(content: {
UnreadIndicatorView(unreadCount: chatHelper.unreadCount)
})
: nil
)
})
.frame(maxWidth: .infinity)
Button(
action: {
viewModel.toggleCameraEnabled()
},
label: {
CallIconView(
icon: (viewModel.callSettings.videoOn ? images.videoTurnOn : images.videoTurnOff),
size: size,
iconStyle: (viewModel.callSettings.videoOn ? .primary : .transparent)
)
}
)
.frame(maxWidth: .infinity)
Button(
action: {
viewModel.toggleMicrophoneEnabled()
},
label: {
CallIconView(
icon: (viewModel.callSettings.audioOn ? images.micTurnOn : images.micTurnOff),
size: size,
iconStyle: (viewModel.callSettings.audioOn ? .primary : .transparent)
)
}
)
.frame(maxWidth: .infinity)
Button(
action: {
viewModel.toggleCameraPosition()
},
label: {
CallIconView(
icon: images.toggleCamera,
size: size,
iconStyle: .primary
)
}
)
.frame(maxWidth: .infinity)
Button {
viewModel.hangUp()
} label: {
images.hangup
.applyCallButtonStyle(
color: colors.hangUpIconColor,
size: size
)
}
.frame(maxWidth: .infinity)
}
if chatHelper.chatShown {
if let channelController = chatHelper.channelController {
ChatChannelView(
viewFactory: ChatViewFactory.shared,
channelController: channelController
)
.frame(height: chatHeight)
.preferredColorScheme(.dark)
.onAppear {
chatHelper.markAsRead()
}
} else {
Spacer()
Text("Chat not available")
Spacer()
}
}
}
.frame(maxWidth: .infinity)
.frame(height: chatHelper.chatShown ? chatHeight + 100 : 100)
.background(
colors.callControlsBackground
.cornerRadius(16)
.edgesIgnoringSafeArea(.all)
)
.onReceive(viewModel.$callParticipants, perform: { output in
if viewModel.callParticipants.count > 1 {
chatHelper.update(memberIds: Set(viewModel.callParticipants.map(\.key)))
}
})
}
private var chatHeight: CGFloat {
(UIScreen.main.bounds.height / 3 + 50)
}
}
struct EqualSpacingHStack: View {
var views: [AnyView]
var body: some View {
HStack(alignment: .top) {
ForEach(0..<views.count, id:\.self) { index in
Spacer()
views[index]
Spacer()
}
}
}
}
final class ChatHelper: ObservableObject {
@Published var chatShown: Bool = false
@Published var unreadCount: Int = 0
@Published var channelController: ChatChannelController?
init() {}
func markAsRead() { /* Your implementation here */ }
func update(memberIds: Set<String>) { /* Your implementation here */ }
}