Petnow LogoPetnow
iOS SDK

UI 커스터마이징

CameraView의 UI를 앱 디자인에 맞게 커스터마이징하는 방법.

사전 요구사항

이 문서는 기본 사용법을 먼저 읽었다고 가정합니다. CameraController 생성과 초기화 방법을 먼저 익히세요.


개요

CameraView는 반려동물 탐지 카메라 UI를 제공하며, 다음과 같은 방법으로 커스터마이징할 수 있습니다:

  1. 오버레이 UI 추가 - ZStack으로 CameraView 위에 UI 배치 (진행률, 버튼, 안내 등)
  2. 트래커 위 가이드 배치 - floatingGuideContent로 탐지된 객체 위에 자동 배치되는 UI
  3. 완전 커스텀 구현 - 직접 만든 AVCaptureSession을 주입하여 처음부터 구축

대부분의 경우 1번과 2번을 조합하여 사용하면 충분합니다.


오버레이 UI 개발 가이드

CameraView 위에 ZStack을 사용하여 추가 UI를 배치할 수 있습니다. 버튼, 진행률 표시, 상태 메시지 등 대부분의 UI는 이 방법으로 구현합니다.

기본 패턴

struct CameraScreenView: View {
    @ObservedObject var controller: CameraController
    @Environment(\.dismiss) var dismiss

    var body: some View {
        ZStack {
            CameraView(controller: controller) {
                EmptyView()  // 또는 floatingGuideContent
            }

            VStack {
                HStack {
                    Button("닫기") { 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 "반려동물을 화면에 맞춰주세요"
        case .processing: return "탐지 중입니다..."
        case .detected:   return "완벽해요! 잠시만 기다려주세요"
        case .finished:   return "촬영 완료"
        case .failed(let reason): return failureMessage(for: reason)
        }
    }

    private func failureMessage(for reason: DetectionFailureReason) -> String {
        switch reason {
        case .tooFarAway: return "조금 더 가까이 대주세요"
        case .tooClose:   return "너무 가까워요"
        case .tooBright:  return "너무 밝아요"
        case .tooDark:    return "조명이 어두워요"
        case .tooBlurred: return "흔들림 감지"
        default:          return "다시 시도해주세요"
        }
    }
}

포인트:

  • ZStack으로 CameraView를 감싸서 자유롭게 UI를 배치할 수 있습니다
  • @ObservedObjectCameraController의 상태를 관찰하여 동적으로 UI를 업데이트합니다
  • detectionStatus switch는 .finished를 포함해 5개 케이스를 모두 다뤄야 합니다

예제: 상태별 색상과 아이콘

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
    }
}

예제: 바운딩 박스 시각화

탐지된 영역을 시각적으로 강조하고 싶다면 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
        )
    }
}

탐지된 코/얼굴의 더 정밀한 박스가 필요하면 controller.detectionResult(DetectionResultnose/face BoundingBox)를 사용할 수 있습니다.


floatingGuideContent로 트래커 위 UI 배치

CameraView 생성자에 @ViewBuilder 클로저를 전달하면, 탐지된 객체(트래커) 바로 위에 UI를 자동으로 배치합니다.

  • 자동 위치 조정: 탐지된 객체 위에 배치 (겹치면 아래로 이동)
  • 화면 경계 보정: 화면 밖으로 나가지 않도록 자동 클램프
  • 중앙 정렬: 바운딩 박스 중앙을 기준으로 배치

예제: 기본 텍스트 가이드

CameraView(controller: controller) {
    Text("코를 가운데에 맞춰주세요")
        .font(.headline)
        .foregroundColor(.white)
        .padding()
        .background(Color.black.opacity(0.7))
        .cornerRadius(8)
}

예제: 상태별 동적 가이드

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 "반려동물을 찾는 중..."
    case .processing: return "탐지 중..."
    case .detected:   return "완료!"
    case .finished:   return "촬영 완료"
    case .failed:     return "다시 시도"
    }
}

