Petnow LogoPetnow
iOS SDKUI 모듈

UI 커스터마이징

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

사전 요구사항

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


개요

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

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

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


오버레이 UI 개발 가이드

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

기본 패턴


struct CameraScreenView: View {
    @ObservedObject var cameraViewModel: CameraViewModel
    @Environment(\.dismiss) var dismiss
    
    var body: some View {
        ZStack {
            // 카메라 뷰
            CameraView(viewModel: cameraViewModel) {
                EmptyView()  // 또는 floatingGuideContent
            }
            
            // 추가 오버레이 UI
            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 = cameraViewModel.detectionStatus,
               cameraViewModel.currentDetectionProgress > 0 {
                ProgressView(value: Double(cameraViewModel.currentDetectionProgress) / 100.0)
                    .progressViewStyle(LinearProgressViewStyle(tint: .white))
                    .frame(maxWidth: 300)
                
                Text("\(cameraViewModel.currentDetectionProgress)%")
                    .font(.caption)
                    .foregroundColor(.white.opacity(0.8))
            }
        }
    }
    
    private var statusMessage: String {
        switch cameraViewModel.detectionStatus {
        case .noObject:
            return "반려동물을 화면에 맞춰주세요"
        case .processing:
            return "탐지 중입니다..."
        case .detected:
            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로 ViewModel의 상태를 관찰하여 동적으로 UI를 업데이트합니다
  • VStack/HStack을 사용하여 상단/하단/좌우에 UI를 배치합니다

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

상태에 따라 색상과 아이콘을 변경하여 더 직관적인 피드백을 제공할 수 있습니다.

@ViewBuilder
private var statusOverlay: some View {
    HStack(spacing: 12) {
        Image(systemName: statusIcon)
            .font(.title2)
            .foregroundColor(.white)
        
        VStack(alignment: .leading, spacing: 4) {
            Text(statusMessage)
                .font(.headline)
                .foregroundColor(.white)
            
            if let subMessage = statusSubMessage {
                Text(subMessage)
                    .font(.caption)
                    .foregroundColor(.white.opacity(0.8))
            }
        }
    }
    .padding()
    .background(statusColor.opacity(0.8))
    .cornerRadius(12)
    .animation(.easeInOut(duration: 0.3), value: cameraViewModel.detectionStatus)
}

private var statusIcon: String {
    switch cameraViewModel.detectionStatus {
    case .noObject: return "viewfinder"
    case .processing: return "camera.metering.center.weighted"
    case .detected: return "checkmark.circle.fill"
    case .failed: return "exclamationmark.triangle.fill"
    }
}

private var statusColor: Color {
    switch cameraViewModel.detectionStatus {
    case .noObject: return .gray
    case .processing: return .blue
    case .detected: return .green
    case .failed: return .red
    }
}

private var statusSubMessage: String? {
    switch cameraViewModel.detectionStatus {
    case .processing:
        return "움직이지 말고 기다려주세요"
    default:
        return nil
    }
}

포인트:

  • 상태별로 다른 아이콘과 색상을 사용하여 시각적 피드백 제공
  • .animation modifier로 상태 전환을 부드럽게 처리
  • 서브 메시지로 추가 정보 제공

예제: 바운딩 박스 시각화

탐지된 영역을 시각적으로 강조하고 싶다면 detectedObjectNormalizedRect를 사용할 수 있습니다.

import SwiftUI
import PetnowUI

struct CameraWithBoundingBoxView: View {
    @ObservedObject var cameraViewModel: CameraViewModel
    @Environment(\.dismiss) var dismiss
    
    var body: some View {
        ZStack {
            // 카메라 뷰
            CameraView(viewModel: cameraViewModel) {
                EmptyView()
            }
            
            // 바운딩 박스 외곽선 (정규화된 좌표를 픽셀로 변환 필요)
            GeometryReader { geometry in
                if let normalizedRect = cameraViewModel.detectedObjectNormalizedRect {
                    let boundingBox = convertToPixelRect(normalizedRect: normalizedRect, viewSize: geometry.size)
                    Rectangle()
                        .stroke(borderColor, lineWidth: 3)
                        .frame(width: boundingBox.width, height: boundingBox.height)
                        .position(x: boundingBox.midX, y: boundingBox.midY)
                        .opacity(0.8)
                        .animation(.easeInOut(duration: 0.3), value: normalizedRect)
                }
            }
            
            // 상단 UI
            VStack {
                HStack {
                    Button("닫기") { dismiss() }
                        .foregroundColor(.white)
                        .padding()
                    Spacer()
                }
                Spacer()
            }
        }
    }
    
    private var borderColor: Color {
        switch cameraViewModel.detectionStatus {
        case .detected: return .green
        case .processing: return .yellow
        case .failed: return .red
        default: return .gray
        }
    }
    
    private func convertToPixelRect(normalizedRect: CGRect, viewSize: CGSize) -> CGRect {
        // 카메라 비율 (3:4)
        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
        )
    }
}

포인트:

  • detectedObjectNormalizedRect는 정규화된 좌표(0.0~1.0)입니다
  • convertToPixelRect 함수로 픽셀 좌표로 변환하여 화면에 표시합니다
  • 상태에 따라 색상을 변경하여 직관적인 피드백 제공

floatingGuideContent로 트래커 위 UI 배치

앞서 오버레이로 상단/하단에 UI를 배치하는 방법을 살펴봤습니다. 이제 탐지된 객체(트래커) 바로 위에 UI를 자동으로 배치하는 방법을 알아봅니다.

작동 원리

CameraView 생성자에 @ViewBuilder 클로저를 전달하면, 자동으로 다음을 처리합니다:

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

이 방법은 탐지 영역을 따라다니는 가이드가 필요할 때 유용합니다.

예제: 기본 텍스트 가이드

가장 간단한 예제부터 시작합니다.

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

포인트:

  • SwiftUI의 모든 View를 사용할 수 있습니다
  • 배경 불투명도를 적절히 설정하여 가독성을 확보하세요
  • 탐지된 객체를 따라 자동으로 위치가 조정됩니다

예제: 상태별 동적 가이드

상태에 따라 다른 메시지와 스타일을 표시할 수 있습니다.

CameraView(viewModel: cameraViewModel) {
    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: cameraViewModel.detectionStatus)
}

private var statusIcon: String {
    switch cameraViewModel.detectionStatus {
    case .noObject: return "viewfinder"
    case .processing: return "camera.metering.center.weighted"
    case .detected: return "checkmark.circle.fill"
    case .failed: return "exclamationmark.triangle.fill"
    }
}

private var statusMessage: String {
    switch cameraViewModel.detectionStatus {
    case .noObject: return "반려동물을 찾는 중..."
    case .processing: return "탐지 중..."
    case .detected: return "완료!"
    case .failed: return "다시 시도"
    }
}

private var statusColor: Color {
    switch cameraViewModel.detectionStatus {
    case .noObject: return .gray
    case .processing: return .blue
    case .detected: return .green
    case .failed: return .red
    }
}

포인트:

