Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased]

- We added support for QR of available remaining types including pdf417 in barcode scanner functinality.
- We fixed the barcode mask not visible issue and selection logic of QR code.

## [4.2.2] - 2025-12-23

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "barcode-scanner-native",
"widgetName": "BarcodeScanner",
"version": "4.4.0",
"version": "4.4.1",
"license": "Apache-2.0",
"repository": {
"type": "git",
Expand All @@ -21,7 +21,7 @@
"dependencies": {
"@mendix/piw-native-utils-internal": "*",
"@mendix/piw-utils-internal": "*",
"react-native-barcode-mask": "^1.2.4",
"react-native-svg": "15.15.4",
"react-native-vision-camera": "4.7.3"
},
"devDependencies": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,141 @@
import { flattenStyles } from "@mendix/piw-native-utils-internal";
import { ValueStatus } from "mendix";
import { ReactElement, useCallback, useMemo, useRef } from "react";
import { View } from "react-native";
import { Camera, useCodeScanner, Code, useCameraDevice } from "react-native-vision-camera";
import BarcodeMask from "react-native-barcode-mask";
import { ReactElement, useCallback, useMemo, useRef, useState } from "react";
import { View, LayoutChangeEvent, Platform } from "react-native";
import { Camera, useCodeScanner, Code, useCameraDevice, CodeScannerFrame } from "react-native-vision-camera";
import BarcodeMask from "./components/BarcodeMask";

import { BarcodeScannerProps } from "../typings/BarcodeScannerProps";
import { BarcodeScannerStyle, defaultBarcodeScannerStyle } from "./ui/styles";
import { executeAction } from "@mendix/piw-utils-internal";

export type Props = BarcodeScannerProps<BarcodeScannerStyle>;

type CodePositionInfo = {
isWithinMask: boolean;
distanceToMaskCenterSquared: number;
overlapArea: number;
overlapPercentage: number;
};

type TransformedCoordinates = {
codeX: number;
codeY: number;
codeWidth: number;
codeHeight: number;
};

type OverlapInfo = {
overlapArea: number;
overlapPercentage: number;
};

type CandidateCode = {
code: Code;
isWithinMask: boolean;
distanceToMaskCenterSquared: number;
overlapArea: number;
overlapPercentage: number;
};

/**
* Transforms barcode coordinates from camera sensor space to screen view space.
* Handles platform-specific coordinate systems (iOS sensor landscape vs Android ML Kit rotated)
* and applies appropriate scaling based on device orientation.
*/
function transformCodeCoordinates(
codeFrame: { x: number; y: number; width: number; height: number },
scanFrame: CodeScannerFrame,
viewWidth: number,
viewHeight: number
): TransformedCoordinates {
const { width: frameWidth, height: frameHeight } = scanFrame;
const isPortrait = viewHeight > viewWidth;

let codeX: number;
let codeY: number;
let codeWidth: number;
let codeHeight: number;

if (isPortrait && Platform.OS === "ios") {
// iOS: code.frame coordinates are in the sensor's native landscape space,
// so we need a 90° rotation to map to portrait view coordinates.
const scaleX = viewWidth / frameHeight;
const scaleY = viewHeight / frameWidth;

codeX = (frameHeight - codeFrame.y - codeFrame.height) * scaleX;
codeY = codeFrame.x * scaleY;
codeWidth = codeFrame.height * scaleX;
codeHeight = codeFrame.width * scaleY;
} else if (isPortrait && Platform.OS === "android") {
// Android: ML Kit already rotates code.frame coordinates to match device orientation,
// but CodeScannerFrame still reports sensor landscape dimensions (e.g. 1920x1080).
// Scale using the shorter dimension for X and longer for Y.
const scaleX = viewWidth / Math.min(frameWidth, frameHeight);
const scaleY = viewHeight / Math.max(frameWidth, frameHeight);

codeX = codeFrame.x * scaleX;
codeY = codeFrame.y * scaleY;
codeWidth = codeFrame.width * scaleX;
codeHeight = codeFrame.height * scaleY;
} else {
const scaleX = viewWidth / frameWidth;
const scaleY = viewHeight / frameHeight;

codeX = codeFrame.x * scaleX;
codeY = codeFrame.y * scaleY;
codeWidth = codeFrame.width * scaleX;
codeHeight = codeFrame.height * scaleY;
}

return { codeX, codeY, codeWidth, codeHeight };
}

/**
* Calculates the overlap between a barcode and the mask region.
* Returns both the absolute overlap area and the percentage of the barcode that overlaps with the mask.
*/
function calculateOverlap(
codeX: number,
codeY: number,
codeWidth: number,
codeHeight: number,
maskX: number,
maskY: number,
maskWidth: number,
maskHeight: number
): OverlapInfo {
const overlapLeft = Math.max(codeX, maskX);
const overlapTop = Math.max(codeY, maskY);
const overlapRight = Math.min(codeX + codeWidth, maskX + maskWidth);
const overlapBottom = Math.min(codeY + codeHeight, maskY + maskHeight);

const overlapWidth = Math.max(0, overlapRight - overlapLeft);
const overlapHeight = Math.max(0, overlapBottom - overlapTop);

const overlapArea = overlapWidth * overlapHeight;
const barcodeArea = codeWidth * codeHeight;
const overlapPercentage = barcodeArea > 0 ? overlapArea / barcodeArea : 0;

return { overlapArea, overlapPercentage };
}

/**
* Comparator function to select the best barcode when multiple codes are detected.
* Priority: overlap percentage > overlap area > distance to mask center (squared).
*/
function compareCodesByPriority(a: CandidateCode, b: CandidateCode): number {
if (b.overlapPercentage !== a.overlapPercentage) {
return b.overlapPercentage - a.overlapPercentage;
}

if (b.overlapArea !== a.overlapArea) {
return b.overlapArea - a.overlapArea;
}

return a.distanceToMaskCenterSquared - b.distanceToMaskCenterSquared;
}

export function BarcodeScanner(props: Props): ReactElement {
const device = useCameraDevice("back");

Expand All @@ -19,18 +144,113 @@ export function BarcodeScanner(props: Props): ReactElement {
// Ref to track the lock state
const isLockedRef = useRef(false);

const [cameraViewDimensions, setCameraViewDimensions] = useState<{ width: number; height: number } | null>(null);

const maskWidth = styles.mask.width || 280;
const maskHeight = styles.mask.height || 260;

const getCodePositionInfo = useCallback(
(code: Code, scanFrame: CodeScannerFrame): CodePositionInfo => {
if (!props.showMask) {
return {
isWithinMask: true,
distanceToMaskCenterSquared: 0,
overlapArea: Number.MAX_SAFE_INTEGER,
overlapPercentage: 1
};
}

if (!cameraViewDimensions || !code.frame) {
return {
isWithinMask: false,
distanceToMaskCenterSquared: Number.MAX_SAFE_INTEGER,
overlapArea: 0,
overlapPercentage: 0
};
}

const { width: viewWidth, height: viewHeight } = cameraViewDimensions;

// Barcode mask coordinates in view space
const maskX = (viewWidth - maskWidth) / 2;
const maskY = (viewHeight - maskHeight) / 2;
const maskCenterX = maskX + maskWidth / 2;
const maskCenterY = maskY + maskHeight / 2;

// Transform Qr barcode coordinates from camera sensor space to view space
const { codeX, codeY, codeWidth, codeHeight } = transformCodeCoordinates(
code.frame,
scanFrame,
viewWidth,
viewHeight
);

const codeCenterX = codeX + codeWidth / 2;
const codeCenterY = codeY + codeHeight / 2;

const distanceToMaskCenterSquared =
Math.pow(codeCenterX - maskCenterX, 2) + Math.pow(codeCenterY - maskCenterY, 2);

const { overlapArea, overlapPercentage } = calculateOverlap(
codeX,
codeY,
codeWidth,
codeHeight,
maskX,
maskY,
maskWidth,
maskHeight
);

const isWithinMask =
codeCenterX >= maskX &&
codeCenterX <= maskX + maskWidth &&
codeCenterY >= maskY &&
codeCenterY <= maskY + maskHeight;

return {
isWithinMask,
distanceToMaskCenterSquared,
overlapArea,
overlapPercentage
};
},
[props.showMask, cameraViewDimensions, maskWidth, maskHeight]
);

const onCodeScanned = useCallback(
(codes: Code[]) => {
(codes: Code[], frame: CodeScannerFrame) => {
// Block if still in cooldown
if (isLockedRef.current) {
return;
}

if (props.barcode.status !== ValueStatus.Available || codes.length === 0 || !codes[0].value) {
if (props.barcode.status !== ValueStatus.Available || codes.length === 0) {
return;
}

const candidates = codes
.map(code => {
const positionInfo = getCodePositionInfo(code, frame);
return {
code,
...positionInfo
};
})
.filter(item => item.isWithinMask);

if (candidates.length === 0) {
return;
}

const { value } = codes[0];
const selectedCode = candidates.sort(compareCodesByPriority)[0].code;

if (!selectedCode.value) {
return;
}

const { value } = selectedCode;

if (value !== props.barcode.value) {
props.barcode.setValue(value);
}
Expand All @@ -43,7 +263,7 @@ export function BarcodeScanner(props: Props): ReactElement {
isLockedRef.current = false;
}, 2000);
},
[props.barcode, props.onDetect]
[props.barcode, props.onDetect, getCodePositionInfo]
);

const codeScanner = useCodeScanner({
Expand All @@ -65,27 +285,34 @@ export function BarcodeScanner(props: Props): ReactElement {
onCodeScanned
});

const handleCameraLayout = useCallback((event: LayoutChangeEvent) => {
const { width, height } = event.nativeEvent.layout;
setCameraViewDimensions({ width, height });
}, []);

return (
<View style={styles.container}>
{device && (
<Camera
testID={props.name}
style={{ flex: 1, justifyContent: "center", alignItems: "center" }}
audio={false}
isActive
device={device}
codeScanner={codeScanner}
>
<>
<Camera
testID={props.name}
style={{ flex: 1, justifyContent: "center", alignItems: "center" }}
audio={false}
isActive
device={device}
codeScanner={codeScanner}
onLayout={handleCameraLayout}
/>
{props.showMask && (
<BarcodeMask
edgeColor={styles.mask.color}
width={styles.mask.width}
height={styles.mask.height}
width={maskWidth}
height={maskHeight}
backgroundColor={styles.mask.backgroundColor}
showAnimatedLine={props.showAnimatedLine}
/>
)}
</Camera>
</>
)}
</View>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jest.mock("react-native-vision-camera", () => ({
}
}));

jest.mock("react-native-barcode-mask", () => "BarcodeMask");
jest.mock("../components/BarcodeMask", () => "BarcodeMask");

describe("BarcodeScanner", () => {
let defaultProps: Props;
Expand Down
Loading
Loading