Petnow LogoPetnow

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:

  1. Add overlay UI - Place UI on top of CameraView with a ZStack (progress, buttons, guidance, etc.)
  2. Place a guide over the tracker - UI that is automatically positioned over the detected object using floatingGuideContent
  3. 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 CameraController with @ObservedObject to update the UI dynamically
  • The detectionStatus switch 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 (DetectionResultnose/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 session your app created into CameraController(... captureSession:), and display the same instance with AVCaptureVideoPreviewLayer
  • Subscribe to @Published properties (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 session directly to view.layer
  • Subscribe to @Published state with Combine's sink

Next Steps

Once you've mastered customization, check out the following:

  1. Sound Guide - Changing the capture sound

References

On this page