예제: 종별 맞춤 가이드

종(Species)은 앱이 DetectionConfiguration을 만들 때 이미 알고 있으므로, 화면에 파라미터로 전달하여 사용합니다.

import SwiftUI
import PetnowUI

struct SpeciesGuideView: View {
    @ObservedObject var controller: CameraController
    let species: Species   // 앱이 설정한 종을 전달받음

    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 ? "강아지의 코를 가까이 대주세요" : "고양이의 얼굴을 정면으로 맞춰주세요")
                    .font(.headline)
                    .foregroundColor(.white)
                    .multilineTextAlignment(.center)
            }
            .padding()
            .background((species == .dog ? Color.blue : Color.orange).opacity(0.8))
            .cornerRadius(16)
        }
    }
}

CameraControllerspecies를 공개 프로퍼티로 노출하지 않습니다. 종 정보는 위처럼 앱에서 직접 전달하세요.


탐지 일시정지 / 재개

카메라가 작동하는 상태에서 탐지만 일시적으로 멈추고 다시 재개할 수 있습니다. 팁 화면이나 안내 모달을 표시하는 동안 유용합니다.

// 팁 화면을 보여주기 전에 탐지 일시정지
controller.pauseDetection()
isShowingTips = true

// 팁 시트가 닫히면 탐지 재개
.sheet(isPresented: $isShowingTips, onDismiss: {
    controller.resumeDetection()
}) {
    TipsSheetView()
}

startDetection / pauseDetection / resumeDetection

  • startDetection() — 진행률을 0으로 리셋하고 새 Detection Session 시작(재촬영).
  • pauseDetection() — 탐지만 일시적으로 멈춤. 카메라 프리뷰·진행률 유지.
  • resumeDetection() — 일시정지 시점부터 이어서 재개.

카메라 정리(teardown)는 finalizeCamera()를 사용하세요. (이전의 stopDetection() / startDetectionSession()은 deprecated입니다.)


완전 커스텀 UI 구현

CameraView 없이 직접 만든 AVCaptureSession을 주입하여 처음부터 UI를 구축하는 방법입니다. 완전히 독자적인 디자인이 필요할 때만 사용하세요.

대부분의 경우 오버레이/floatingGuideContent로 충분합니다

이 섹션은 CameraView를 전혀 사용할 수 없는 특수한 상황을 위한 것입니다. SwiftUI 앱이라면 앞선 방법들을 먼저 고려하세요. React Native 통합은 별도의 공식 RN 패키지를 사용하세요.

핵심 원리

CameraController는 생성자에 직접 만든 AVCaptureSession을 주입받을 수 있습니다. 이 세션을 그대로 프리뷰 레이어에 사용하면, SDK가 채우는 영상을 직접 렌더링할 수 있습니다.

let session = AVCaptureSession()
let controller = CameraController(
    configuration: DetectionConfiguration(species: .dog, purpose: .petProfileRegistration),
    licenseInfo: LicenseInfo(apiKey: "YOUR_API_KEY"),
    captureSession: session   // 직접 만든 세션 주입
)
// 같은 session으로 프리뷰 레이어를 만들고, @Published 프로퍼티로 상태를 구독합니다.

CameraControllercaptureSessiongetter로 노출하지 않습니다. 위처럼 앱이 만든 session 인스턴스를 직접 보관해 프리뷰에 재사용하세요.

세션 시작과 정리

AVCaptureSession()을 그대로 주입하세요. 입력(AVCaptureDeviceInput)·출력을 직접 추가하거나 session.startRunning()을 호출하지 마세요 — 세션의 구성과 시작은 SDK가 initializeCamera()에서 담당합니다(직접 손대면 충돌합니다).

