130 Widgets

Building tools. Learning to build tools. Learning to build learning tools.

6. Bridging UIKit and SwiftUI Arch

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

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: Setup, Not Configuration

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.

Architecture Concept

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: Sync, Don't Rebuild

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.

The Coordinator Pattern

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.

iOS Concept

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.

The PiP Callback

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.

Summary

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.