Swift
Camera Controls
The camera-controls API gives you direct, read-write access to the camera behind CodeScannerView: zoom, torch, focus, and which physical lens is bound. It also gives you a single observable snapshot of what the camera is actually doing right now, so your UI can render sliders, toggles, and a preview reveal from real device state instead of guessing.
Available since v2.4.0. Every symbol on this page ships in the VisionSDK/Core subspec; no extra subspec or dependency is needed. From v2.6.0 the Core SDK's minimum deployment target is iOS 16.0 (the podspec previously claimed iOS 13.0, but 13.0-15.x were never fully functional - see the release notes).
rampZoomRatio(_:durationMs:), covered in the Zoom section below, and the teardown-complete signal covered in Lifecycle are both new in v2.6.0.
When consumed through react-native-vision-sdk, this API is available since v3.12.0. The React Native package targets iOS 16+, and the v3.13.0 release bumps the underlying iOS VisionSDK to 2.4.1, which fixes a camera-core bug where a pinned lens was silently dropped on the next startRunning() call (or when calling setSessionPresetTo(_:)) and reset zoom, torch, and focus to their defaults. As of the RN wrapper's latest release (v3.14.0, native versions VisionSDK 2.5.0 / vision-sdk-android v2.6.0), rampZoomRatio and the teardown-complete signal are not yet exposed through react-native-vision-sdk.
Use it when you need to:
- Build a zoom slider, torch button, or lens picker driven by real min/max values from the device.
- Know exactly when the camera preview has a live frame in it, so you can fade the preview in without showing a black rectangle.
- React to camera interruptions (a phone call, Slide Over, a hardware error) rather than silently freezing.
- Pin scanning to one physical lens, for example forcing the telephoto for small barcodes at distance.
If all you need is "show the camera and give me barcodes", you do not need this page. configure(...) plus the standard delegate methods are enough.
Observing camera state
There is exactly one state channel: an optional delegate method on CodeScannerViewDelegate that fires on every camera state transition and hands you a full snapshot.
Notes on the contract:
- It is a full snapshot, not a diff. Every field is populated on every call, so you can render your whole camera UI from the one object without keeping your own shadow copy.
- It is delivered on the main queue, so you can touch UIKit directly inside it.
- It is not throttled or coalesced. One state transition equals one callback. Transitions are driven by actual events (bind, unbind, interruption, zoom/torch/focus apply, first frame, active-lens switch), not by a timer, so this is a low-frequency callback in practice. Do not do heavy work in it anyway.
- There is no separate "camera ready" callback. Preview readiness is a field on the snapshot (
isPreviewActive, see below).
For a listener that attaches after the camera is already running, read the current snapshot instead of waiting for the next transition:
currentCameraState is safe to read from any thread.
VSDKCameraState fields
VSDKCameraState is immutable and cannot be constructed by consumers.
VSDKCameraStatus
Because backgrounding also produces .interrupted, a UI wired directly to status == .interrupted (a "camera unavailable" banner, for example) will show up every time the user leaves and returns to the app, not only on a genuine system interruption. If you only want to surface real interruptions, gate that UI on your own foreground state as well.
VSDKCameraErrorCode
A .error state is also mapped onto the existing codeScannerView(_:didFailure:) delegate method, so an app that only implements didFailure still hears about fatal camera failures. .running, .interrupted, and warnings have no didFailure equivalent; they are only visible through didChangeCameraState.
Querying capabilities
VSDKCameraState tells you what the camera is doing. VSDKCameraCapabilities tells you what the hardware can do, before any session starts. It is a read-only, static snapshot.
VSDKLens fields
VSDKLens implements isEqual(_:) and hash over id, kind, and facing, so it is safe to use in sets and dictionaries and to compare against state.activeLens.
Controls
All control methods live on CodeScannerView and are fire-and-forget: they return immediately and do not report success. The state snapshot is the feedback channel. Read didChangeCameraState (or currentCameraState) to see what actually landed.
Zoom
zoomRatio is never a raw AVCaptureDevice.videoZoomFactor; the SDK converts to and from the device's native zoom factor internally. Which scale it converts into depends on the lens selection.
On an .automatic binding it is wide-normalized: 1.0 means "the wide lens at 1x" regardless of how many lenses the device has, so on a device with an ultra-wide minZoomRatio is below 1.0 (typically 0.5).
A lens pin re-bases the scale. A pin binds one physical device, and a pinned physical lens converts zoom in its own native 1.0-based space rather than through the virtual device's switch-over mapping. Under a pin, 1.0 therefore means "the pinned lens at 1x" and the live range becomes 1.0 ... min(that lens's digital ceiling, 8.0). On a pinned telephoto that means:
state.zoomRatio == 1.0is telephoto-1x - roughly 3x wide-equivalent framing, not wide-1x.state.minZoomRatioreports1.0whilestate.activeLens?.minZoomRatiostill reports3.0.VSDKCameraCapabilitiesandVSDKLensalways report in wide-normalized terms, so the two channels do not line up while a pin is live.setZoomRatio(2.0)means 2x of the pinned lens, not wide-2x.
Drive sliders from state.minZoomRatio/state.maxZoomRatio rather than from a VSDKLens, and do not carry a zoom ratio across a pin change - the number means something different on each side of it.
setZoomRatio(_:) and setLinearZoom(_:) are mutually exclusive inputs; whichever you called most recently governs. Values are stored raw and clamped at apply time against whatever range is live then, so calling either before configure(...) is legal and lands on the first bind.
currentLinearZoom() always converts the live zoomRatio back into a 0...1 position, no matter which setter drove it. It returns 0 before the first bind, when the range is a degenerate 1.0...1.0.
The maximum is capped at 8.0, in whichever of the two scales above is live, on purpose. iOS reports purely digital ceilings that can run into the tens or hundreds of x, far past usable image quality, and the reported ceiling can drift at runtime. The cap keeps the capabilities channel, the state channel, and the apply site in agreement.
Ramping to a zoom ratio
Available since v2.6.0. setZoomRatio(_:) jumps immediately; to transition smoothly over a duration instead, use rampZoomRatio(_:durationMs:):
Internally this converts your requested duration into AVFoundation's rate-based zoom ramp: rate = log2(target / current) / durationSeconds. Two behaviors worth knowing:
- A replacement
rampZoomRatio(_:durationMs:)call issued while a ramp is already in flight resumes from the camera's actual current zoom at that instant, not from the previous ramp's target - so a burst of ramp requests does not overshoot or restart from a stale point. - A discrete
setZoomRatio(_:)call that lands mid-ramp supersedes the ramp outright, rather than fighting it for control of the device.
Like the other zoom setters, the ratio is wide-normalized on an .automatic binding and re-based to the pinned lens under a pin (see above).
Torch
Check capabilities.hasTorch(for: .back) before showing a torch button. If the bound device has no torch, isTorchEnabled will keep reporting false no matter what you set.
Focus point and focus mode
setFocusPoint(_:) takes a view-normalized point, not a device-space point. x and y are clamped to 0...1, then converted through the preview layer into device coordinates for you. This matches the Android and React Native contract, so the same normalized point works on all three platforms.
setFocusPoint(_:) is unrelated to setFocusSettingsTo(_:). The latter configures the on-screen focus overlay's styling; this one drives the actual lens.
Lens selection and pinning
By default the session binds the widest-spanning virtual multi-camera device on the chosen facing and lets iOS switch physical constituents as you zoom. That is VSDKLensSelection.automatic.
To restrict the session to one physical lens, resolve a VSDKLens from capabilities and pin it:
pin(_:) is Swift-only (it throws, which does not bridge cleanly to Objective-C here). VSDKLensSelection.automatic is available from both Swift and Objective-C, as is setLensSelection(_:).
setLensSelection(_:) rebinds a session that is already running, so the pin takes effect immediately. It then survives background to foreground and a system interruption with its automatic recovery - both of those reconcile from the camera core's own desired configuration, which this call updates.
From v2.4.1 a pin also survives startRunning() and setSessionPresetTo(_:).
On v2.4.0 a pin does not survive startRunning() or setSessionPresetTo(_:). Fixed in v2.4.1 - upgrade if you pin lenses.
The view snapshots its camera configuration during configure(...), and on v2.4.0 setLensSelection(_:) did not refresh that snapshot:
startRunning()binds from the stale, un-pinned snapshot, so the pin is silently dropped on the next cold start.setSessionPresetTo(_:)passes the same stale snapshot into a live update. The pin drops and zoom, torch, and focus mode reset to their defaults, because the camera core reads the vanished pin as a genuine input-device change.
Neither case reports an error or a warning, so on v2.4.0 the only symptom is the camera quietly returning to Auto lens selection.
If you cannot upgrade, re-apply setLensSelection(_:) after every startRunning() and after every setSessionPresetTo(_:). Calling it before configure(...) also works, since that is the point at which the snapshot is taken.
A pin also does not survive a facing switch; see the gotchas below.
Gestures
Tap-to-focus and pinch-to-zoom are built in but off by default:
The recognizers are attached during configure(...), and these methods only flip a gate, so enabling before configure(...) is fine. Tap-to-focus draws a brief on-screen indicator at the tap point and routes through the same one-shot focus path as setFocusPoint(_:). Pinch multiplies the zoom ratio captured at gesture start by the gesture's cumulative scale, and clamps against the live range.
Lifecycle
Revealing the preview
isPreviewActive is a first-frame signal, not a "session started" signal. Precisely:
- It is
trueonce the currently bound camera has delivered at least one frame to the frame pipeline. - It is
falseinitially, and throughout.idleand.starting. - It is reset to
falseon every rebind, stop, interruption, and error, even when the previous binding had already delivered frames.
The SDK already masks the two most visible cases internally, so you are not on your own here. On a cold start it hides its own preview layer and cross-fades it in on the first frame, with a roughly 1.5 second watchdog that force-reveals if no frame ever arrives. On a facing switch it holds a frozen, blurred snapshot of the last preview frame until the new camera delivers. Neither is public API and neither needs opting into.
What isPreviewActive gives you on top of that is the signal for your own chrome - a loading overlay, a spinner, a container view you own. status == .running is not sufficient for that: the session is bound at that point but the first frame has not arrived. Fading your own overlay on isPreviewActive lines your chrome up with the SDK's internal reveal instead of running ahead of it.
Because it resets on rebind, the same code correctly re-hides the preview when the camera is switched or interrupted and re-reveals it when frames resume. iOS switching physical constituents inside an Auto binding is not a rebind, so isPreviewActive is carried forward across those and your preview will not flicker when the user zooms past a lens switch point.
Animating the scanner view's own alpha alongside your overlay, as above, overlaps the SDK's internal cold-start cross-fade. That is harmless - both fades run to opacity 1 on the same first frame - so keep it if it makes your reveal easier to reason about, but it is not what stops you from seeing a black rectangle.
Control persistence across start, stop, and rebind
Zoom, torch, and focus mode are stored as desired values and re-applied every time the session transitions into .running. They therefore survive:
stopRunning()followed bystartRunning().- Background to foreground.
- A system interruption and its automatic recovery.
You do not need to re-apply those three yourself, and you should not: setting them again from a state callback is how feedback loops start.
From v2.4.1 the lens pin persists the same way: across background to foreground, interruption recovery, startRunning(), and setSessionPresetTo(_:). A facing switch is the one case that still drops it, deliberately, because a pin is facing-specific.
On v2.4.0 the pin was the exception - it survived background to foreground and interruption recovery but not startRunning() or setSessionPresetTo(_:), and because setSessionPresetTo(_:) dropped the pin it took zoom, torch, and focus mode down with it. See the pinning section above. Note that whenever a pin does go away, a zoom ratio set while it was live is re-applied in wide-normalized terms, so it no longer means what it did.
Stopping and waiting for teardown
Available since v2.6.0. stopRunning() returns immediately, before the underlying capture session has actually finished tearing down. If your app immediately mounts a second CodeScannerView (for example, navigating from a scan screen straight into a document-capture screen), that gap could produce two capture sessions briefly fighting over the same camera hardware. Previously the only workaround was a fixed delay, typically around 400ms, between stopping one screen and starting the next.
stopRunning(completion:) invokes its completion exactly once, only after the capture session has genuinely finished releasing - not merely once teardown was requested. Reach for this when you control the transition point directly.
There is no synchronous property to poll for this from elsewhere - there is no scannerView.isReleased. The same signal is also delivered as the isReleased field on VSDKCameraState, but only on the one snapshot that represents teardown actually completing; every other snapshot, including later ones, reports isReleased == false, so it is not something you can read back at an arbitrary later time. If you need to observe teardown from somewhere other than the call site - for example, a navigation coordinator that does not own the stopRunning(completion:) closure - watch for it in didChangeCameraState instead:
Gotchas
Pinning a lens re-bases the zoom scale; it does not just narrow it. A pin binds a single physical device, so 1.0 becomes that lens at 1x and the live range becomes 1.0 ... min(that lens's digital ceiling, 8.0). It is no longer wide-normalized, and it no longer lines up with the wide-normalized bounds VSDKLens reports for the same lens - on a pinned telephoto state.minZoomRatio is 1.0 while state.activeLens?.minZoomRatio is 3.0. Pinning the wide lens on a device with an ultra-wide takes minZoomRatio from 0.5 to 1.0, and zooming out below 1x stops working. If your UI needs the whole range in one scale, stay on .automatic and drive framing with zoom instead of a pin.
A failed pin does not throw or error out. pin(_:) only throws when the lens itself reports isPinnable == false. If the pin resolves fine but the lens cannot be bound at session time (for example it is not available on that facing), the session silently falls back to Auto and reports a non-fatal warning of .lensUnavailable on a .running state. status stays .running and error stays nil. If it matters to your UI that the pin took effect, compare state.activeLens against the lens you asked for, or check state.warning.
Switching facing resets both the lens pin and the runtime controls. A genuine input-device change (a different facing via setCameraSettingsTo(_:), or a different pinned lens via setLensSelection(_:)) resets zoom to 1.0, torch to off, and focus mode to .continuous. A facing switch additionally drops the lens pin back to .automatic, because front and back have independent lens sets and a back-lens ID does not resolve against the front facing. Re-apply zoom, torch, and any pin after you switch facing.
The reset only happens when the change lands on a live session. Switching facing while the session is stopped and then calling startRunning() resets nothing - the values you set beforehand are applied to the new binding. Ordinary rebinds (stop/start, background/foreground, interruption recovery) do not reset anything either. On v2.4.0 there was one non-obvious extra case: setSessionPresetTo(_:) reset all three whenever a pin was live, because the pin went missing and read as an input-device change. Fixed in v2.4.1; see the pinning section.
.starting snapshots carry nominal values, not device values. During an in-flight bind, zoomRatio/minZoomRatio/maxZoomRatio are all 1.0 and torch is false. If you bind a slider directly to every snapshot, it will jump to 1x during startup. Gate slider updates on status == .running.
hasFlash is not torch capability. VSDKLens.hasFlash is still-photo flash presence. For a torch button, use VSDKCameraCapabilities.hasTorch(for:).
Controls are asynchronous. The setters hop onto the camera's own queue. getCurrentZoomRatio() immediately after setZoomRatio(_:) will usually still report the old value. Read the value back from the next state snapshot.
maxZoomRatio is capped at 8.0. If you were expecting the raw AVFoundation ceiling, you will not get it. This is deliberate; see the zoom section.
Full example
CodeScannerViewDelegate has one required member: codeScannerViewDidDetect(_:barCode:qrCode:document:). The SDK ships a default implementation in a public extension CodeScannerViewDelegate, but the protocol is @objc, and a protocol-extension default cannot satisfy an @objc protocol requirement - so every conformer has to declare it, even as an empty stub. This is the first compile error a new conformer hits. The SDK's own demo declares it as {}, and so does the example below.
Objective-C
Everything except VSDKLensSelection.pin(_:) is available from Objective-C:
Because pin(_:) throws, pinning a specific lens must be done from Swift. Objective-C callers can still reset to VSDKLensSelection.automatic.