diff --git a/packages/pluggableWidgets/barcode-scanner-native/CHANGELOG.md b/packages/pluggableWidgets/barcode-scanner-native/CHANGELOG.md index 6264d826b..1ec105dc9 100644 --- a/packages/pluggableWidgets/barcode-scanner-native/CHANGELOG.md +++ b/packages/pluggableWidgets/barcode-scanner-native/CHANGELOG.md @@ -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 diff --git a/packages/pluggableWidgets/barcode-scanner-native/package.json b/packages/pluggableWidgets/barcode-scanner-native/package.json index 7fda1b4a8..33c62cfc7 100644 --- a/packages/pluggableWidgets/barcode-scanner-native/package.json +++ b/packages/pluggableWidgets/barcode-scanner-native/package.json @@ -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", @@ -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": { diff --git a/packages/pluggableWidgets/barcode-scanner-native/src/BarcodeScanner.tsx b/packages/pluggableWidgets/barcode-scanner-native/src/BarcodeScanner.tsx index 779849238..a48767b4b 100644 --- a/packages/pluggableWidgets/barcode-scanner-native/src/BarcodeScanner.tsx +++ b/packages/pluggableWidgets/barcode-scanner-native/src/BarcodeScanner.tsx @@ -1,9 +1,9 @@ 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"; @@ -11,6 +11,131 @@ import { executeAction } from "@mendix/piw-utils-internal"; export type Props = BarcodeScannerProps; +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"); @@ -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); } @@ -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({ @@ -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 ( {device && ( - + <> + {props.showMask && ( )} - + )} ); diff --git a/packages/pluggableWidgets/barcode-scanner-native/src/__tests__/BarcodeScanner.spec.tsx b/packages/pluggableWidgets/barcode-scanner-native/src/__tests__/BarcodeScanner.spec.tsx index dbf475a34..4774fd46e 100644 --- a/packages/pluggableWidgets/barcode-scanner-native/src/__tests__/BarcodeScanner.spec.tsx +++ b/packages/pluggableWidgets/barcode-scanner-native/src/__tests__/BarcodeScanner.spec.tsx @@ -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; diff --git a/packages/pluggableWidgets/barcode-scanner-native/src/__tests__/__snapshots__/BarcodeScanner.spec.tsx.snap b/packages/pluggableWidgets/barcode-scanner-native/src/__tests__/__snapshots__/BarcodeScanner.spec.tsx.snap index 479e716ac..f533bae22 100644 --- a/packages/pluggableWidgets/barcode-scanner-native/src/__tests__/__snapshots__/BarcodeScanner.spec.tsx.snap +++ b/packages/pluggableWidgets/barcode-scanner-native/src/__tests__/__snapshots__/BarcodeScanner.spec.tsx.snap @@ -15,6 +15,7 @@ exports[`BarcodeScanner renders 1`] = ` codeScanner="mockCodeScanner" device="mock-device" isActive={true} + onLayout={[Function]} style={ { "alignItems": "center", @@ -42,6 +43,7 @@ exports[`BarcodeScanner renders with mask 1`] = ` codeScanner="mockCodeScanner" device="mock-device" isActive={true} + onLayout={[Function]} style={ { "alignItems": "center", @@ -50,13 +52,14 @@ exports[`BarcodeScanner renders with mask 1`] = ` } } testID="barcode-scanner-test" - > - - + /> + `; @@ -75,6 +78,7 @@ exports[`BarcodeScanner renders with mask with animated line 1`] = ` codeScanner="mockCodeScanner" device="mock-device" isActive={true} + onLayout={[Function]} style={ { "alignItems": "center", @@ -83,12 +87,13 @@ exports[`BarcodeScanner renders with mask with animated line 1`] = ` } } testID="barcode-scanner-test" - > - - + /> + `; diff --git a/packages/pluggableWidgets/barcode-scanner-native/src/components/BarcodeMask.tsx b/packages/pluggableWidgets/barcode-scanner-native/src/components/BarcodeMask.tsx new file mode 100644 index 000000000..075d6eaa6 --- /dev/null +++ b/packages/pluggableWidgets/barcode-scanner-native/src/components/BarcodeMask.tsx @@ -0,0 +1,139 @@ +import { ReactElement, useEffect, useRef } from "react"; +import { Animated, View } from "react-native"; +import Svg, { Line, Path } from "react-native-svg"; +import { barcodeMaskStyles as styles } from "../ui/styles"; + +const AnimatedLine = Animated.createAnimatedComponent(Line); + +export type BarcodeMaskSvgProps = { + edgeColor?: string; + width?: number; + height?: number; + showAnimatedLine?: boolean; + backgroundColor?: string; +}; + +const EDGE_WIDTH = 25; +const EDGE_HEIGHT = 25; +const EDGE_BORDER_WIDTH = 4; +const ANIMATED_LINE_COLOR = "#fff"; +const ANIMATED_LINE_THICKNESS = 3; +const LINE_ANIMATION_DURATION = 2000; + +export const BarcodeMask = (props: BarcodeMaskSvgProps): ReactElement => { + const { + edgeColor = "#fff", + width = 280, + height = 260, + showAnimatedLine = true, + backgroundColor = "rgba(0, 0, 0, 0.6)" + } = props; + + const lineY = useRef(new Animated.Value(0)).current; + const lineStrokeWidth = useRef(new Animated.Value(0)).current; + + useEffect(() => { + lineY.setValue(0); + lineStrokeWidth.setValue(0); + + Animated.spring(lineStrokeWidth, { + toValue: ANIMATED_LINE_THICKNESS, + damping: 15, + stiffness: 100, + mass: 0.5, + useNativeDriver: false + }).start(); + + Animated.loop( + Animated.sequence([ + Animated.timing(lineY, { + toValue: height, + duration: LINE_ANIMATION_DURATION, + useNativeDriver: false + }), + Animated.timing(lineY, { + toValue: 0, + duration: LINE_ANIMATION_DURATION, + useNativeDriver: false + }) + ]) + ).start(); + }, [height, lineY, lineStrokeWidth]); + + const strokeOffset = EDGE_BORDER_WIDTH / 2; + const maskLeft = strokeOffset; + const maskTop = strokeOffset; + const maskRight = width - strokeOffset; + const maskBottom = height - strokeOffset; + + return ( + + + + + + + + + + + + + + + {showAnimatedLine && ( + + )} + + + + + + + ); +}; + +export default BarcodeMask; diff --git a/packages/pluggableWidgets/barcode-scanner-native/src/package.xml b/packages/pluggableWidgets/barcode-scanner-native/src/package.xml index 679718e6c..fdda5ba78 100644 --- a/packages/pluggableWidgets/barcode-scanner-native/src/package.xml +++ b/packages/pluggableWidgets/barcode-scanner-native/src/package.xml @@ -1,6 +1,6 @@ - + diff --git a/packages/pluggableWidgets/barcode-scanner-native/src/ui/styles.tsx b/packages/pluggableWidgets/barcode-scanner-native/src/ui/styles.tsx index 6ad1c7b94..57d71ebe5 100644 --- a/packages/pluggableWidgets/barcode-scanner-native/src/ui/styles.tsx +++ b/packages/pluggableWidgets/barcode-scanner-native/src/ui/styles.tsx @@ -1,5 +1,5 @@ import { Style } from "@mendix/piw-native-utils-internal"; -import { ViewStyle } from "react-native"; +import { StyleSheet, ViewStyle } from "react-native"; export interface BarcodeScannerStyle extends Style { container: ViewStyle; @@ -22,3 +22,31 @@ export const defaultBarcodeScannerStyle: BarcodeScannerStyle = { backgroundColor: "rgba(0, 0, 0, 0.6)" } }; + +export const barcodeMaskStyles = StyleSheet.create({ + container: { + flex: 1, + display: "flex", + alignItems: "center", + justifyContent: "space-around", + position: "absolute", + height: "100%", + width: "100%" + }, + maskCenter: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-around" + }, + mask: { + position: "relative", + maxHeight: "100%", + maxWidth: "100%", + zIndex: 99 + }, + svg: { + position: "absolute", + top: 0, + left: 0 + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad1e3164e..a7afd4b73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -350,9 +350,9 @@ importers: '@mendix/piw-utils-internal': specifier: '*' version: link:../../tools/piw-utils-internal - react-native-barcode-mask: - specifier: ^1.2.4 - version: 1.2.4 + react-native-svg: + specifier: 15.15.4 + version: 15.15.4(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) react-native-vision-camera: specifier: 4.7.3 version: 4.7.3(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3))(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3) @@ -5994,9 +5994,6 @@ packages: react-native-animatable@1.4.0: resolution: {integrity: sha512-DZwaDVWm2NBvBxf7I0wXKXLKb/TxDnkV53sWhCvei1pRyTX3MVFpkvdYBknNBqPrxYuAIlPxEp7gJOidIauUkw==} - react-native-barcode-mask@1.2.4: - resolution: {integrity: sha512-Jh3mEs/xUBs/z9mKkj2mM07MMQ2SdtW9mU4FOo5DQyA2kDte6lW87vIYFkWw1nxpzc7sy28ahMsgId8LTtKZsg==} - react-native-blob-util@0.24.7: resolution: {integrity: sha512-3vgn3hblfJh0+LIoqEhYRqCtwKh1xID2LtXHdTrUml3rYh4xj69eN+lvWU235AL0FRbX5uKrS1c4lIYexSgtWQ==} peerDependencies: @@ -13826,10 +13823,6 @@ snapshots: dependencies: prop-types: 15.8.1 - react-native-barcode-mask@1.2.4: - dependencies: - prop-types: 15.8.1 - react-native-blob-util@0.24.7(react-native@0.84.1(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(react@19.2.3))(react@19.2.3): dependencies: base-64: 0.1.0