scandit-react-native-datacapture-core
Version:
Scandit Data Capture SDK for React Native
1,090 lines (1,069 loc) • 44.2 kB
JavaScript
import { CORE_PROXY_TYPE_NAMES, registerCoreProxies, loadCoreDefaults, setCoreDefaultsLoader, BaseDataCaptureView, DataCaptureContext, TorchState, CameraPosition, FrameSourceState, Camera } from './core.js';
export { AimerViewfinder, Anchor, Brush, CameraSettings, ClusteringMode, Color, ContextStatus, DataCaptureContextSettings, Direction, Expiration, Feedback, FocusGestureStrategy, FocusRange, FontFamily, FrameDataSettings, FrameDataSettingsBuilder, ImageBuffer, ImageFrameSource, LaserlineViewfinder, LicenseInfo, LogoStyle, MacroMode, MarginsWithUnit, MeasureUnit, NoViewfinder, NoneLocationSelection, NumberWithUnit, OpenSourceSoftwareLicenseInfo, Orientation, PinchToZoom, Point, PointWithUnit, Quadrilateral, RadiusLocationSelection, Rect, RectWithUnit, RectangularLocationSelection, RectangularViewfinder, RectangularViewfinderAnimation, RectangularViewfinderLineStyle, RectangularViewfinderStyle, ScanIntention, ScanditIcon, ScanditIconBuilder, ScanditIconShape, ScanditIconType, SelectionMode, Size, SizeWithAspect, SizeWithUnit, SizeWithUnitAndAspect, SizingMode, Sound, SwipeToZoom, TapToFocus, TextAlignment, TorchSwitchControl, Vibration, VideoResolution, WaveFormVibration, ZoomSwitchControl, ZoomSwitchOrientation } from './core.js';
import { NativeEventEmitter, Platform, NativeModules, TurboModuleRegistry, findNodeHandle, requireNativeComponent, AppState, PermissionsAndroid } from 'react-native';
import React, { useState, useEffect, useCallback, useMemo, createContext, useContext, useRef } from 'react';
class RNNativeCaller {
nativeModule;
_nativeEventEmitter = null;
constructor(nativeModule) {
this.nativeModule = nativeModule;
}
/**
* Lazily creates the NativeEventEmitter only when needed (old architecture fallback).
* Avoids the "NativeEventEmitter was called with a non-null argument without the
* required addListener method" warning that fires when eagerly constructing the
* emitter for TurboModule-based native modules.
*/
get nativeEventEmitter() {
if (!this._nativeEventEmitter) {
this._nativeEventEmitter = new NativeEventEmitter(this.nativeModule);
}
return this._nativeEventEmitter;
}
get framework() {
return 'react-native';
}
get frameworkVersion() {
const { major, minor, patch } = Platform.constants.reactNativeVersion;
return `${major}.${minor}.${patch}`;
}
callFn(fnName, args, _meta) {
// meta parameter ignored - React Native handles events automatically through NativeEventEmitter
const fn = this.nativeModule[fnName];
// Some frameworks pass array-like objects with length property
const hasLength = args && typeof args === 'object' && 'length' in args;
if (args === null || args === undefined || (hasLength && args.length > 0)) {
return fn();
}
return fn(args);
}
registerEvent(evName, handler) {
const newArchModule = this.nativeModule;
// New architecture: use CodegenTypes.EventEmitter (onScanditEvent)
// Each subscription filters by event name
if (newArchModule.onScanditEvent) {
const subscription = newArchModule.onScanditEvent((event) => {
if (event.name === evName) {
void handler(event);
}
});
return Promise.resolve(subscription);
}
// Old architecture fallback: one listener per event name via NativeEventEmitter
return Promise.resolve(this.nativeEventEmitter.addListener(evName, (event) => {
// Fire-and-forget: intentionally not awaiting to match NativeEventEmitter's sync signature
void handler(event);
}));
}
async unregisterEvent(evName, subscription) {
try {
await subscription.remove();
}
catch (error) {
console.warn(`Failed to unregister event '${evName}':`, error);
}
}
eventHook(args) {
return args;
}
}
function createRNNativeCaller(nativeModule) {
return new RNNativeCaller(nativeModule);
}
class RNCoreNativeCallerProvider {
getNativeCaller(proxyType) {
if (!CORE_PROXY_TYPE_NAMES.includes(proxyType)) {
throw new Error(`No native module mapped for proxy type: ${proxyType}`);
}
return createRNNativeCaller(NativeModules.ScanditDataCaptureCore);
}
}
function initCoreProxy() {
registerCoreProxies(new RNCoreNativeCallerProvider());
}
function getNativeModule(name) {
let mod = null;
// Try TurboModuleRegistry first (new architecture)
// Available in RN 0.70+
if (typeof TurboModuleRegistry !== 'undefined' && TurboModuleRegistry.get) {
// Try the module name directly first
mod = TurboModuleRegistry.get(name);
if (mod) {
return mod;
}
// Try with "Native" prefix (TurboModules naming convention)
const nativeName = `Native${name}`;
mod = TurboModuleRegistry.get(nativeName);
if (mod) {
return mod;
}
}
// Fallback to NativeModules (legacy architecture)
mod = NativeModules[name];
if (mod) {
return mod;
}
throw new Error(`Module ${name} not found. Ensure the native module is properly linked.`);
}
function getModuleDefaults(name) {
const mod = getNativeModule(name);
// Constants are automatically merged by React Native
const defaults = mod.Defaults;
if (defaults) {
// Our modules will always be returned by Defaults property, the legacy
// code is there just to ensure compatibility with older versions of React Native.
return defaults;
}
// Fallback: Try getConstants() directly
if (typeof mod.getConstants === 'function') {
const constants = mod.getConstants();
if (constants?.Defaults) {
return constants.Defaults;
}
}
throw new Error(`Could not load Defaults from module ${name}`);
}
function initCoreDefaults() {
// Use helper to get defaults with fallback logic
const defaults = getModuleDefaults('ScanditDataCaptureCore');
loadCoreDefaults(defaults);
}
setCoreDefaultsLoader(initCoreDefaults);
const NativeModule = getNativeModule('ScanditDataCaptureCore');
class DataCaptureVersion {
static get pluginVersion() {
return '8.5.2';
}
static get sdkVersion() {
return NativeModule.Version;
}
}
class DataCaptureView extends React.Component {
view;
_isMounted = false;
_viewCreated = false;
_createViewRafHandle = null;
constructor(props) {
super(props);
// Do not create the view automatically. Do that only when componentDidMount is called.
this.view = new BaseDataCaptureView(props.context);
this.view.viewComponent = this;
this.view.parentId = props.parentId ?? null;
}
get scanAreaMargins() {
return this.view.scanAreaMargins;
}
set scanAreaMargins(newValue) {
this.view.scanAreaMargins = newValue;
}
get pointOfInterest() {
return this.view.pointOfInterest;
}
set pointOfInterest(newValue) {
this.view.pointOfInterest = newValue;
}
get logoStyle() {
return this.view.logoStyle;
}
set logoStyle(style) {
this.view.logoStyle = style;
}
get logoAnchor() {
return this.view.logoAnchor;
}
set logoAnchor(newValue) {
this.view.logoAnchor = newValue;
}
get logoOffset() {
return this.view.logoOffset;
}
set logoOffset(newValue) {
this.view.logoOffset = newValue;
}
get focusGesture() {
return this.view.focusGesture;
}
set focusGesture(newValue) {
this.view.focusGesture = newValue;
}
get zoomGestures() {
return this.view.zoomGestures;
}
set zoomGestures(newValue) {
this.view.zoomGestures = newValue;
}
/** @deprecated Use zoomGestures instead. Will be removed in a future version. */
get zoomGesture() {
return this.view.zoomGesture;
}
/** @deprecated Use zoomGestures instead. Will be removed in a future version. */
set zoomGesture(newValue) {
this.view.zoomGesture = newValue;
}
get shouldShowZoomNotification() {
return this.view.shouldShowZoomNotification;
}
set shouldShowZoomNotification(newValue) {
this.view.shouldShowZoomNotification = newValue;
}
setProperty(name, value) {
this.view.setProperty(name, value);
}
addOverlay(overlay) {
return this.view.addOverlay(overlay);
}
removeOverlay(overlay) {
return this.view.removeOverlay(overlay);
}
addListener(listener) {
this.view.addListener(listener);
}
removeListener(listener) {
this.view.removeListener(listener);
}
viewPointForFramePoint(point) {
return this.view.viewPointForFramePoint(point);
}
viewQuadrilateralForFrameQuadrilateral(quadrilateral) {
return this.view.viewQuadrilateralForFrameQuadrilateral(quadrilateral);
}
addControl(control) {
return this.view.addControl(control);
}
addControlWithAnchorAndOffset(control, anchor, offset) {
return this.view.addControlWithAnchorAndOffset(control, anchor, offset);
}
removeControl(control) {
return this.view.removeControl(control);
}
componentWillUnmount() {
this._isMounted = false;
this._viewCreated = false;
if (this._createViewRafHandle !== null) {
cancelAnimationFrame(this._createViewRafHandle);
this._createViewRafHandle = null;
}
this.view.dispose();
}
componentDidMount() {
this._isMounted = true;
// Dual trigger (SDC-32583): `onLayout` is the primary trigger, but on some
// setups (RN 0.78 New Architecture, Android release builds) `onLayout` is
// not reliably emitted on the Fabric view, so relying on it alone can leave
// the native view uncreated. Also attempt creation from a
// `requestAnimationFrame` loop, which is frame-aligned (fires once the view
// is committed so `findNodeHandle` is valid) and, unlike `InteractionManager`,
// cannot be starved. `_viewCreated` is set synchronously, so whichever
// trigger fires first wins exactly once.
this.scheduleCreateDataCaptureView();
}
render() {
return React.createElement(RNTDataCaptureView, { ...this.props, onLayout: this.onNativeViewLayout });
}
removeAllOverlays() {
this.view.removeAllOverlays();
}
// Create the native view on layout rather than via
// `InteractionManager.runAfterInteractions`: layout fires when the view is
// committed to the native tree (so `findNodeHandle` is valid) and is not
// starvable by a blocked JS interaction queue (e.g. a looping animation with
// `useNativeDriver: false`), which previously left the preview never created.
// See SDC-32208. `onLayout` can fire repeatedly, so create exactly once.
onNativeViewLayout = (event) => {
// Forward to a caller-supplied onLayout so our internal handler doesn't
// swallow it (render() overrides the spread `onLayout` with this one).
this.props.onLayout?.(event);
this.tryCreateDataCaptureView();
};
// Attempt to create the native view exactly once. Returns true once creation
// has been kicked off, false if the native tag is not available yet (so a
// caller can retry). Callable from both `onLayout` and the rAF loop; the
// `_viewCreated` flag is flipped synchronously to keep it single-shot.
tryCreateDataCaptureView() {
if (this._viewCreated || !this._isMounted) {
return true;
}
const viewId = findNodeHandle(this);
if (viewId === null) {
return false;
}
this._viewCreated = true;
// Whichever trigger wins tears down a pending rAF retry so the loop does
// not fire a redundant (no-op) frame afterwards.
if (this._createViewRafHandle !== null) {
cancelAnimationFrame(this._createViewRafHandle);
this._createViewRafHandle = null;
}
void this.view.createNativeView(viewId);
return true;
}
// rAF fallback loop (see componentDidMount): retry until the native tag is
// available, then create. Stops as soon as creation succeeds by either trigger.
scheduleCreateDataCaptureView = () => {
if (this._viewCreated || !this._isMounted) {
return;
}
if (this.tryCreateDataCaptureView()) {
return;
}
this._createViewRafHandle = requestAnimationFrame(this.scheduleCreateDataCaptureView);
};
}
const RNTDataCaptureView = requireNativeComponent('RNTDataCaptureView');
/**
* Returns whether the app is currently in the foreground.
* Useful for composing the `isActive` prop on scanning views:
*
* ```tsx
* const isForeground = useIsForeground();
* const isFocused = useIsFocused(); // from @react-navigation/native
* <BarcodeCaptureView isActive={isFocused && isForeground} ... />
* ```
*/
function useIsForeground() {
const [isForeground, setIsForeground] = useState(() => AppState.currentState === 'active');
useEffect(() => {
const onChange = (state) => {
setIsForeground(state === 'active');
};
const subscription = AppState.addEventListener('change', onChange);
return () => subscription.remove();
}, []);
return isForeground;
}
function mapAndroidResult(result) {
switch (result) {
case PermissionsAndroid.RESULTS.GRANTED:
return 'granted';
case PermissionsAndroid.RESULTS.DENIED:
return 'denied';
case PermissionsAndroid.RESULTS.NEVER_ASK_AGAIN:
return 'restricted';
default:
return 'not-determined';
}
}
async function checkAndroidPermission() {
const granted = await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.CAMERA);
return granted ? 'granted' : 'not-determined';
}
async function requestAndroidPermission() {
const result = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.CAMERA);
return mapAndroidResult(result);
}
/**
* Manages camera permission status.
*
* **Android**: real status via `PermissionsAndroid`; `requestPermission()` shows the native
* prompt; status re-checked when the app returns to the foreground (e.g. user flipped it
* in Settings).
*
* **iOS**: limited. Without a platform-specific native module we can't query
* `AVCaptureDevice.authorizationStatus` or drive the system prompt directly — iOS
* handles the permission dialog automatically the first time the camera is accessed.
* On iOS this hook returns `permissionStatus: 'not-determined'` initially and
* optimistically flips to `'granted'` after `requestPermission()` is called.
* It cannot detect denials after-the-fact; consumers should treat the iOS camera-start
* flow as the authoritative signal (surfaced via `<BarcodeCaptureView onError={...} />`).
*
* ```tsx
* const { hasPermission, requestPermission } = useCameraPermission();
* if (!hasPermission) return <Button onPress={requestPermission} title="Grant Camera Access" />;
* ```
*/
function useCameraPermission() {
const [status, setStatus] = useState(Platform.OS === 'android' ? 'not-determined' : 'not-determined');
const refresh = useCallback(async () => {
if (Platform.OS !== 'android')
return;
setStatus(await checkAndroidPermission());
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
// Re-check when returning from Settings (Android only — iOS can't be introspected).
useEffect(() => {
if (Platform.OS !== 'android')
return;
const onChange = (state) => {
if (state === 'active') {
void refresh();
}
};
const subscription = AppState.addEventListener('change', onChange);
return () => subscription.remove();
}, [refresh]);
const requestPermission = useCallback(async () => {
if (Platform.OS === 'android') {
const result = await requestAndroidPermission();
setStatus(result);
return result === 'granted';
}
// iOS: we can't drive the prompt from JS. Flip optimistically to 'granted';
// the native camera access triggers the iOS dialog automatically, and denial
// surfaces as a camera-start failure (hook into `BarcodeCaptureView.onError`).
setStatus('granted');
return true;
}, []);
return {
hasPermission: status === 'granted',
permissionStatus: status,
requestPermission,
};
}
/**
* Initializes (or retrieves) the DataCaptureContext singleton.
*
* ```tsx
* const settings = useMemo(() => new DataCaptureContextSettings(), []);
* const context = useScanditContext('YOUR_LICENSE_KEY', { settings });
* ```
*/
function useScanditContext(licenseKey, options) {
const context = useMemo(() => DataCaptureContext.initialize(licenseKey, options?.creationOptions ?? null, options?.settings ?? null),
// The context is a singleton; init runs once per license key. `settings`
// and `creationOptions` are intentionally excluded — settings are pushed
// by the effect below, creationOptions are init-only.
// eslint-disable-next-line react-hooks/exhaustive-deps
[licenseKey]);
// Re-apply settings whenever the consumer hands us a new instance. The
// first call here is redundant with the init above (same instance) — it's
// a no-op in native. Subsequent changes propagate to the running context.
useEffect(() => {
if (options?.settings)
void context.applySettings(options.settings);
}, [context, options?.settings]);
return context;
}
const DEFAULT_TORCH = TorchState.Off;
const DEFAULT_POSITION = CameraPosition.WorldFacing;
const DEFAULT_FRAME_SOURCE_STATE = FrameSourceState.On;
function createCameraOwner(context) {
let camera = null;
let position = null;
let desiredTorch = DEFAULT_TORCH;
let desiredState = DEFAULT_FRAME_SOURCE_STATE;
let queue = Promise.resolve();
const enqueue = (op) => {
queue = queue.then(op).catch(err => console.warn('ScanditProvider: camera operation failed', err));
return queue;
};
// `Camera.atPosition` is a process-wide singleton, so any other
// DataCaptureContext can grab it via `setFrameSource(...)` while we're idle.
// Rebind only when the camera isn't ours anymore — otherwise a no-op.
const isOurs = (cam) => cam.context === context;
// Bind `camera` to our context and push our cached desired state.
// Used after a fresh `Camera.atPosition(...)` and to reclaim a stolen camera.
const bind = async () => {
if (camera === null)
return;
camera.desiredTorchState = desiredTorch;
await context.setFrameSource(camera);
await camera.switchToDesiredState(desiredState);
};
const reclaimIfNeeded = async () => {
if (camera !== null && !isOurs(camera))
await bind();
};
return {
setPosition(next) {
void enqueue(async () => {
if (camera !== null && position === next) {
await reclaimIfNeeded();
return;
}
if (camera !== null)
await camera.switchToDesiredState(FrameSourceState.Off);
camera = Camera.atPosition(next);
position = next;
await bind();
});
},
setTorch(next) {
void enqueue(async () => {
desiredTorch = next;
await reclaimIfNeeded();
if (camera !== null)
camera.desiredTorchState = next;
});
},
setFrameSourceState(next) {
void enqueue(async () => {
desiredState = next;
await reclaimIfNeeded();
if (camera !== null)
await camera.switchToDesiredState(next);
});
},
reclaimIfNeeded() {
void enqueue(reclaimIfNeeded);
},
dispose() {
return enqueue(async () => {
const cam = camera;
camera = null;
// Skip `setFrameSource(null)` if we don't own the camera — it would
// yank it away from whichever context holds it now.
if (cam !== null && isOurs(cam)) {
await cam.switchToDesiredState(FrameSourceState.Off);
await context.setFrameSource(null);
}
await context.dispose();
});
},
};
}
const ScanditInternalContext = createContext(null);
/** Reflects a provider's camera-related props onto the singleton camera. */
function useApplyCameraProps(owner, props) {
const { frameSourceState, torchState, cameraPosition } = props;
useEffect(() => {
if (cameraPosition !== undefined)
owner.setPosition(cameraPosition);
}, [owner, cameraPosition]);
useEffect(() => {
if (torchState !== undefined)
owner.setTorch(torchState);
}, [owner, torchState]);
useEffect(() => {
if (frameSourceState !== undefined)
owner.setFrameSourceState(frameSourceState);
}, [owner, frameSourceState]);
}
/**
* Provides a `DataCaptureContext` and a singleton `Camera` to descendant AIO views.
*
* - **Root** (no parent `<ScanditProvider>` above): creates the context + camera,
* disposes them on unmount.
* - **Nested**: applies its own `frameSourceState` / `torchState` / `cameraPosition`
* props to the same singleton camera. Last writer wins; values are not reverted
* when a nested provider unmounts.
*
* The camera is recreated only when `cameraPosition` flips; torch and
* `frameSourceState` are applied directly to the live camera.
*
* ```tsx
* <ScanditProvider licenseKey={KEY}>
* <NavigationContainer> ... </NavigationContainer>
* </ScanditProvider>
*
* // Screen-local control:
* <ScanditProvider
* frameSourceState={isFocused ? FrameSourceState.On : FrameSourceState.Off}
* torchState={torch}
* cameraPosition={position}>
* <BarcodeCaptureView state="enabled" ... />
* </ScanditProvider>
* ```
*/
function ScanditProvider(props) {
const parent = useContext(ScanditInternalContext);
if (parent !== null) {
return React.createElement(NestedScanditProvider, { ...props, parent: parent });
}
if (!props.licenseKey) {
throw new Error('<ScanditProvider> requires a `licenseKey` prop when used as the root provider.');
}
return React.createElement(RootScanditProvider, { ...props, licenseKey: props.licenseKey });
}
function NestedScanditProvider({ parent, frameSourceState, torchState, cameraPosition, licenseKey, settings, options, children, }) {
if (licenseKey || settings || options) {
console.warn('ScanditProvider: licenseKey/settings/options are ignored on nested providers.');
}
useApplyCameraProps(parent.owner, { frameSourceState, torchState, cameraPosition });
return React.createElement(ScanditInternalContext.Provider, { value: parent }, children);
}
function RootScanditProvider({ licenseKey, options, settings, frameSourceState, torchState, cameraPosition, children, }) {
const context = useMemo(() => DataCaptureContext.initialize(licenseKey, options ?? null, settings ?? null),
// The context is a singleton keyed on licenseKey.
// eslint-disable-next-line react-hooks/exhaustive-deps
[licenseKey]);
const owner = useMemo(() => createCameraOwner(context), [context]);
// Apply our own props to the singleton, falling back to defaults so the
// camera is always created and in a known state.
useApplyCameraProps(owner, {
frameSourceState: frameSourceState ?? DEFAULT_FRAME_SOURCE_STATE,
torchState: torchState ?? DEFAULT_TORCH,
cameraPosition: cameraPosition ?? DEFAULT_POSITION,
});
// Dispose context + camera on unmount. Chained onto the owner's queue so
// any in-flight mutation finishes first.
useEffect(() => {
return () => {
void owner.dispose();
};
}, [owner]);
const internal = useMemo(() => ({ context, owner }), [context, owner]);
return React.createElement(ScanditInternalContext.Provider, { value: internal }, children);
}
// ─── Internal hook consumed by AIO view packages ─────────────────────────────
/** Internal — used by AIO views to attach modes to the shared context. */
function useDataCaptureContextInternal() {
const internal = useContext(ScanditInternalContext);
if (!internal) {
throw new Error('This component must be rendered inside a <ScanditProvider>.');
}
// Reclaim the singleton camera on (re)mount: anything else in the app that
// touched `Camera.atPosition` while we were unmounted may have stolen it.
useEffect(() => internal.owner.reclaimIfNeeded(), [internal]);
return internal.context;
}
function isSerializable(v) {
return typeof v === 'object' && v !== null && typeof v.toJson === 'function';
}
function signature(v) {
try {
return JSON.stringify(isSerializable(v) ? v.toJson() : v) ?? '';
}
catch {
// Cyclic / un-serializable values fall through to `''` so the caller sees
// a "changed" signature and uses the new value. Better safe than wrong.
return '';
}
}
/**
* Returns a referentially-stable copy of `value` as long as the structural
* content stays the same. Lets consumers pass inline SDK class instances
* (`new Brush(...)`, `new TorchSwitchControl()`) or plain options objects
* without memoizing — the effect deps array sees the same reference until
* the underlying content actually changes.
*
* SDK classes that extend `DefaultSerializeable` are compared via their
* `toJson()` output (which respects `@ignoreFromSerialization`), so private
* back-refs like `view` don't cause spurious diffs. Plain objects and arrays
* are compared via direct `JSON.stringify`.
*
* ```tsx
* function MyView({ brush }: { brush?: Brush | null }) {
* const stableBrush = useStableProp(brush);
* useEffect(() => {
* if (stableBrush) overlay.brush = stableBrush;
* }, [stableBrush]);
* }
* ```
*/
function useStableProp(value) {
const ref = useRef(null);
const sig = signature(value);
if (ref.current && ref.current.sig === sig)
return ref.current.value;
ref.current = { value, sig };
return value;
}
/**
* Registers a listener on a Scandit mode or view and keeps it up to date.
*
* Pass `listenerFns` with the callbacks you care about and leave the rest
* `undefined`. The hook only registers when `mode` is non-null and at least
* one callback is set; it unregisters automatically on unmount or when those
* conditions stop being true.
*
* The proxy installed on the target contains methods **only for keys whose
* values are currently defined**. This matters because some shared
* controllers do `if (listener.foo)` truthy checks (e.g.
* `BarcodeBatchBasicOverlayController.handleBrushForTrackedBarcode`) and a
* proxy method that returns `undefined` is interpreted as a real `null`
* response by the bridge — which wipes the configured default brush. When
* the set of defined keys changes (a callback flips between defined and
* undefined across renders), the proxy is rebuilt and the listener is
* unregistered + re-registered.
*
* Inline functions are fine — within the "defined" set, the registered
* listener is a stable proxy that always dispatches to the latest version of
* each callback without re-registering.
*
* ```tsx
* useModeListener<BarcodeCapture, BarcodeCaptureListener>({
* mode,
* listenerFns: {
* didScan: onScan ? async (_c, session, getFD) => onScan(session, getFD) : undefined,
* },
* addListener: (m, l) => m.addListener(l),
* removeListener: (m, l) => m.removeListener(l),
* });
* ```
*/
function useModeListener({ mode, listenerFns, addListener, removeListener, }) {
const definedKeysSig = currentDefinedKeysSig(listenerFns);
const isActive = mode != null && definedKeysSig !== '';
const listenerRef = useRef(listenerFns);
listenerRef.current = listenerFns;
const addRef = useRef(addListener);
addRef.current = addListener;
const removeRef = useRef(removeListener);
removeRef.current = removeListener;
// Rebuild the proxy whenever the set of defined keys changes. Within the
// same set, callback-identity changes are absorbed via `listenerRef`.
const stableListenerRef = useRef(null);
const stableListenerSigRef = useRef('');
if (definedKeysSig !== stableListenerSigRef.current) {
stableListenerSigRef.current = definedKeysSig;
if (definedKeysSig === '') {
stableListenerRef.current = null;
}
else {
const proxy = {};
for (const key of definedKeysSig.split(',')) {
proxy[key] = (...args) => listenerRef.current[key]?.(...args);
}
stableListenerRef.current = proxy;
}
}
useEffect(() => {
if (!isActive)
return;
const proxy = stableListenerRef.current;
if (!proxy)
return;
addRef.current(mode, proxy);
return () => removeRef.current(mode, proxy);
// The proxy identity changes only when `definedKeysSig` changes, which is
// already in the deps.
}, [mode, isActive, definedKeysSig]);
}
function currentDefinedKeysSig(listenerFns) {
const keys = [];
for (const k of Object.keys(listenerFns)) {
if (listenerFns[k] != null)
keys.push(k);
}
keys.sort();
return keys.join(',');
}
/**
* Bundles the `ref + viewState + viewId` pattern that AIO views share.
*
* - The returned `ref` is stable across renders.
* - `current` is a reactive snapshot — effects keyed on it re-run when the
* view mounts/unmounts. `mutableRef` exposes the same value for imperative
* reads that must not trigger re-renders.
* - `id` is generated once per hook instance and stays stable for the lifetime
* of the component, suitable for `parentId` serialization.
*/
function useViewHandle() {
const mutableRef = useRef(null);
const [current, setCurrent] = useState(null);
// Random over the signed-int32 range: `id` feeds native `parentId` (an int),
// and the wide range keeps collisions negligible across concurrent views.
const id = useRef(Math.floor(Math.random() * 0x7fffffff)).current;
// Readiness promise resolved on the first `onLayout`. Created lazily once per
// hook instance so `whenReady()` returns a stable promise across renders.
const readyRef = useRef(undefined);
const resolveReadyRef = useRef(undefined);
const resolvedRef = useRef(false);
const rafRef = useRef(null);
if (!readyRef.current) {
readyRef.current = new Promise(resolve => {
resolveReadyRef.current = resolve;
});
}
const resolveReady = useCallback(() => {
if (resolvedRef.current) {
return;
}
resolvedRef.current = true;
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
resolveReadyRef.current?.();
}, []);
const onLayout = useCallback(() => resolveReady(), [resolveReady]);
const ref = useCallback((v) => {
mutableRef.current = v;
setCurrent(v);
if (v === null) {
// Unmount: cancel a still-pending fallback frame so it can't fire after
// the view is gone (mirrors the class components' componentWillUnmount).
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
return;
}
// Fallback for setups where `onLayout` is not reliably emitted on the
// Fabric view (RN 0.78 New Architecture, Android release builds): once the
// view is mounted, resolve readiness on the next frame. requestAnimationFrame is
// frame-aligned (the view is committed to the native tree by then, so the
// node handle is valid) and, unlike InteractionManager, not starvable.
if (!resolvedRef.current && rafRef.current === null) {
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
resolveReady();
});
}
}, [resolveReady]);
const whenReady = useCallback(() => readyRef.current, []);
return { ref, current, mutableRef, id, onLayout, whenReady };
}
/**
* Adds `control` to `view` while both are present and the control reference is
* stable. Removes the control on unmount or when either reference changes.
*
* The view is typically a `DataCaptureView` and the control is a Scandit
* `Control` (e.g. `TorchSwitchControl`, `ZoomSwitchControl`). Pair this with
* `useStableProp(control)` at the call site so inline `new XControl()`
* instantiation doesn't churn add/remove.
*/
function useNativeControl(view, control) {
useEffect(() => {
if (!view || !control)
return;
void view.addControl(control);
return () => void view.removeControl(control);
}, [view, control]);
}
/**
* Mode-lifetime state machine shared by SDK view components.
*
* Owns: lazy mode creation, attach/detach transitions driven by `state`,
* `isEnabled` flips on `enabled`↔`disabled`, settings reapply on dep change,
* and detach-on-unmount.
*
* Side-effecting callbacks (`attach`, `detach`, `applySettings`, `setEnabled`,
* `createMode`) are read through a ref, so callers can pass closures without
* memoizing — only `state`, `canAttach`, and `settingsDeps` drive effects.
*/
function useMode(options) {
const { state, canAttach = true, settingsDeps } = options;
const optsRef = useRef(options);
optsRef.current = options;
const modeRef = useRef(null);
const attachedRef = useRef(false);
const prevStateRef = useRef('detached');
const getMode = useCallback(() => {
if (modeRef.current)
return modeRef.current;
console.debug('[useMode] create mode');
modeRef.current = optsRef.current.createMode();
return modeRef.current;
}, []);
const isAttached = useCallback(() => attachedRef.current, []);
// State machine: attach/detach + enabled flip.
useEffect(() => {
const prev = prevStateRef.current;
if (state === 'detached') {
prevStateRef.current = state;
if (!attachedRef.current) {
console.debug(`[useMode] state ${prev} -> detached (no-op, not attached)`);
return;
}
const mode = modeRef.current;
attachedRef.current = false;
console.debug(`[useMode] state ${prev} -> detached: detaching`);
if (mode) {
void detachAttachablesThen(optsRef.current.attachables, () => optsRef.current.detach(mode)).then(() => {
console.debug('[useMode] detach complete');
if (modeRef.current === mode)
modeRef.current = null;
});
}
return;
}
const enabled = state === 'enabled';
if (attachedRef.current) {
prevStateRef.current = state;
console.debug(`[useMode] state ${prev} -> ${state}: flip isEnabled=${enabled}`);
if (modeRef.current)
optsRef.current.setEnabled(modeRef.current, enabled);
return;
}
if (!canAttach) {
console.debug(`[useMode] state ${prev} -> ${state}: parked (canAttach=false)`);
return;
}
prevStateRef.current = state;
const mode = getMode();
let cancelled = false;
console.debug(`[useMode] state ${prev} -> ${state}: attaching`);
void attachThenAttachables(() => optsRef.current.attach(mode), optsRef.current.attachables).then(() => {
if (cancelled) {
console.debug('[useMode] attach resolved but cancelled');
return;
}
attachedRef.current = true;
console.debug(`[useMode] attach complete, isEnabled=${enabled}`);
if (modeRef.current)
optsRef.current.setEnabled(modeRef.current, enabled);
});
return () => {
cancelled = true;
};
}, [state, canAttach, getMode]);
// Settings reapply. Only meaningful when a mode exists.
useEffect(() => {
if (state === 'detached')
return;
if (!modeRef.current)
return;
console.debug('[useMode] reapply settings');
void Promise.resolve(optsRef.current.applySettings(modeRef.current));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state, ...settingsDeps]);
// Detach on unmount.
useEffect(() => {
return () => {
if (!attachedRef.current) {
console.debug('[useMode] unmount (not attached)');
modeRef.current = null;
return;
}
const mode = modeRef.current;
attachedRef.current = false;
console.debug('[useMode] unmount: detaching');
if (mode)
void detachAttachablesThen(optsRef.current.attachables, () => optsRef.current.detach(mode));
modeRef.current = null;
};
}, []);
return { getMode, modeRef, isAttached };
}
async function attachThenAttachables(attachMode, attachables) {
await attachMode();
if (!attachables)
return;
for (const a of attachables)
await a.attach();
}
async function detachAttachablesThen(attachables, detachMode) {
if (attachables) {
for (let i = attachables.length - 1; i >= 0; i--)
await attachables[i].detach();
}
await detachMode();
}
/**
* Wait until the underlying `BaseDataCaptureView` has finished
* `createNativeView` (which sets `viewId` to a non-negative value). Calling
* `addOverlay` before this is a silent no-op — the controller's `updateView`
* bails out when `!isViewCreated()`, so the overlay JSON never reaches native.
*
* The native-view creation is itself driven by `DataCaptureView`'s `onLayout`
* (see SDC-32208), so we just poll `viewId` until it flips to a non-negative
* value. This avoids the previous dependency on
* `InteractionManager.runAfterInteractions`, whose queue can be starved
* indefinitely by a looping JS animation, leaving the overlay never attached.
*/
async function waitForDataCaptureViewReady(view) {
const baseView = view.view;
for (let i = 0; baseView && baseView.viewId === -1 && i < 100; i++) {
await new Promise(r => setTimeout(r, 10));
}
}
/**
* Lifecycle helper for `DataCaptureView` overlays. Pass the returned value into
* `useMode({ attachables: [...] })` — `useMode` orders `attach()` after the
* mode is added to the context and `detach()` before it's removed.
*
* Listener registration is not handled here; pair with `useModeListener`
* keyed on the reactive `overlay` snapshot:
*
* ```tsx
* const basicOverlay = useOverlay<BarcodeBatchBasicOverlay>({ ... });
*
* useModeListener<BarcodeBatchBasicOverlay, BarcodeBatchBasicOverlayListener>({
* mode: basicOverlay.overlay,
* listenerFns: { brushForTrackedBarcode, didTapTrackedBarcode },
* addListener: (o, l) => { o.listener = l; },
* removeListener: o => { o.listener = null; },
* });
* ```
*/
function useOverlay(opts) {
const { view, enabled = true, factoryDeps = [], updateDeps = [] } = opts;
const optsRef = useRef(opts);
optsRef.current = opts;
const overlayRef = useRef(null);
const [overlay, setOverlay] = useState(null);
// Tracks whether `useMode` currently considers us "mode-attached" — set by
// the `attach`/`detach` callbacks it invokes via `attachables`. Used by the
// `enabled`-flip effect to gate self-driven attach/detach.
const modeAttachedRef = useRef(false);
const doAttach = useCallback(async () => {
if (overlayRef.current)
return;
const v = view.current;
if (!v)
return;
// The native view is created asynchronously when `DataCaptureView` lays out
// (see SDC-32208). Calling `addOverlay` before it's ready is a silent no-op
// (the view controller's `updateView` bails out on `!isViewCreated()`), so
// wait for the view to report a valid `viewId` first.
await waitForDataCaptureViewReady(v);
const created = optsRef.current.factory();
optsRef.current.update?.(created);
overlayRef.current = created;
await v.addOverlay(created);
setOverlay(created);
}, [view]);
const doDetach = useCallback(async () => {
const o = overlayRef.current;
overlayRef.current = null;
setOverlay(null);
if (!o)
return;
const v = view.current;
if (v)
await v.removeOverlay(o);
}, [view]);
const attach = useCallback(async () => {
modeAttachedRef.current = true;
if (optsRef.current.enabled === false)
return;
await doAttach();
}, [doAttach]);
const detach = useCallback(async () => {
modeAttachedRef.current = false;
await doDetach();
}, [doDetach]);
const getOverlay = useCallback(() => overlayRef.current, []);
// Enabled toggle handling while the mode is attached.
useEffect(() => {
if (!modeAttachedRef.current)
return;
if (enabled && !overlayRef.current) {
void doAttach();
}
else if (!enabled && overlayRef.current) {
void doDetach();
}
}, [enabled, doAttach, doDetach]);
// Recreate on factoryDeps change while attached. Skips the initial render
// (first attach is driven by useMode's attachables, not this effect).
const isFirstFactoryEffect = useRef(true);
useEffect(() => {
if (isFirstFactoryEffect.current) {
isFirstFactoryEffect.current = false;
return;
}
if (!overlayRef.current)
return;
void (async () => {
await doDetach();
await doAttach();
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, factoryDeps);
// Re-run `update` on updateDeps change while attached.
useEffect(() => {
if (!overlay)
return;
optsRef.current.update?.(overlay);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [overlay, ...updateDeps]);
return { overlay, getOverlay, attach, detach };
}
var index = /*#__PURE__*/Object.freeze({
__proto__: null,
useDataCaptureContextInternal: useDataCaptureContextInternal,
useMode: useMode,
useModeListener: useModeListener,
useNativeControl: useNativeControl,
useOverlay: useOverlay,
useStableProp: useStableProp,
useViewHandle: useViewHandle
});
initCoreDefaults();
initCoreProxy();
export { Camera, CameraPosition, DataCaptureContext, DataCaptureVersion, DataCaptureView, FrameSourceState, RNNativeCaller, ScanditProvider, TorchState, index as _internal, createRNNativeCaller, getModuleDefaults, getNativeModule, initCoreDefaults, initCoreProxy, useCameraPermission, useIsForeground, useScanditContext };
//# sourceMappingURL=index.js.map