Building tools. Learning to build tools. Learning to build learning tools.
SwiftUI is Apple’s preferred modern UI framework, but it can’t do everything.
Large parts of the iOS ecosystem — including most media players, maps, cameras, and
rich text editors — are built on UIKit, the older, imperative framework.
KSPlayer exposes a UIView subclass, not a SwiftUI View. To use it,
we need a bridge.
UIViewRepresentable is a protocol that wraps a UIKit view (UIView)
in a SwiftUI-compatible shell. Any type conforming to this protocol can appear in a SwiftUI
view hierarchy as if it were a native SwiftUI view.
The protocol requires three methods (plus an optional fourth):
| Method | When Called | Responsibility |
|---|---|---|
makeUIView(context:) |
Once, when SwiftUI first places this view in the hierarchy | Create and configure the UIView. Return it. |
updateUIView(_:context:) |
Every time SwiftUI re-renders this view (which can be frequent) | Sync any changes from SwiftUI state into the UIKit view. |
static dismantleUIView(_:coordinator:) |
When SwiftUI removes this view from the hierarchy | Clean up resources (stop playback, release references). |
makeCoordinator() |
Before makeUIView, if implemented |
Create a Coordinator object that handles delegate callbacks. |
Here’s CameraView’s full implementation:
struct CameraView: UIViewRepresentable {
let url: URL
@Binding var streamState: StreamState
var onPipReady: ((@escaping () -> Void) -> Void)?
func makeCoordinator() -> Coordinator {
Coordinator(streamState: $streamState)
}
func makeUIView(context: Context) -> UIView {
let containerView = UIView()
containerView.backgroundColor = .black
let options = KSOptions()
options.avOptions["rtsp_transport"] = "tcp"
options.avOptions["tls_verify"] = "0"
options.nobuffer = true
options.codecLowDelay = true
options.maxAnalyzeDuration = 1_000_000
options.probesize = 500_000
options.registerRemoteControll = false
options.canStartPictureInPictureAutomaticallyFromInline = true
let layer = KSPlayerLayer(url: url, options: options, delegate: context.coordinator)
context.coordinator.playerLayer = layer
layer.play()
DispatchQueue.main.async {
self.onPipReady?({
layer.isPipActive.toggle()
})
}
return containerView
}
func updateUIView(_ uiView: UIView, context: Context) {
if let playerLayer = context.coordinator.playerLayer,
let playerView = playerLayer.player.view,
playerView.superview == nil {
playerView.frame = uiView.bounds
playerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
uiView.addSubview(playerView)
}
}
static func dismantleUIView(_ uiView: UIView, coordinator: Coordinator) {
coordinator.playerLayer?.stop()
coordinator.playerLayer = nil
}
}
makeUIView runs once. It creates a black UIView as a
container, configures a KSPlayerLayer with the stream URL and options,
and starts playback immediately. This is the right place for one-time setup: allocating
the player, setting delegates, and starting the stream.
Notice that the KSPlayer view (playerLayer.player.view) is not
added to the container in makeUIView. Instead, it’s added in
updateUIView. This is because KSPlayer doesn’t create its render view
synchronously — the player needs to negotiate with the stream first. By checking
in updateUIView (which runs after every SwiftUI update), the player view
gets added as soon as it exists.
makeUIView is called by SwiftUI on the main thread. Heavy initialization
or blocking network calls here would freeze the UI. KSPlayer starts its RTSP
negotiation on a background thread internally; makeUIView just hands it
the URL and options and returns quickly. This is the right pattern: create the player,
give it work, return immediately.
updateUIView is called by SwiftUI every time any state the parent
view depends on changes. This can happen dozens of times per second in an animated app.
The implementation is defensive:
func updateUIView(_ uiView: UIView, context: Context) {
if let playerLayer = context.coordinator.playerLayer,
let playerView = playerLayer.player.view,
playerView.superview == nil { // ← only add it once
playerView.frame = uiView.bounds
playerView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
uiView.addSubview(playerView)
}
}
The playerView.superview == nil check ensures the player view is only added
once, even though updateUIView might be called hundreds of times. Without
this guard, you’d add the player view repeatedly, each time on top of the previous
one — a classic UIKit memory and layout bug.
autoresizingMask = [.flexibleWidth, .flexibleHeight] is UIKit’s
auto-layout shorthand for “fill the parent.” When the container view resizes
(on rotation, for example), the player view stretches with it automatically.
UIKit uses the delegate pattern for callbacks — a separate object
that implements a protocol to receive events. SwiftUI doesn’t have delegates; it
uses bindings and closures. The Coordinator class is the bridge between them.
class Coordinator: NSObject, KSPlayerLayerDelegate {
var playerLayer: KSPlayerLayer?
var streamState: Binding<StreamState>
init(streamState: Binding<StreamState>) {
self.streamState = streamState
}
func player(layer: KSPlayerLayer, state: KSPlayerState) {
DispatchQueue.main.async {
switch state {
case .preparing, .readyToPlay, .buffering:
if case .connecting = self.streamState.wrappedValue { }
else { self.streamState.wrappedValue = .connecting }
case .bufferFinished:
withAnimation(.easeIn(duration: 0.5)) {
self.streamState.wrappedValue = .live
}
case .error:
withAnimation {
self.streamState.wrappedValue = .error("Could not connect to printer camera.")
}
default:
break
}
}
}
// Required by protocol but not used:
func player(layer: KSPlayerLayer, currentTime: TimeInterval, totalTime: TimeInterval) {}
func player(layer: KSPlayerLayer, finish error: Error?) {
guard let error = error else { return }
DispatchQueue.main.async {
withAnimation {
self.streamState.wrappedValue = .error(error.localizedDescription)
}
}
}
// Required by protocol but not used:
func player(layer: KSPlayerLayer, bufferedCount: Int, consumeTime: TimeInterval) {}
}
The Coordinator is a class (reference type) that inherits from
NSObject. The NSObject base class is required by
KSPlayerLayerDelegate because KSPlayer is written with Objective-C
interoperability in mind.
Coordinator holds a Binding<StreamState> — the same
binding that ContentView passed to CameraView via
$streamState. When KSPlayer fires a delegate callback, the coordinator
writes to streamState.wrappedValue, which updates ContentView’s
@State property, which triggers a SwiftUI re-render.
Always update SwiftUI state on the main thread. KSPlayer fires delegate
callbacks on a background thread. Writing to a @State or @Binding
property from a background thread causes a runtime warning (and undefined behavior in
older iOS versions). Every callback in Coordinator wraps its state
update in DispatchQueue.main.async { ... } to hop back to the main thread.
This is standard UIKit-to-SwiftUI bridging practice.
Picture-in-Picture is triggered by calling layer.isPipActive.toggle() on the
KSPlayerLayer. But the player layer is created inside makeUIView,
which runs deep in the UIKit layer — not directly accessible from
ContentView’s button actions.
The solution is the onPipReady closure:
// CameraView property
var onPipReady: ((@escaping () -> Void) -> Void)?
// In makeUIView — once the layer is ready, call onPipReady with an action
DispatchQueue.main.async {
self.onPipReady?({
layer.isPipActive.toggle()
})
}
// ContentView stores the action
@State private var pipAction: (() -> Void)?
CameraView(url: url, streamState: $streamState) { action in
pipAction = action
}
// ContentView's PiP button uses it
Button { pipAction?() } label: { ... }
CameraView calls onPipReady with a closure that knows how to
toggle PiP. ContentView stores that closure as pipAction.
The PiP button calls pipAction?(). The indirection lets the UIKit layer
hand a capability up to the SwiftUI layer without breaking the ownership hierarchy.
CameraView conforms to UIViewRepresentable, wrapping KSPlayer’s
KSPlayerLayer. makeUIView creates and starts the player.
updateUIView adds the player’s render view once it’s ready.
dismantleUIView stops playback on teardown. The Coordinator
implements KSPlayerLayerDelegate and translates player state changes into
StreamState updates, always dispatching to the main thread.