  • 상태별로 아이콘과 색상을 변경하여 직관적인 피드백 제공
  • .animation으로 상태 전환을 부드럽게 처리
  • 탐지 영역을 따라다니므로 사용자의 시선이 자연스럽게 유도됩니다

예제: 종별 맞춤 가이드

강아지와 고양이에 따라 다른 가이드를 표시할 수 있습니다.

import SwiftUI
import PetnowUI

struct SpeciesGuideView: View {
    @ObservedObject var cameraViewModel: CameraViewModel
    
    var body: some View {
        CameraView(viewModel: cameraViewModel) {
            VStack(spacing: 12) {
                Image(systemName: speciesIcon)
                    .font(.system(size: 40))
                    .foregroundColor(.white)
                
                Text(speciesGuide)
                    .font(.headline)
                    .foregroundColor(.white)
                    .multilineTextAlignment(.center)
            }
            .padding()
            .background(speciesColor.opacity(0.8))
            .cornerRadius(16)
        }
    }
    
    private var speciesIcon: String {
        cameraViewModel.species == .dog ? "pawprint.fill" : "cat.fill"
    }
    
    private var speciesGuide: String {
        cameraViewModel.species == .dog 
            ? "강아지의 코를 가까이 대주세요"
            : "고양이의 얼굴을 정면으로 맞춰주세요"
    }
    
    private var speciesColor: Color {
        cameraViewModel.species == .dog ? .blue : .orange
    }
}

포인트:

  • CameraViewModel.species로 현재 종을 확인할 수 있습니다
  • 종에 맞는 아이콘, 색상, 메시지로 더 직관적인 UI를 제공합니다

오버레이와 floatingGuideContent 조합

대부분의 경우 상단/하단은 오버레이로, 탐지 영역 주변은 floatingGuideContent로 UI를 구성하는 것이 가장 효과적입니다.


탐지 일시정지 / 재개

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

pauseDetection / resumeDetection

pauseDetection()은 탐지를 잠시 멈추지만 카메라 프리뷰와 타이머, 캡처된 이미지는 그대로 유지합니다. resumeDetection()으로 중단된 지점부터 바로 다시 시작합니다.

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

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

startDetectionSession / pauseDetection / resumeDetection / stopDetection

