Camera Controls
The Camera Controls API gives you direct, observable control over the camera session that powers VisionCameraView. Instead of treating the camera as an opaque box that either works or doesn't, you get a full state snapshot on every meaningful change (status, active lens, zoom range, torch, focus mode, preview liveness), a capability query you can run before the camera is even started, and setters for zoom, torch, focus, and physical lens selection.
Use it when you need to build your own camera UI on top of the SDK: a zoom slider that knows the real min/max for the current device, a torch button that only renders when the device actually has a flash, a lens picker for ultra-wide/wide/telephoto, or a preview that stays hidden behind a placeholder until real pixels are flowing.
The io.packagex.visionsdk.camera.core package, setLensSelection(), setFocusPoint(), setFocusMode(), and the camera-state listener APIs are available since v2.5.0. setZoomRatio(), setLinearZoom(), setFlashTurnedOn(), and getCamera() existed before v2.5.0 but are now routed through the same camera core, which is what makes the state snapshots truthful.
From v2.7.0, rampZoomRatio() adds a duration-based zoom ramp (see Zoom), and VisionCameraView correctly releases and re-arms the camera across a detach/reattach cycle (see View Attach and Detach).
Observing Camera State
Register a CameraStateListener on VisionCameraView to receive a full CameraState snapshot on every change. Callbacks are always dispatched on the main thread.
Because a listener only hears about future changes, read the current snapshot synchronously when you attach one (for example, to paint the initial state of a control bar). Gate anything that consumes the zoom range on status == RUNNING - see the warning below:
Only a RUNNING snapshot carries real zoom values. Every non-RUNNING snapshot - IDLE, STARTING, INTERRUPTED, ERROR - hardcodes nominal placeholders: zoomRatio, minZoomRatio, and maxZoomRatio are all 1.0f, isTorchEnabled is false, focusMode is CONTINUOUS, and activeLens is null. These are the same nominal values iOS reports, and they are not hardware readings - never treat them as the device's real range.
The failure this causes is not subtle. The normal time to attach a listener and paint a control bar is onCreate(), before the camera is RUNNING, so minZoomRatio and maxZoomRatio are both 1.0. Feeding those to a Material Slider sets valueFrom == valueTo, which the slider rejects at layout time with IllegalStateException: valueFrom(1.0) must be smaller than valueTo(1.0) - a crash on first paint, not a cosmetic glitch. Set slider bounds only from a RUNNING snapshot, and use CameraCapabilities (which needs no session) if you want real hardware numbers before the camera starts.
CameraState
CameraStatus
CameraError
CameraError is a sealed class, so you can exhaustively handle it in a when:
There is no time-based throttle in the Android SDK. One state change is one callback. Two mechanisms do reduce redundant callbacks, though, and neither is time-based:
- Snapshots are de-duplicated before dispatch, so a value-identical re-emission never reaches your listener. Every field is compared by value except
errorandwarning, which are compared by concreteCameraErrorsubclass only - so two consecutiveLensUnavailablewarnings for different requested lenses are treated as the same condition and collapse into one callback. If you need the exactrequestedlens of every fallback, do not rely on getting a callback per distinct request. - Bursts of control-setter calls (a dragged zoom slider firing 60-120 times per second) are conflated into at most one scheduled apply-and-emit, so you get the latest value rather than every intermediate one.
You can still receive one callback per genuinely distinct value. If this callback drives an expensive view tree, throttle on your side.
The React Native wrapper adds its own 10 Hz throttle on top of this before forwarding state to JS. That throttle is a property of the RN bridge, not of the Kotlin SDK - a native Kotlin consumer does not get it.
Querying Capabilities
CameraCapabilities.snapshot(context) reads the device's camera hardware directly. It does not require a running camera session, so you can call it before startCamera() to decide which controls to render at all.
Lens
Because zoomSwitchPoints is always empty, zoomStops(facing) returns at most two values on Android: the facing's minimum zoom (only when it is below 1.0) and 1.0. Treat it as "here are the optical anchor points we can honestly name", not as a complete detent list.
Zoom
Zoom on Android is wide-normalized: 1.0 means the wide lens at 1x. A device with an ultra-wide reports a minZoomRatio below 1.0 (for example 0.67 on a Pixel 7), and zooming out past 1.0 is how you reach the ultra-wide field of view.
setZoomRatio() and setLinearZoom() are two views of the same desired state - whichever you called most recently wins.
Requests are stored unclamped and clamped against the live camera's real range only at apply time. That means calling setZoomRatio(5.0F) before startCamera() is honored: the value is retained and applied once the true range is known, instead of being truncated against a guessed range.
For reading the current values, prefer CameraState (zoomRatio, minZoomRatio, maxZoomRatio) - it is always populated while RUNNING. The direct getters exist as well, but return null before a camera is bound:
Ramping to a Zoom Ratio
Available since v2.7.0. setZoomRatio() jumps immediately; rampZoomRatio() transitions smoothly over a duration instead:
CameraX has no native ramp primitive (unlike AVFoundation's rate-based ramp on iOS), so this is driven by an internal ticker running at roughly 60 steps per second that walks the zoom ratio from its current value to the target over durationMs. A new rampZoomRatio() call issued while one is already in flight starts from the actual current zoom rather than the previous target, and a setZoomRatio() call mid-ramp supersedes it.
Torch
Check CameraCapabilities.hasTorch(facing) before rendering a torch control, and read the resulting state back from CameraState.isTorchEnabled rather than tracking your own boolean - isTorchEnabled reflects what the camera actually reports, so it stays truthful if the request could not be honored.
Like zoom, a torch request made before the camera is bound is retained and applied on bind.
Focus
setFocusPoint() takes view coordinates, not sensor or surface-plane coordinates. The SDK compensates internally for sensor mounting rotation and front-camera mirroring, so you can pass a touch position divided by the view's width and height directly. Values outside 0..1 are clamped.
FocusMode values:
The SDK also ships a built-in gesture layer if you don't want to wire your own: enableTapToFocus() / disableTapToFocus() and enablePinchPanToZoom() / disablePinchPanToZoom(). Both are off by default.
The SDK installs its own OnTouchListener to drive those gestures - not on VisionCameraView itself, but on the internal PreviewView it adds as a MATCH_PARENT child. That child listener returns true for every event, so it consumes the whole touch stream. Do not call setOnTouchListener() on VisionCameraView: it does not replace anything and the built-in gestures keep working, but a parent only receives events no child consumed, so your listener silently never fires. If you want to drive setFocusPoint() from your own touch handling, place a transparent overlay View above the camera view and attach your listener there.
Note also that the built-in tap-to-focus gesture talks to CameraX directly rather than going through setFocusPoint(), so a focus applied by the gesture is invisible to the SDK's tracked state.
setFocusPoint() is a one-shot request, unlike the other controls. Calling it before the camera is bound retains the point and applies it on the first bind, after which it is cleared - it is not re-applied on later rebinds. setFocusMode(), by contrast, is persistent desired state and is re-applied on every bind.
Lens Selection
By default the session lets the platform pick a lens for the requested facing (LensSelection.Auto). Pinning restricts the session to one specific physical lens - useful when you want a guaranteed ultra-wide for scanning a wide pallet label, or a guaranteed wide when the platform keeps drifting to a crop sensor.
LensSelection is a sealed interface with two cases:
LensSelection.Pin requires lens.isPinnable == true and throws IllegalArgumentException at construction time otherwise. Always filter on isPinnable before constructing a pin. A lens is pinnable when it is a physical sub-lens of a logical multi-camera, or when its facing has more than one separately-enumerable top-level camera - pinning the only camera on a facing would be indistinguishable from Auto, so it is reported as not pinnable.
A pin that passes isPinnable but cannot actually be bound at runtime is not an error. The session falls back to the plain facing selector, keeps status == RUNNING, and surfaces CameraError.LensUnavailable(requested) in CameraState.warning:
Facing
Facing is set through CameraSettings, not through LensSelection:
A pin is facing-specific, so switching facing resets the lens selection back to Auto. If you want a pinned lens on the new facing, re-resolve it against CameraCapabilities and call setLensSelection() again. Changing facing while the camera is bound also performs a full stop/start internally; changing any other CameraSettings field (such as nthFrameToProcess) does not.
Preview Liveness
isPreviewActive is a first-frame signal, not a session-started signal. It becomes true only once the currently-bound camera has delivered at least one frame to the analysis pipeline. It is false initially and while IDLE / STARTING, and is reset to false on every rebind, stopCamera(), interruption, and error.
This is the signal to use to reveal a preview. status == RUNNING only means a camera was bound - the surface may still be black for a few frames, which is exactly the window where users see a flash of black behind your UI.
isPreviewActive mirrors iOS's VSDKCameraState.isPreviewActive field-for-field, so cross-platform code can share the same reveal logic.
Lifecycle and Control Persistence
Both startCamera() and stopCamera() behave as before; what v2.5.0 adds is a well-defined rule for what happens to your control values across a rebind.
The session distinguishes an input-device change (the facing changed, or the pinned lens id changed) from an ordinary rebind. The persistent controls are zoom, torch, and focus mode; a focus point is one-shot and is never re-applied.
So a scan loop that stops and restarts the camera between captures keeps the user's zoom and torch. But if your UI owns those values and you change facing or lens, you must re-assert them yourself after the camera comes back:
View Attach and Detach
Available since v2.7.0. VisionCameraView now releases the camera when it detaches from the window, and re-arms it automatically when it reattaches. This closes a leak where a detached view - for example, one left behind after a fragment transaction or a ViewPager swipe - kept the camera bound indefinitely.
This interacts predictably with the rest of the lifecycle:
- If you called
stopCamera()explicitly before the view detached, it stays stopped on reattach - detach/reattach no longer overrides an explicit stop with auto-resume. - An in-flight
rampZoomRatio()ramp is cancelled before the camera unbinds on detach, instead of continuing to tick against a camera that is no longer there. - A redundant
startCamera()call on an already-bound camera no longer releases the active analyzer out from under a live frame stream.
Gotchas
isPreviewActiveis first-frame, not session-started.RUNNINGalone is not enough to reveal a preview without risking a black flash. Gate onstatus == RUNNING && isPreviewActive.Zoom is wide-normalized.
1.0is wide at 1x, not "minimum zoom". A device'sminZoomRatiocan be below1.0; do not assume a1.0..maxslider range.Changing lens selection performs a full unbind/rebind, which resets
isPreviewActivetofalse. Expected, and it happens on iOS too - a freshRUNNINGstate always means the binding just changed, so no frame has been delivered for this bind yet. Do not treat thetrue -> falsetransition as an error or tear down your UI - just re-run your reveal logic when it flips back.A lens change also resets zoom, torch, and focus mode (it is an input-device change). A plain
rescan()does not. Re-assert UI-owned values on theRUNNINGtransition.setOnTouchListener()onVisionCameraViewsilently never fires. The SDK's own listener for tap-to-focus and pinch-to-zoom lives on the internalMATCH_PARENTPreviewViewchild and returnstruefor every event, so the child consumes the whole touch stream before the parent sees it. Your listener is not overwritten and the built-in gestures are not broken - your callback simply never runs. Use a transparent overlayViewabove the camera view for your own touch handling.Pinning does not narrow the reported zoom range on Android. This differs from iOS. CameraX reports the same logical-camera zoom range regardless of a physical pin - on a Pixel 7, a verified ultra-wide pin still reports
0.67..8.0.Lens.minZoomRatio/Lens.maxZoomRatiofromCameraCapabilitiesare per-lens hardware values and can therefore differ from the liveCameraStaterange while pinned. Zoom requests while pinned are accepted without error; what they do optically to a single pinned sensor is device-dependent.LensSelection.Pinthrows on a non-pinnable lens.IllegalArgumentExceptionat construction. Always filter onisPinnable. A runtime bind failure, by contrast, is non-fatal: fallback plus aLensUnavailablewarning.Switching facing silently drops the pin. Expected, since a lens id only exists under one facing. Re-resolve and re-pin if you need it.
zoomSwitchPointsis always empty on Android, sozoomStops()returns at most[min, 1.0]. There is no Camera2 API to report lens switch-over points honestly, and the SDK does not invent them.Values set directly on the raw CameraX object are not tracked.
getCamera()remains available as an escape hatch, but anything you set viacamera.cameraControlis invisible to the SDK's own desired-state store and will be overwritten by the SDK's values on the next rebind. Prefer theVisionCameraViewsetters.Listener callbacks are de-duplicated and conflated, but never throttled. During a zoom drag you can still receive one callback per genuinely distinct value. The 10 Hz throttle you may have seen referenced belongs to the React Native bridge, not to this SDK. Throttle on your side if the callback drives expensive rendering.
Cross-platform parity note. The reset-on-input-device-change rule, the wide-normalized zoom scale,
isPreviewActive's first-frame semantics (including thetrue -> false -> truedip on a lens change), and the nominal values in non-RUNNINGsnapshots all match iOS exactly. The one known Android divergence is that pinning does not narrow the reported zoom range - it does on iOS. Cross-platform reveal logic needs no Android special case.
Advanced: CameraSession
VisionCameraView delegates to a public CameraSession, which you can use directly if you need a camera pipeline without the scanning view. It exposes the same state as a StateFlow, which is often more convenient in Compose or a coroutine-based ViewModel:
RotationLock controls the orientation pin: FOLLOW_DISPLAY (default), LOCKED_PORTRAIT, or LOCKED_LANDSCAPE. When using VisionCameraView, set this through CameraSettings(orientationMode = ...) instead.
Sample Code
A complete control bar wired to camera state:
Imports
Imports used by the sample above and by the shorter snippets on this page:
The CameraSession section additionally needs io.packagex.visionsdk.camera.core.CameraSession, CameraConfiguration, and RotationLock, plus androidx.lifecycle.lifecycleScope; the facing snippet needs io.packagex.visionsdk.config.CameraSettings and io.packagex.visionsdk.core.CameraLensFace.