프리뷰 레이어만 올린다고 영상이 나오지 않습니다. 주입한 세션은 initializeCamera()가 시작합니다 — 화면이 나타날 때 호출하고, 떠날 때 finalizeCamera()로 정리하세요. (이 호출이 CameraView 방식과 동일하게 누락되기 쉬운 부분입니다.)

// 화면 onAppear / viewDidAppear 등에서
Task {
    do {
        try await controller.initializeCamera(
            initialPosition: .back,
            captureSessionId: captureSessionId   // 서버에서 발급받은 UUID
        ) { result in
            // 촬영 결과 (UI 갱신은 메인 스레드에서)
        }
        controller.startDetection()
    } catch { /* 권한/라이선스 에러 처리 */ }
}

// 화면 종료 시 (onDisappear / deinit)
controller.finalizeCamera()

SwiftUI 최소 구현

import SwiftUI
import AVFoundation
import PetnowUI

struct MinimalCustomCameraView: View {
    @ObservedObject var controller: CameraController
    let session: AVCaptureSession   // 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 "반려동물을 화면에 맞춰주세요"
        case .processing: return "탐지 중... \(controller.currentDetectionProgress)%"
        case .detected:   return "완료!"
        case .finished:   return "촬영 완료"
        case .failed(let reason): return "실패: \(reason)"
        }
    }
}

// AVCaptureSession을 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 }
        }
    }
}

핵심 포인트:

  • 앱이 만든 sessionCameraController(... captureSession:)에 주입하고, 같은 인스턴스를 AVCaptureVideoPreviewLayer로 표시
  • @Published 프로퍼티(detectionStatus, currentDetectionProgress, detectedObjectNormalizedRect 등)를 구독하여 상태 변화에 반응

UIKit 최소 구현

import UIKit
import AVFoundation
import PetnowUI
import Combine

class CustomCameraViewController: UIViewController {
    private let controller: CameraController
    private let session: AVCaptureSession
    private let captureSessionId: UUID            // 서버에서 발급받은 세션 ID
    private var previewLayer: AVCaptureVideoPreviewLayer!
    private var cancellables = Set<AnyCancellable>()
    private let statusLabel = UILabel()

    init(controller: CameraController, session: AVCaptureSession, captureSessionId: UUID) {
        self.controller = controller
        self.session = session
        self.captureSessionId = captureSessionId
        super.init(nibName: nil, bundle: nil)
    }
    required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }

    override func viewDidLoad() {
        super.viewDidLoad()

        previewLayer = AVCaptureVideoPreviewLayer(session: session)
        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)
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        previewLayer.frame = view.bounds   // 회전·레이아웃 변경 시 프리뷰 프레임 동기화
        statusLabel.frame = CGRect(x: 0, y: view.bounds.maxY - 80, width: view.bounds.width, height: 40)
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        // 주입한 session은 initializeCamera()가 시작합니다.
        Task {
            do {
                try await controller.initializeCamera(
                    initialPosition: .back,
                    captureSessionId: captureSessionId
                ) { [weak self] result in
                    DispatchQueue.main.async { /* result 처리 (CameraResult) */ }
                }
                controller.startDetection()
            } catch { print("초기화 실패: \(error)") }
        }
    }

    deinit { controller.finalizeCamera() }   // 화면 종료 시 정리

    private func updateStatus(_ status: DetectionStatus) {
        switch status {
        case .noObject:   statusLabel.text = "반려동물을 화면에 맞춰주세요"
        case .processing: statusLabel.text = "탐지 중..."
        case .detected:   statusLabel.text = "완료!"
        case .finished:   statusLabel.text = "촬영 완료"
        case .failed(let reason): statusLabel.text = "실패: \(reason)"
        }
    }
}

핵심 포인트:

  • 주입한 sessionview.layer에 직접 추가
  • Combine의 sink@Published 상태 구독

다음 단계

커스터마이징을 마스터했다면 다음을 확인하세요:

추천 학습 순서

  1. 사운드 가이드 - 촬영 사운드 변경하기

참고 자료

On this page