  • startDetectionSession() — 진행률을 0으로 리셋하고 새 Detection Session 시작. 재촬영 시 사용.
  • pauseDetection() — 탐지만 일시적으로 멈춤. 카메라 프리뷰 유지, 진행률 유지.
  • resumeDetection() — 일시정지 시점부터 이어서 재개.
  • stopDetection() — 탐지를 완전히 중단. 진행 상태 초기화.

활용 예시: 팁 버튼

Button(action: {
    cameraViewModel.pauseDetection()
    isShowingTips = true
}) {
    Text("Tips")
        .padding()
        .background(Color.orange.opacity(0.7))
        .foregroundColor(.white)
        .cornerRadius(10)
}

완전 커스텀 UI 구현

CameraView 없이 CameraViewModel만으로 처음부터 UI를 구축하는 방법입니다. React Native, Flutter 등 크로스 플랫폼 통합이나 완전히 독자적인 디자인이 필요할 때만 사용하세요.

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

이 섹션은 CameraView를 전혀 사용할 수 없는 특수한 상황을 위한 것입니다. SwiftUI 앱이라면 앞선 방법들을 먼저 고려하세요.

핵심 원리

CameraViewModelUI에 독립적이며, 두 가지 핵심 요소만 제공합니다:

  1. captureSession - 카메라 프리뷰를 표시하기 위한 AVFoundation 세션
  2. @Published 프로퍼티들 - 탐지 상태, 진행률 등을 구독

이 두 가지로 완전히 커스텀 UI를 구현할 수 있습니다.

SwiftUI 최소 구현

import SwiftUI
import AVFoundation
import PetnowUI

struct MinimalCustomCameraView: View {
    @ObservedObject var viewModel: CameraViewModel
    
    var body: some View {
        ZStack {
            // 1. 카메라 프리뷰
            CameraPreviewLayer(session: viewModel.captureSession)
                .edgesIgnoringSafeArea(.all)
            
            // 2. 상태 표시
            VStack {
                Spacer()
                Text(statusText)
                    .padding()
                    .background(Color.black.opacity(0.7))
                    .foregroundColor(.white)
                    .cornerRadius(8)
            }
        }
    }
    
    private var statusText: String {
        switch viewModel.detectionStatus {
        case .noObject:
            return "반려동물을 화면에 맞춰주세요"
        case .processing:
            return "탐지 중... \(viewModel.currentDetectionProgress)%"
        case .detected:
            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
            }
        }
    }
}

핵심 포인트:

  • captureSessionAVCaptureVideoPreviewLayer로 감싸서 카메라 화면 표시
  • @Published 프로퍼티를 구독하여 상태 변화에 반응
  • 나머지는 일반 SwiftUI 개발과 동일

UIKit 최소 구현

UIKit 환경에서는 더 간단합니다:

import UIKit
import AVFoundation
import PetnowUI
import Combine

class CustomCameraViewController: UIViewController {
    private let viewModel: CameraViewModel
    private var cancellables = Set<AnyCancellable>()
    private let statusLabel = UILabel()
    
    init(viewModel: CameraViewModel) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // 1. 카메라 프리뷰 레이어 추가
        let previewLayer = AVCaptureVideoPreviewLayer(session: viewModel.captureSession)
        previewLayer.frame = view.bounds
        previewLayer.videoGravity = .resizeAspectFill
        view.layer.addSublayer(previewLayer)
        
        // 2. 상태 라벨 설정
        statusLabel.textAlignment = .center
        statusLabel.textColor = .white
        statusLabel.backgroundColor = UIColor.black.withAlphaComponent(0.7)
        statusLabel.layer.cornerRadius = 8
        statusLabel.clipsToBounds = true
        view.addSubview(statusLabel)
        
        // 3. 상태 구독
        viewModel.$detectionStatus
            .sink { [weak self] status in
                self?.updateStatus(status)
            }
            .store(in: &cancellables)
        
