Fully Custom UI
How to build a 100% custom camera UI with CameraController and your own SurfaceView.
Before starting this guide, read the UI Module Overview and Basic Usage.
Overview
The CameraView from Basic Usage draws both the preview and the default tracking UI for you. By contrast, if you want to draw everything yourself, including the tracking overlay, you can use only CameraController without CameraView and provide the preview Surface yourself.
This approach is well suited to the following cases:
- A fully custom screen based on Jetpack Compose
- Bridges such as React Native / Flutter (the Petnow React Native package actually uses this approach)
- Cases where you need to draw your own overlay instead of using the SDK's default tracking UI
CameraView Approach vs. Fully Custom Approach
| Feature | CameraView (default) | Fully custom |
|---|---|---|
| Preview Surface | Managed internally by CameraView | Create your own SurfaceView and call attachPreviewSurface() |
| Default tracking UI | Built-in | None — implement it yourself |
| Camera permission | Handled by the host | Handled by the host |
| Receiving status | V2 listener / getState() | V2 listener / getState() |
| UI flexibility | Add overlays | 100% custom |
In the fully custom approach, the SDK does not draw any UI. You must render progress, status, and tracking boxes yourself from getState() or the V2 listener values.
Architecture
CameraController manages the entire lifecycle of the camera/detection but is not involved in UI rendering. You draw the preview with your own SurfaceView and receive status through the StateFlow (getState()) and the PetnowCameraDetectionListenerV2 callbacks.
Basic Integration
Step 1: Create the Controller + Register a Listener
import io.petnow.ui.CameraController
import io.petnow.ui.CameraResult
import io.petnow.ui.config.LicenseInfo
import io.petnow.ui.status.DetectionStatus
import io.petnow.callback.PetnowCameraDetectionListenerV2
val controller = CameraController(
context.applicationContext,
LicenseInfo(apiKey = "YOUR_API_KEY", isDebugMode = false),
scope,
).apply {
setDetectionListenerV2(object : PetnowCameraDetectionListenerV2 {
override fun onDetectionStatus(primaryDetectionStatus: DetectionStatus) { /* render it yourself */ }
override fun onDetectionProgress(progress: Int) { /* 0~100 */ }
override fun onDetectionFinished(result: CameraResult) { /* handle the result */ }
})
}Step 2: Provide the SurfaceView + Start the Session
Pass the Surface of your own SurfaceView to attachPreviewSurface(). The camera opens once the Surface is ready. Since initializeCamera() waits until the camera is open, you can call startDetection() afterward.
import android.view.SurfaceHolder
import android.view.SurfaceView
import io.petnow.ui.config.DetectionConfiguration
import io.petnow.ui.config.DetectionPurpose
import io.petnow.ui.config.PetSpecies
import java.util.UUID
val config = DetectionConfiguration(
species = PetSpecies.DOG,
purpose = DetectionPurpose.PET_PROFILE_REGISTRATION,
enableFakeDetection = false,
)
val captureSessionId: UUID = /* captureSessionId received from the server */
val surfaceView = SurfaceView(context).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
controller.attachPreviewSurface(holder.surface)
scope.launch {
controller.initializeCamera(config, captureSessionId)
controller.startDetection()
}
}
override fun surfaceChanged(h: SurfaceHolder, f: Int, w: Int, ht: Int) {}
override fun surfaceDestroyed(holder: SurfaceHolder) {
controller.detachPreviewSurface() // camera closes
}
})
}The host handles permissions
CameraController does not request the camera permission. Declare the CAMERA permission in AndroidManifest.xml and request the runtime permission yourself before starting the session.
Step 3: Observe Status (StateFlow)
scope.launch {
controller.state.collect { state ->
// state.progressPercent: progress (0~100)
// state.detectionStatusList: list of detection statuses for the current frame (PetnowDetectionStatus)
// state.currentDetectionResult: nose/face BoundingBox (DetectionResult?) — for rendering the tracking box
// state.isDetectionFinished: whether detection is complete
}
}Step 4: Release Resources
controller.release() // fully releases native detector/sound pool (terminates)finalizeCamera() stops only the camera session and lets the controller be reused. When you leave the screen for good, clean up the native resources too with release().
Jetpack Compose Example
@Composable
fun PetnowCameraScreen(captureSessionId: UUID) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val controller = remember {
CameraController(
context.applicationContext,
LicenseInfo(apiKey = "YOUR_API_KEY", isDebugMode = false),
scope,
)
}
val state by controller.state.collectAsState()
val config = remember {
DetectionConfiguration(
species = PetSpecies.DOG,
purpose = DetectionPurpose.PET_PROFILE_REGISTRATION,
enableFakeDetection = false,
)
}
LaunchedEffect(Unit) {
controller.setDetectionListenerV2(object : PetnowCameraDetectionListenerV2 {
override fun onDetectionStatus(primaryDetectionStatus: DetectionStatus) { /* ... */ }
override fun onDetectionProgress(progress: Int) { /* ... */ }
override fun onDetectionFinished(result: CameraResult) { /* ... */ }
})
}
// The screen owns the controller, so dispose = full release().
DisposableEffect(Unit) {
onDispose { controller.release() }
}
Column(modifier = Modifier.fillMaxSize()) {
// Preview: wrap your own SurfaceView in an AndroidView.
AndroidView(
factory = { ctx ->
SurfaceView(ctx).apply {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
controller.attachPreviewSurface(holder.surface)
scope.launch {
controller.initializeCamera(config, captureSessionId)
controller.startDetection()
}
}
override fun surfaceChanged(h: SurfaceHolder, f: Int, w: Int, ht: Int) {}
override fun surfaceDestroyed(holder: SurfaceHolder) {
controller.detachPreviewSurface()
}
})
}
},
modifier = Modifier.weight(1f),
)
// Progress bar (rendered directly from state)
LinearProgressIndicator(
progress = { state.progressPercent / 100f },
modifier = Modifier.fillMaxWidth(),
)
// Detection status text
Text(
text = state.detectionStatusList.firstOrNull()?.name ?: "",
modifier = Modifier.padding(16.dp),
textAlign = TextAlign.Center,
)
}
}Camera Switch / Pause / Resume
Uses the same controller API as the CameraView approach.
controller.switchCamera() // returns a Result (front ↔ back)
controller.pauseDetection() // pause (returns nothing)
controller.resumeDetection() // resume (returns a Result)
controller.startDetection() // re-capture: reset progress and start a new session (returns a Result)Sound Playback
import io.petnow.ui.sound.SoundType
val streamId = controller.playSound(SoundType.RANDOM)
controller.stopSound(streamId)For details on the available sound types, see the Sound Guide.
API Summary
| Method | Description |
|---|---|
CameraController(context, license, scope) | Create the controller |
setDetectionListenerV2(listener) | Register callbacks for detection status/progress/result |
attachPreviewSurface(surface) | Connect your own Surface (camera opens) |
detachPreviewSurface() | Detach the Surface (camera closes) |
initializeCamera(config, captureSessionId) | Prepare the session (suspend) |
startDetection() | Start/restart detection (Result) |
pauseDetection() / resumeDetection() | Pause / resume detection |
switchCamera() | Switch front/back (Result) |
getState() | Observe StateFlow<DetectionState> |
setBracketingMode(enabled) | Configure bracketing mode |
playSound(type) / stopSound(id) | Play/stop sound |
finalizeCamera() | Stop the camera session (reusable) |
release() | Fully release native resources (terminate) |
Next Steps
- Customization - Customize the
CameraViewguide UI - Sound Guide - Detailed guide to sound playback