UI Customization
How to customize the CameraView UI to match your app's design.
Prerequisites
This document assumes you have already read Basic Usage. Learn how to create and initialize a CameraController first.
Overview
CameraView provides the pet detection camera UI, and you can customize it in the following ways:
- Add overlay UI - Place UI on top of CameraView with a ZStack (progress, buttons, guidance, etc.)
- Place a guide over the tracker - UI that is automatically positioned over the detected object using
floatingGuideContent - Fully custom implementation - Build from scratch by injecting your own
AVCaptureSession
In most cases, combining approaches 1 and 2 is sufficient.
Overlay UI Development Guide
You can place additional UI on top of CameraView using a ZStack. Most UI — buttons, progress indicators, status messages, and so on — is implemented this way.
Basic Pattern
struct CameraScreenView: View {
@ObservedObject var controller: CameraController
@Environment(\.dismiss) var dismiss
var body: some View {
ZStack {
CameraView(controller: controller) {
EmptyView() // or floatingGuideContent
}
VStack {
HStack {
Button("Close") { dismiss() }
Spacer()
}
Spacer()
statusOverlay
}
.padding()
}
}
@ViewBuilder
private var statusOverlay: some View {
VStack(spacing: 12) {
Text(statusMessage)
.font(.headline)
.foregroundColor(.white)
.padding()
.background(Color.black.opacity(0.7))
.cornerRadius(12)
if case .processing = controller.detectionStatus,
controller.currentDetectionProgress > 0 {
ProgressView(value: Double(controller.currentDetectionProgress) / 100.0)
.progressViewStyle(LinearProgressViewStyle(tint: .white))
.frame(maxWidth: 300)
Text("\(controller.currentDetectionProgress)%")
.font(.caption)
.foregroundColor(.white.opacity(0.8))
}
}
}
private var statusMessage: String {
switch controller.detectionStatus {
case .noObject: return "Please fit your pet into the screen"
case .processing: return "Detecting..."
case .detected: return "Perfect! Please wait a moment"
case .finished: return "Capture complete"
case .failed(let reason): return failureMessage(for: reason)
}
}
private func failureMessage(for reason: DetectionFailureReason) -> String {
switch reason {
case .tooFarAway: return "Please move a little closer"
case .tooClose: return "Too close"
case .tooBright: return "Too bright"
case .tooDark: return "Lighting is too dark"
case .tooBlurred: return "Blur detected"
default: return "Please try again"
}
}
}Key points:
- Wrap CameraView in a ZStack to place UI freely
- Observe the state of
CameraControllerwith@ObservedObjectto update the UI dynamically - The
detectionStatusswitch must handle all 5 cases, including.finished
Example: Color and Icon by Status
private var statusIcon: String {
switch controller.detectionStatus {
case .noObject: return "viewfinder"
case .processing: return "camera.metering.center.weighted"
case .detected: return "checkmark.circle.fill"
case .finished: return "checkmark.seal.fill"
case .failed: return "exclamationmark.triangle.fill"
}
}
private var statusColor: Color {
switch controller.detectionStatus {
case .noObject: return .gray
case .processing: return .blue
case .detected: return .green
case .finished: return .green
case .failed: return .red
}
}Example: Bounding Box Visualization
If you want to visually highlight the detected region, you can use detectedObjectNormalizedRect.
import SwiftUI
import PetnowUI
struct CameraWithBoundingBoxView: View {
@ObservedObject var controller: CameraController
var body: some View {
ZStack {
CameraView(controller: controller) { EmptyView() }
GeometryReader { geometry in
if let normalizedRect = controller.detectedObjectNormalizedRect {
let box = convertToPixelRect(normalizedRect: normalizedRect, viewSize: geometry.size)
Rectangle()
.stroke(borderColor, lineWidth: 3)
.frame(width: box.width, height: box.height)
.position(x: box.midX, y: box.midY)
.animation(.easeInOut(duration: 0.3), value: normalizedRect)
}
}
}
}
private var borderColor: Color {
switch controller.detectionStatus {
case .detected: return .green
case .processing: return .yellow
case .failed: return .red
default: return .gray // noObject, finished
}
}
private func convertToPixelRect(normalizedRect: CGRect, viewSize: CGSize) -> CGRect {
let videoAspectRatio: CGFloat = 3.0 / 4.0
let scaledHeight = viewSize.height
let scaledWidth = scaledHeight * videoAspectRatio
let xOffset = (viewSize.width - scaledWidth) / 2
let drawingRect = CGRect(x: xOffset, y: 0, width: scaledWidth, height: scaledHeight)
return CGRect(
x: drawingRect.origin.x + (normalizedRect.origin.x * drawingRect.width),
y: drawingRect.origin.y + (normalizedRect.origin.y * drawingRect.height),
width: normalizedRect.width * drawingRect.width,
height: normalizedRect.height * drawingRect.height
)
}
}If you need a more precise box for the detected nose/face, you can use controller.detectionResult (DetectionResult — nose/face BoundingBox).
Placing UI Over the Tracker with floatingGuideContent
If you pass a @ViewBuilder closure to the CameraView initializer, the UI is automatically positioned directly over the detected object (the tracker).
- Automatic positioning: Placed above the detected object (moves below if it overlaps)
- Screen boundary correction: Automatically clamped so it does not go off-screen
- Center alignment: Positioned relative to the center of the bounding box
Example: Basic Text Guide
CameraView(controller: controller) {
Text("Please center the nose")
.font(.headline)
.foregroundColor(.white)
.padding()
.background(Color.black.opacity(0.7))
.cornerRadius(8)
}Example: Status-Driven Dynamic Guide
CameraView(controller: controller) {
guideContent
}
@ViewBuilder
private var guideContent: some View {
HStack(spacing: 12) {
Image(systemName: statusIcon)
.font(.title2)
.foregroundColor(.white)
Text(statusMessage)
.font(.headline)
.foregroundColor(.white)
}
.padding()
.background(statusColor.opacity(0.8))
.cornerRadius(12)
.animation(.easeInOut(duration: 0.3), value: controller.detectionStatus)
}
private var statusMessage: String {
switch controller.detectionStatus {
case .noObject: return "Looking for your pet..."
case .processing: return "Detecting..."
case .detected: return "Done!"
case .finished: return "Capture complete"
case .failed: return "Try again"
}
}Example: Species-Specific Guide
Since the species (Species) is already known when the app creates the DetectionConfiguration, pass it to the screen as a parameter and use it.
import SwiftUI
import PetnowUI
struct SpeciesGuideView: View {
@ObservedObject var controller: CameraController
let species: Species // Receives the species set by the app
var body: some View {
CameraView(controller: controller) {
VStack(spacing: 12) {
Image(systemName: species == .dog ? "pawprint.fill" : "cat.fill")
.font(.system(size: 40))
.foregroundColor(.white)
Text(species == .dog ? "Hold the dog's nose close" : "Center the cat's face from the front")
.font(.headline)
.foregroundColor(.white)
.multilineTextAlignment(.center)
}
.padding()
.background((species == .dog ? Color.blue : Color.orange).opacity(0.8))
.cornerRadius(16)
}
}
}CameraController does not expose species as a public property. Pass the species information directly from your app, as shown above.
Pausing / Resuming Detection
While the camera is running, you can temporarily pause detection only and then resume it. This is useful while showing a tips screen or a guidance modal.
// Pause detection before showing the tips screen
controller.pauseDetection()
isShowingTips = true
// Resume detection when the tips sheet is dismissed
.sheet(isPresented: $isShowingTips, onDismiss: {
controller.resumeDetection()
}) {
TipsSheetView()
}startDetection / pauseDetection / resumeDetection
startDetection()— Resets progress to 0 and starts a new Detection Session (re-capture).pauseDetection()— Temporarily pauses detection only. Keeps the camera preview and progress.resumeDetection()— Resumes from where it was paused.
For camera teardown, use finalizeCamera(). (The previous stopDetection() / startDetectionSession() are deprecated.)
Fully Custom UI Implementation
This is how to build the UI from scratch by injecting your own AVCaptureSession without CameraView. Use this only when you need a completely independent design.
In most cases, overlay/floatingGuideContent is sufficient
This section is for special situations where CameraView cannot be used at all. If you have a SwiftUI app, consider the earlier approaches first. For React Native integration, use the separate official RN package.
Core Principle
CameraController can be injected with your own AVCaptureSession through its initializer. By using this session directly in a preview layer, you can render the video that the SDK fills in yourself.
let session = AVCaptureSession()
let controller = CameraController(
configuration: DetectionConfiguration(species: .dog, purpose: .petProfileRegistration),
licenseInfo: LicenseInfo(apiKey: "YOUR_API_KEY", isDebugMode: false),
captureSession: session // Inject your own session
)
// Build the preview layer with the same session, and subscribe to state via @Published properties.CameraController does not expose captureSession via a getter. Keep the session instance your app created, as shown above, and reuse it for the preview.
Minimal SwiftUI Implementation
import SwiftUI
import AVFoundation
import PetnowUI
struct MinimalCustomCameraView: View {
@ObservedObject var controller: CameraController
let session: AVCaptureSession // The same instance injected into the controller
var body: some View {
ZStack {
CameraPreviewLayer(session: session)
.edgesIgnoringSafeArea(.all)
VStack {
Spacer()
Text(statusText)
.padding()
.background(Color.black.opacity(0.7))
.foregroundColor(.white)
.cornerRadius(8)
}
}
}
private var statusText: String {
switch controller.detectionStatus {
case .noObject: return "Please fit your pet into the screen"
case .processing: return "Detecting... \(controller.currentDetectionProgress)%"
case .detected: return "Done!"
case .finished: return "Capture complete"
case .failed(let reason): return "Failed: \(reason)"
}
}
}
// Display an AVCaptureSession in SwiftUI
struct CameraPreviewLayer: UIViewRepresentable {
let session: AVCaptureSession
func makeUIView(context: Context) -> UIView {
let view = UIView()
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
previewLayer.videoGravity = .resizeAspectFill
view.layer.addSublayer(previewLayer)
DispatchQueue.main.async { previewLayer.frame = view.bounds }
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
if let layer = uiView.layer.sublayers?.first as? AVCaptureVideoPreviewLayer {
DispatchQueue.main.async { layer.frame = uiView.bounds }
}
}
}Key points:
- Inject the
sessionyour app created intoCameraController(... captureSession:), and display the same instance withAVCaptureVideoPreviewLayer - Subscribe to
@Publishedproperties (detectionStatus,currentDetectionProgress,detectedObjectNormalizedRect, etc.) to react to state changes
Minimal UIKit Implementation
import UIKit
import AVFoundation
import PetnowUI
import Combine
class CustomCameraViewController: UIViewController {
private let controller: CameraController
private let session: AVCaptureSession
private var cancellables = Set<AnyCancellable>()
private let statusLabel = UILabel()
init(controller: CameraController, session: AVCaptureSession) {
self.controller = controller
self.session = session
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
let previewLayer = AVCaptureVideoPreviewLayer(session: session)
previewLayer.frame = view.bounds
previewLayer.videoGravity = .resizeAspectFill
view.layer.addSublayer(previewLayer)
statusLabel.textAlignment = .center
statusLabel.textColor = .white
view.addSubview(statusLabel)
controller.$detectionStatus
.sink { [weak self] status in self?.updateStatus(status) }
.store(in: &cancellables)
controller.$currentDetectionProgress
.sink { [weak self] progress in self?.statusLabel.text = "Detecting... \(progress)%" }
.store(in: &cancellables)
}
private func updateStatus(_ status: DetectionStatus) {
switch status {
case .noObject: statusLabel.text = "Please fit your pet into the screen"
case .processing: statusLabel.text = "Detecting..."
case .detected: statusLabel.text = "Done!"
case .finished: statusLabel.text = "Capture complete"
case .failed(let reason): statusLabel.text = "Failed: \(reason)"
}
}
}Key points:
- Add the injected
sessiondirectly toview.layer - Subscribe to
@Publishedstate with Combine'ssink
Next Steps
Once you've mastered customization, check out the following:
Recommended Learning Order
- Sound Guide - Changing the capture sound
References
- Basic Usage - Basic CameraView integration
- UI Module Overview - Detailed component descriptions