        viewModel.$currentDetectionProgress
            .sink { [weak self] progress in
                self?.statusLabel.text = "탐지 중... \(progress)%"
            }
            .store(in: &cancellables)
    }
    
    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        
        // 레이아웃 업데이트
        if let previewLayer = view.layer.sublayers?.first as? AVCaptureVideoPreviewLayer {
            previewLayer.frame = view.bounds
        }
        
        statusLabel.frame = CGRect(
            x: 20,
            y: view.bounds.height - 100,
            width: view.bounds.width - 40,
            height: 60
        )
    }
    
    private func updateStatus(_ status: DetectionStatus) {
        switch status {
        case .noObject:
            statusLabel.text = "반려동물을 화면에 맞춰주세요"
        case .processing:
            statusLabel.text = "탐지 중..."
        case .detected:
            statusLabel.text = "완료!"
        case .failed(let reason):
            statusLabel.text = "실패: \(reason)"
        }
    }
}

핵심 포인트:

  • AVCaptureVideoPreviewLayerview.layer에 직접 추가
  • Combine의 sink로 상태 구독
  • UIKit의 표준 레이아웃 방식 사용

React Native / Flutter 통합

크로스 플랫폼 환경에서는 CaptureContext 객체로 captureSession을 전달합니다:

// Native Module
@objc(PetnowCameraModule)
class PetnowCameraModule: RCTEventEmitter {
    private var viewModels: [String: CameraViewModel] = [:]
    private var cancellables: [String: Set<AnyCancellable>] = [:]
    
    @objc func initialize(
        _ species: String,
        apiKey: String,
        sessionId: String,
        resolver: @escaping RCTPromiseResolveBlock,
        rejecter: @escaping RCTPromiseRejectBlock
    ) {
        let contextId = UUID().uuidString
        let viewModel = CameraViewModel(
            species: species == "dog" ? .dog : .cat,
            cameraPurpose: .forRegisterFromProfile
        )
        
        Task {
            do {
                try await viewModel.initializeCamera(
                    licenseInfo: LicenseInfo(apiKey: apiKey, isDebugMode: false),
                    initialPosition: .back,
                    captureSessionId: sessionId
                ) { result in
                    self.sendEvent(withName: "onResult", body: ["contextId": contextId, "data": result])
                }
                
                // 상태를 JS로 전달
                var cancellables = Set<AnyCancellable>()
                viewModel.$detectionStatus
                    .sink { [weak self] status in
                        self?.sendEvent(withName: "onStatusChange", body: ["contextId": contextId, "status": "\(status)"])
                    }
                    .store(in: &cancellables)
                
                self.viewModels[contextId] = viewModel
                self.cancellables[contextId] = cancellables
                
                // CaptureContext 반환
                resolver([
                    "contextId": contextId,
                    "captureSession": viewModel.captureSession
                ])
            } catch {
                rejecter("INIT_ERROR", error.localizedDescription, error)
            }
        }
    }
}

// Native View Component
@objc(PetnowCameraView)
class PetnowCameraView: UIView {
    private var previewLayer: AVCaptureVideoPreviewLayer?
    
    @objc var captureContext: NSDictionary? {
        didSet {
            guard let session = captureContext?["captureSession"] as? AVCaptureSession else { return }
            
            previewLayer?.removeFromSuperlayer()
            
            let layer = AVCaptureVideoPreviewLayer(session: session)
            layer.frame = bounds
            layer.videoGravity = .resizeAspectFill
            self.layer.addSublayer(layer)
            self.previewLayer = layer
        }
    }
    
    override func layoutSubviews() {
        super.layoutSubviews()
        previewLayer?.frame = bounds
    }
}
// JavaScript 사용 예시
import { NativeModules, requireNativeComponent } from 'react-native';

const PetnowCamera = requireNativeComponent('PetnowCameraView');
const { PetnowCameraModule } = NativeModules;

function CameraScreen() {
  const [context, setContext] = useState(null);
  
  useEffect(() => {
    // CaptureContext 생성
    PetnowCameraModule.initialize('dog', 'your-api-key', 'session-123')
      .then(ctx => setContext(ctx));
  }, []);
  
  if (!context) return <LoadingView />;
  
  // context를 View에 전달
  return <PetnowCamera captureContext={context} style={{ flex: 1 }} />;
}

핵심 포인트:

  • initialize()CaptureContext 객체를 반환 (contextId + captureSession)
  • View가 captureContext prop을 받아 자동으로 프리뷰 설정
  • contextId로 이벤트와 ViewModel을 매칭

고급 사용자 전용

이 방법은 AVFoundation, Combine, UIKit/SwiftUI에 대한 깊은 이해가 필요합니다. 크로스 플랫폼이 아니라면 CameraView를 사용하세요.


다음 단계

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

추천 학습 순서

  1. 사운드 가이드 - 촬영 사운드 변경하기
  2. 트러블슈팅 - 일반적인 문제 해결

참고 자료

On this page