UI 커스터마이징
CameraView의 UI를 앱 디자인에 맞게 커스터마이징하는 방법.
사전 요구사항
이 문서는 기본 사용법을 먼저 읽었다고 가정합니다. CameraController 생성과 초기화 방법을 먼저 익히세요.
개요
CameraView는 반려동물 탐지 카메라 UI를 제공하며, 다음과 같은 방법으로 커스터마이징할 수 있습니다:
- 오버레이 UI 추가 - ZStack으로 CameraView 위에 UI 배치 (진행률, 버튼, 안내 등)
- 트래커 위 가이드 배치 -
floatingGuideContent로 탐지된 객체 위에 자동 배치되는 UI - 완전 커스텀 구현 - 직접 만든
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를 배치할 수 있습니다
@ObservedObject로CameraController의 상태를 관찰하여 동적으로 UI를 업데이트합니다detectionStatusswitch는.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(DetectionResult — nose/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)
}
}
}CameraController는 species를 공개 프로퍼티로 노출하지 않습니다. 종 정보는 위처럼 앱에서 직접 전달하세요.
탐지 일시정지 / 재개
카메라가 작동하는 상태에서 탐지만 일시적으로 멈추고 다시 재개할 수 있습니다. 팁 화면이나 안내 모달을 표시하는 동안 유용합니다.
// 팁 화면을 보여주기 전에 탐지 일시정지
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 프로퍼티로 상태를 구독합니다.CameraController는 captureSession을 getter로 노출하지 않습니다. 위처럼 앱이 만든 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 }
}
}
}핵심 포인트:
- 앱이 만든
session을CameraController(... 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)"
}
}
}핵심 포인트:
- 주입한
session을view.layer에 직접 추가 - Combine의
sink로@Published상태 구독
다음 단계
커스터마이징을 마스터했다면 다음을 확인하세요:
추천 학습 순서
- 사운드 가이드 - 촬영 사운드 변경하기