UI 커스터마이징
CameraView의 UI를 앱 디자인에 맞게 커스터마이징하는 방법.
사전 요구사항
이 문서는 기본 사용법을 먼저 읽었다고 가정합니다. CameraViewModel 생성과 초기화 방법을 먼저 익히세요.
개요
CameraView는 반려동물 탐지 카메라 UI를 제공하며, 다음과 같은 방법으로 커스터마이징할 수 있습니다:
- 오버레이 UI 추가 - ZStack으로 CameraView 위에 UI 배치 (진행률, 버튼, 안내 등)
- 트래커 위 가이드 배치 -
floatingGuideContent로 탐지된 객체 위에 자동 배치되는 UI - 완전 커스텀 구현 -
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
}
}포인트:
- 상태별로 다른 아이콘과 색상을 사용하여 시각적 피드백 제공
.animationmodifier로 상태 전환을 부드럽게 처리- 서브 메시지로 추가 정보 제공
예제: 바운딩 박스 시각화
탐지된 영역을 시각적으로 강조하고 싶다면 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 앱이라면 앞선 방법들을 먼저 고려하세요.
핵심 원리
CameraViewModel은 UI에 독립적이며, 두 가지 핵심 요소만 제공합니다:
captureSession- 카메라 프리뷰를 표시하기 위한 AVFoundation 세션@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
}
}
}
}핵심 포인트:
captureSession을AVCaptureVideoPreviewLayer로 감싸서 카메라 화면 표시@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)"
}
}
}핵심 포인트:
AVCaptureVideoPreviewLayer를view.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가
captureContextprop을 받아 자동으로 프리뷰 설정 contextId로 이벤트와 ViewModel을 매칭
고급 사용자 전용
이 방법은 AVFoundation, Combine, UIKit/SwiftUI에 대한 깊은 이해가 필요합니다. 크로스 플랫폼이 아니라면 CameraView를 사용하세요.
다음 단계
커스터마이징을 마스터했다면 다음을 확인하세요: