1. Swift
  2. Camera Controls

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.

INFO

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.

        @objc optional func codeScannerView(
    _ scannerView: CodeScannerView,
    didChangeCameraState state: VSDKCameraState
)

      

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:

        @objc public var currentCameraState: VSDKCameraState { get }

      

currentCameraState is safe to read from any thread.

VSDKCameraState fields

Field Type Meaning
status VSDKCameraStatus Lifecycle phase. See the table below.
error NSError? Non-nil only when status == .error. Domain is VSDKCameraErrorDomain; code is a VSDKCameraErrorCode raw value. Fatal: the camera is not running.
warning NSError? Non-fatal advisory carried on a .running state. Same domain/code scheme as error. Today the only warning is .lensUnavailable, raised when a requested lens pin could not be honored and the session fell back to Auto.
facing VSDKLensFacing .back or .front. Reflects the configured facing, and is populated even while .idle.
activeLens VSDKLens? The physical lens currently feeding frames. nil while .idle, .starting, .interrupted, or .error. On a multi-lens Auto binding this updates as iOS switches constituents under you.
zoomRatio Float Current zoom. Wide-normalized on an .automatic binding; re-based to the pinned lens under a pin (see the zoom section). 1.0 while .idle/.starting.
minZoomRatio Float Lower bound of the currently bound camera's usable zoom range, in the same scale as zoomRatio. 1.0 while .idle/.starting.
maxZoomRatio Float Upper bound, same scale, capped at 8.0. 1.0 while .idle/.starting.
isTorchEnabled Bool Whether the torch is actually on. Never reports true for a device with no torch, even if you asked for it.
focusMode VSDKFocusMode Current focus mode. .continuous while .idle/.starting.
isPreviewActive Bool First-frame-landed signal. See "Revealing the preview" below.

VSDKCameraState is immutable and cannot be constructed by consumers.

VSDKCameraStatus

Case Meaning
.idle No camera bound. Either nothing has started yet, or stopRunning() ran. All values are nominal defaults.
.starting A bind is in flight. Nothing is live yet, so zoom/torch/focus values are nominal defaults, not real device values. Do not drive a slider off a .starting snapshot.
.running A camera is bound. activeLens and the zoom range are real. This is the only status where warning can be set.
.interrupted The camera is not available right now. Raised both when the system takes it away (a call, Slide Over, another app) and on every backgrounding of a running or starting session - the SDK unbinds itself when the app goes to the background. Zoom/torch/focus values are carried forward from the last .running state, activeLens is nil, and isPreviewActive is false. Recovery is automatic and lands back on .running.
.error Fatal. error is set.

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

Case Raised as
.permissionDenied Fatal error. Camera permission is not granted.
.lensUnavailable Fatal error when no camera at all could be bound; non-fatal warning when a lens pin failed and the session fell back to Auto.
.configurationFailed Fatal error. The capture session could not be configured.

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.

        let capabilities = VSDKCameraCapabilities.snapshot()

let backLenses: [VSDKLens] = capabilities.lenses(for: .back)
let stops: [NSNumber] = capabilities.zoomStops(for: .back)
let torchAvailable: Bool = capabilities.hasTorch(for: .back)
let focusPointSupported: Bool = capabilities.supportsFocusPoint(for: .back)

      
Method Returns
snapshot() A fresh VSDKCameraCapabilities. There is no public initializer.
lenses(for:) Every physical lens on that facing, widest first. A single-lens device returns one entry.
zoomStops(for:) Wide-normalized stops for a zoom pill: a sub-1.0 entry when the widest constituent sits below the wide lens, then always 1.0, then the real switch-over ratios - for example [0.5, 1.0, 3.0]. Useful for building "0.5x / 1x / 3x" buttons. These are UI stops, not purely hardware switch points; see VSDKLens.zoomSwitchPoints. Returns [1.0] if nothing can be enumerated.
hasTorch(for:) Whether any lens on that facing has a torch. Reads the dedicated torch flag, not still-photo flash.
supportsFocusPoint(for:) Whether any lens on that facing supports a focus point of interest.

VSDKLens fields

Field Type Meaning
id String The device unique ID. Stable identifier to pass around, persist, or match against.
kind VSDKLensKind .ultraWide, .wide, .telephoto, or .unknown.
facing VSDKLensFacing .back or .front.
minZoomRatio Float Lower bound of this lens's own range, wide-normalized.
maxZoomRatio Float Upper bound of this lens's own range, wide-normalized, capped at 8.0.
zoomSwitchPoints [NSNumber] Only the real ratios at which iOS swaps physical constituents on this facing, wide-normalized - for example [1.0, 3.0] on an ultra-wide + wide + telephoto iPhone. Every lens on a facing carries the same array. Not the same as zoomStops(for:), which additionally injects the 1.0 UI baseline and a sub-1.0 entry; the same device reports [0.5, 1.0, 3.0] there.
hasFlash Bool Still-photo flash presence. For scanning, use hasTorch(for:) on capabilities instead.
isLogical Bool Always false for lenses returned by lenses(for:); these are physical constituents, never virtual multi-camera devices.
isPinnable Bool Whether this lens can be passed to VSDKLensSelection.pin(_:).

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

        // Absolute, wide-normalized ratio
scannerView.setZoomRatio(2.0)

// FOV-linear 0...1 position, for a slider whose travel should feel even
scannerView.setLinearZoom(0.35)

// Reads
let current  = scannerView.getCurrentZoomRatio()
let minRatio = scannerView.getMinZoomRatioAvailable()
let maxRatio = scannerView.getMaxZoomRatioAvailable()
let linear   = scannerView.currentLinearZoom()

      

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.0 is telephoto-1x - roughly 3x wide-equivalent framing, not wide-1x.
  • state.minZoomRatio reports 1.0 while state.activeLens?.minZoomRatio still reports 3.0. VSDKCameraCapabilities and VSDKLens always 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:):

        scannerView.rampZoomRatio(4.0, durationMs: 400)

      

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

        scannerView.setFlashTurnedOn(true)

      

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

        // One-shot autofocus / metering at a VIEW-normalized point:
// {0, 0} is the top-left of the scanner view, {1, 1} the bottom-right.
scannerView.setFocusPoint(CGPoint(x: 0.5, y: 0.5))

scannerView.setFocusMode(.continuous)

      

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.

VSDKFocusMode Meaning
.continuous Continuous autofocus. The default.
.single One-shot autofocus, then hold.
.locked Focus locked at its current position.

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:

        let capabilities = VSDKCameraCapabilities.snapshot()

guard let telephoto = capabilities.lenses(for: .back).first(where: { $0.kind == .telephoto }) else {
    return
}

do {
    let selection = try VSDKLensSelection.pin(telephoto)
    scannerView.setLensSelection(selection)
} catch {
    // pin(_:) throws only when the lens reports isPinnable == false.
    // Fall back to Auto rather than leaving the session unconfigured.
    scannerView.setLensSelection(.automatic)
}

      

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(_:).

INFO

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:

        scannerView.enableTapToFocus()
scannerView.enablePinchPanToZoom()

scannerView.disableTapToFocus()
scannerView.disablePinchPanToZoom()

      

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 true once the currently bound camera has delivered at least one frame to the frame pipeline.
  • It is false initially, and throughout .idle and .starting.
  • It is reset to false on 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.

        func codeScannerView(_ scannerView: CodeScannerView, didChangeCameraState state: VSDKCameraState) {
    // Runs on the main queue.
    let shouldShowPreview = state.isPreviewActive
    UIView.animate(withDuration: 0.2) {
        scannerView.alpha = shouldShowPreview ? 1 : 0
        self.loadingOverlay.alpha = shouldShowPreview ? 0 : 1
    }
}

      

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 by startRunning().
  • 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.

        scannerView.stopRunning {
    // The capture session has genuinely finished releasing here.
    presentNextCameraScreen()
}

      

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:

        func codeScannerView(_ scannerView: CodeScannerView, didChangeCameraState state: VSDKCameraState) {
    if state.isReleased {
        // This specific snapshot means teardown just completed - it will not stay true.
        presentNextCameraScreen()
    }
}

      

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.

        import UIKit
import AVFoundation
import VisionSDK

final class ScannerViewController: UIViewController {

    private let scannerView = CodeScannerView(frame: .zero)
    private let loadingOverlay = UIView()

    private let zoomSlider = UISlider()
    private let torchButton = UIButton(type: .system)

    private var pinnedLens: VSDKLens?

    override func viewDidLoad() {
        super.viewDidLoad()

        scannerView.frame = view.bounds
        scannerView.alpha = 0
        view.addSubview(scannerView)

        loadingOverlay.frame = view.bounds
        loadingOverlay.backgroundColor = .black
        view.addSubview(loadingOverlay)

        setUpControls()

        scannerView.configure(
            delegate: self,
            sessionPreset: .high,
            captureMode: .auto,
            captureType: .single,
            scanMode: .autoBarCodeOrQRCode
        )

        // Opt into the built-in gestures; both are off by default.
        scannerView.enableTapToFocus()
        scannerView.enablePinchPanToZoom()

        scannerView.startRunning()
    }

    private func setUpControls() {
        let capabilities = VSDKCameraCapabilities.snapshot()

        // Only offer a torch button if the back facing actually has one.
        torchButton.isHidden = !capabilities.hasTorch(for: .back)
        torchButton.addTarget(self, action: #selector(toggleTorch), for: .touchUpInside)

        // Linear zoom gives even slider travel across the whole range.
        zoomSlider.minimumValue = 0
        zoomSlider.maximumValue = 1
        zoomSlider.value = 0
        zoomSlider.addTarget(self, action: #selector(zoomChanged), for: .valueChanged)

        // Remember the telephoto so a "distance mode" toggle can pin it later.
        pinnedLens = capabilities.lenses(for: .back).first { $0.kind == .telephoto }
    }

    @objc private func zoomChanged() {
        scannerView.setLinearZoom(zoomSlider.value)
    }

    @objc private func toggleTorch() {
        scannerView.setFlashTurnedOn(!scannerView.currentCameraState.isTorchEnabled)
    }

    @objc private func enableDistanceMode() {
        guard let telephoto = pinnedLens else { return }
        do {
            scannerView.setLensSelection(try VSDKLensSelection.pin(telephoto))
        } catch {
            scannerView.setLensSelection(.automatic)
        }
        // Pinning re-bases zoom to the telephoto's own 1x, so re-read the range from
        // the next .running snapshot rather than trusting the old one. On v2.4.0 this
        // pin must also be re-applied after any startRunning() or setSessionPresetTo(_:);
        // from v2.4.1 it persists on its own.
    }

    @objc private func disableDistanceMode() {
        scannerView.setLensSelection(.automatic)
    }

    // Focus where the user tapped, in view-normalized coordinates.
    private func focus(atViewPoint point: CGPoint) {
        scannerView.setFocusPoint(
            CGPoint(x: point.x / scannerView.bounds.width,
                    y: point.y / scannerView.bounds.height)
        )
    }
}

extension ScannerViewController: CodeScannerViewDelegate {

    func codeScannerView(_ scannerView: CodeScannerView, didChangeCameraState state: VSDKCameraState) {
        // Delivered on the main queue, full snapshot, on every transition.

        // 1. Reveal the preview only once a frame has actually landed.
        UIView.animate(withDuration: 0.2) {
            scannerView.alpha = state.isPreviewActive ? 1 : 0
            self.loadingOverlay.alpha = state.isPreviewActive ? 0 : 1
        }

        // 2. Only trust zoom/torch values while RUNNING; .starting carries defaults.
        if state.status == .running {
            torchButton.isSelected = state.isTorchEnabled

            if state.maxZoomRatio > state.minZoomRatio {
                zoomSlider.isEnabled = true
                zoomSlider.value = scannerView.currentLinearZoom()
            } else {
                zoomSlider.isEnabled = false
            }

            // 3. A failed lens pin is a non-fatal warning, not an error.
            if let warning = state.warning,
               warning.code == VSDKCameraErrorCode.lensUnavailable.rawValue {
                print("Requested lens unavailable; running on Auto instead.")
            }

            if let requested = pinnedLens, let active = state.activeLens, requested != active {
                print("Pin did not take effect. Active lens is \(active.id).")
            }
        }

        // 4. Handle interruptions explicitly instead of appearing frozen.
        if state.status == .interrupted {
            print("Camera interrupted; recovery is automatic.")
        }

        if state.status == .error, let error = state.error {
            print("Camera error \(error.code) in domain \(error.domain)")
        }
    }

    func codeScannerView(_ scannerView: CodeScannerView, didSuccess codes: [DetectedCode]) {
        print(codes)
    }

    func codeScannerView(_ scannerView: CodeScannerView, didFailure error: NSError) {
        // Fatal camera states are also relayed here for apps that only implement didFailure.
        print(error)
    }

    // Required @objc protocol member. The SDK's protocol-extension default cannot satisfy an
    // @objc requirement, so it must be declared here even if you do nothing with it.
    func codeScannerViewDidDetect(_ text: Bool, barCode: Bool, qrCode: Bool, document: Bool) {}
}

      

Objective-C

Everything except VSDKLensSelection.pin(_:) is available from Objective-C:

        #import <VisionSDK/VisionSDK-Swift.h>

// Controls
[self.scannerView setZoomRatio:2.0f];
[self.scannerView setFlashTurnedOn:YES];
[self.scannerView setFocusMode:VSDKFocusModeContinuous];
[self.scannerView setLensSelection:VSDKLensSelection.automatic];

// Capabilities
VSDKCameraCapabilities *capabilities = [VSDKCameraCapabilities snapshot];
BOOL hasTorch = [capabilities hasTorchFor:VSDKLensFacingBack];

// Replay-on-attach read
VSDKCameraState *state = self.scannerView.currentCameraState;
BOOL ready = state.isPreviewActive;

      
        // Delegate method, same full snapshot and same main-queue delivery as Swift.
- (void)codeScannerView:(CodeScannerView *)scannerView
   didChangeCameraState:(VSDKCameraState *)state {
    if (state.status == VSDKCameraStatusRunning) {
        NSLog(@"zoom %f in %f...%f", state.zoomRatio, state.minZoomRatio, state.maxZoomRatio);
    }
}

      

Because pin(_:) throws, pinning a specific lens must be done from Swift. Objective-C callers can still reset to VSDKLensSelection.automatic.