React Native Barcode Scanner Overlay: Custom UI with Real-Time Contours

A shelf holds six labels in one camera frame, the scanner returns six values, and the operator still cannot tell which value belongs to which label. A custom overlay closes that gap: Dynamsoft’s React Native SDK reports a four-point location.points quadrilateral for every decoded barcode, and your app turns those points into contour lines, corner markers and text callouts using ordinary React Native View components. The SDK ships no scanner screen, so the entire visual layer is yours.

The difficult part is not drawing the box — it is knowing which coordinate system the points are in. item.location.points are image coordinates in the camera frame (1080 × 1920 in portrait, 1920 × 1080 in landscape), while left/top on an absolutely positioned View are preview coordinates in density-independent pixels measured by onLayout. This guide starts with the recognition primitives, then gives the scale + crop-offset transform between the two systems, rotation handling, the contour component, and a measured multi-code overlay result from the sample.

What you’ll build: A headless React Native barcode scanner whose overlay draws green contours, red corner dots and text/format callouts over every barcode in a live frame — on Android and iOS, with React Native 0.79 and the New Architecture enabled.

Key Takeaways

  • dynamsoft-capture-vision-react-native is headless: CaptureVisionRouter.getInstance() + CameraEnhancer.getInstance() + addResultReceiver() deliver DecodedBarcodesResult.items, and the app owns every visible widget.
  • Each BarcodeResultItem carries text, formatString and location.points (four corners) in image space. Preview space is the CameraView measured by onLayout; never mix the two.
  • Transform: scale = max(viewW / imageW, viewH / imageH) for aspect-fill, then x' = x * scale - (imageW * scale - viewW) / 2 and the equivalent for y. The subtracted terms are the crop offsets.
  • Orientation and layout changes invalidate the camera image basis (1080 × 1920 ↔ 1920 × 1080) and the preview dimensions. Read both from refs updated by useWindowDimensions() and onLayout; values captured in a once-registered receiver go stale.
  • Measured result: the sample’s overlay screenshot shows at least seven simultaneous quadrilaterals spanning DATAMATRIX and EAN_13, with green contours, red corner dots and text callouts aligned to each label.
  • Verified stack: React Native 0.79.0 / React 19.0.0, newArchEnabled=true, dynamsoft-capture-vision-react-native 3.6.2000 and dynamsoft-barcode-reader-bundle-react-native 11.6.2000 — both are the current npm releases as of 2026-11-12.

Common Developer Questions

How do I draw a contour around a detected barcode in React Native?

Use the four item.location.points corners returned by onDecodedBarcodesReceived, convert them into preview coordinates, and render four absolutely positioned View lines rotated with transform: [{ rotate: ... }] plus four small View circles for the corners. The BarcodeContour component in Step 3 is the working implementation.

How do I convert Dynamsoft image coordinates to React Native preview coordinates?

Compute scale = max(viewWidth / imageWidth, viewHeight / imageHeight), subtract cropOffsetX = (imageWidth * scale - viewWidth) / 2 and cropOffsetY = (imageHeight * scale - viewHeight) / 2 from each scaled point, then use the result as left/top. Use 1080 × 1920 as the image basis in portrait and 1920 × 1080 in landscape.

What is the difference between preview coordinates and image coordinates?

Image coordinates are pixels in the SDK’s camera frame (a fixed 1920 × 1080 sensor resolution, reported rotated in portrait); they describe where the barcode was found in the captured image. Preview coordinates are density-independent pixels inside the CameraView widget, obtained from onLayout, and describe where a View is drawn on screen. Because the preview is aspect-filled, parts of the image are cropped — that is why a scale alone is not enough and crop offsets are required.

How do I keep overlays aligned when the device rotates?

Use useWindowDimensions() instead of a one-time Dimensions.get('window') call, store the current orientation and onLayout size in refs, and read those refs inside the result receiver. A receiver registered once in useEffect otherwise keeps mount-time values and misplaces every box after rotation. Step 2 shows the exact pattern.

Why are barcode overlays offset on tablets or wide-aspect devices?

The crop-offset formula assumes the CameraView fills its parent with aspect-fill. If the view is constrained to a smaller region, pass the exact onLayout width and height rather than falling back to window dimensions, and recompute the offsets whenever that layout changes.

Does the Dynamsoft React Native SDK provide a built-in scanner UI?

No. CameraView renders only the camera feed; contours, callouts, buttons, themes and navigation are standard React Native components. Changing the overlay never touches recognition settings, and changing recognition settings never changes the UI.

Which React Native and SDK versions work with this overlay sample?

The sample builds on React Native 0.79.0 with React 19.0.0 and newArchEnabled=true. dynamsoft-capture-vision-react-native 3.6.2000 declares react-native >=0.71.0-rc.0 <1.0.x, and dynamsoft-barcode-reader-bundle-react-native 11.6.2000 requires Capture Vision >= 3.4.3010; both packages ship codegenConfig, so they work with the New Architecture.

Which Overlay Layer Do You Need?

Goal Use Coordinate work
Highlight one barcode First items[0] quad only Image → preview transform
Highlight every code in the frame Map over the full items array Same transform per item
Show decoded text and symbology A View at the quad centroid Transform + centroid
Keep alignment through rotation useWindowDimensions() + layout refs Recompute image basis and offsets
Decode a still image instead CaptureVisionRouter.capture() Image space only; no preview crop

The Recognition Primitives (No UI Included)

Before any overlay code, this is the whole recognition path. The SDK is a data source; nothing here renders a button, a box or a theme.

import {
  CameraEnhancer,
  CameraView,
  CaptureVisionRouter,
  EnumPresetTemplate,
} from 'dynamsoft-capture-vision-react-native';

const cvr = CaptureVisionRouter.getInstance();
const camera = CameraEnhancer.getInstance();

cvr.setInput(camera);
camera.setCameraView(cameraView.current);

const receiver = cvr.addResultReceiver({
  onDecodedBarcodesReceived: ({ items }) => {
    // items is BarcodeResultItem[] — EVERY code decoded in this frame.
    // item.text          -> string
    // item.formatString  -> 'QR_CODE', 'DATAMATRIX', 'EAN_13', ...
    // item.location.points -> 4 corners in IMAGE coordinates
  },
});

camera.open();
cvr.startCapturing(EnumPresetTemplate.PT_READ_BARCODES);
// on unmount: cvr.removeResultReceiver(receiver); cvr.stopCapturing(); camera.close();

Note the RN SDK uses singleton accessors (getInstance()), unlike the instance-based 3.6.x Flutter API. The items array is the multi-code primitive: one frame can contain several codes of different symbologies, and each item supplies its own quadrilateral.

Image Coordinates vs. Preview Coordinates

  Image coordinates Preview coordinates
Source item.location.points from the SDK onLayout width/height, left/top styles
Unit Camera-frame pixels Density-independent pixels (dp)
Portrait basis 1080 × 1920 viewWidth × viewHeight
Landscape basis 1920 × 1080 viewWidth × viewHeight
Origin Top-left of the captured frame Top-left of the CameraView
Aspect-fill crop Not applied Applied; must be compensated

Mapping image coordinates to preview coordinates

The camera stream is scaled with Math.max so it fills the view without distortion; the excess is cropped equally on both axes. A point therefore needs both a scale and a translation before it can be used as left/top.

Step 1: Convert Image Points to Preview Points

const { width: viewWidth, height: viewHeight } = viewRef.current;
const isLandscape = orientationRef.current === 'landscape';
const imageWidth = isLandscape ? 1920 : 1080;
const imageHeight = isLandscape ? 1080 : 1920;

const scale = Math.max(viewWidth / imageWidth, viewHeight / imageHeight);
const cropOffsetX = (imageWidth * scale - viewWidth) / 2;
const cropOffsetY = (imageHeight * scale - viewHeight) / 2;

const overlays: BarcodeOverlay[] = items.map(item => {
  const scaledCorners = item.location.points.map(point => ({
    x: point.x * scale - cropOffsetX,
    y: point.y * scale - cropOffsetY,
  }));

  const centerX = scaledCorners.reduce((sum, p) => sum + p.x, 0) / 4;
  const centerY = scaledCorners.reduce((sum, p) => sum + p.y, 0) / 4;
  const stableId = `${item.text}-${item.formatString}-${Math.round(centerX / 10) * 10}-${Math.round(centerY / 10) * 10}`;

  return {
    id: stableId,
    text: item.text,
    formatString: item.formatString,
    x: centerX,
    y: centerY,
    corners: scaledCorners,
  };
});
setBarcodeOverlays(overlays);

Math.max preserves the aspect ratio and fills the view (the same behaviour as a resizeMode="cover" image). cropOffsetX/cropOffsetY remove the invisible part of the scaled frame. The stable ID keeps React keys unchanged between consecutive frames so contours do not flicker.

interface BarcodeOverlay {
  id: string;
  text: string;
  formatString: string;
  x: number;
  y: number;
  corners: Array<{ x: number, y: number }>;
}

Step 2: Keep the Transform Correct Across Rotations

A result receiver is registered once, so values captured in its closure become stale. useWindowDimensions() re-renders on rotation; store the live orientation and the live CameraView size in refs and read them inside the receiver.

import { useWindowDimensions } from 'react-native';

const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const viewRef = useRef({ width: windowWidth, height: windowHeight });
const orientationRef = useRef<'portrait' | 'landscape'>(
  windowWidth > windowHeight ? 'landscape' : 'portrait',
);

useEffect(() => {
  orientationRef.current = windowWidth > windowHeight ? 'landscape' : 'portrait';
  viewRef.current = { width: windowWidth, height: windowHeight };
}, [windowWidth, windowHeight]);

// Inside the JSX:
<CameraView
  style={StyleSheet.absoluteFill}
  ref={cameraView}
  onLayout={(event) => {
    const { width, height } = event.nativeEvent.layout;
    viewRef.current = { width, height };
  }}
/>

Using a single Dimensions.get('window') call at mount time, or a useState value read inside the receiver, leaves the overlay using the old 1080 × 1920 basis after the phone turns — every box lands in the wrong place. Re-running the transform on orientation change also handles split-screen and foldable layout changes.

Step 3: Draw Contours and Corner Markers

Four rotated View lines form the contour; four small circles mark the corners.

const BarcodeContour: React.FC<{ corners: Array<{ x: number, y: number }> }> = ({ corners }) => {
  if (corners.length !== 4) {
    return null;
  }

  return (
    <>
      {corners.map((corner, index) => {
        const nextCorner = corners[(index + 1) % 4];
        const lineWidth = 3;

        const deltaX = nextCorner.x - corner.x;
        const deltaY = nextCorner.y - corner.y;
        const length = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
        const angle = Math.atan2(deltaY, deltaX) * (180 / Math.PI);

        return (
          <View
            key={index}
            style={[
              styles.contourLine,
              {
                left: corner.x,
                top: corner.y - lineWidth / 2,
                width: length,
                height: lineWidth,
                transform: [{ rotate: `${angle}deg` }],
              },
            ]}
          />
        );
      })}

      {corners.map((corner, index) => (
        <View
          key={`dot-${index}`}
          style={[
            styles.cornerDot,
            {
              left: corner.x - 4,
              top: corner.y - 4,
            },
          ]}
        />
      ))}
    </>
  );
};

Step 4: Add Text Callouts and Expire Stale Overlays

return (
  <SafeAreaView style={StyleSheet.absoluteFill}>
    <CameraView
      style={StyleSheet.absoluteFill}
      ref={cameraView}
      onLayout={(event) => {
        const { width, height } = event.nativeEvent.layout;
        viewRef.current = { width, height };
      }}
    />

    {barcodeOverlays.map((overlay) => (
      <BarcodeContour key={`contour-${overlay.id}`} corners={overlay.corners} />
    ))}

    {barcodeOverlays.map((overlay) => (
      <View
        key={overlay.id}
        style={[
          styles.barcodeOverlay,
          {
            left: overlay.x - 75,
            top: overlay.y - 40,
          },
        ]}
      >
        <Text style={styles.barcodeText}>{overlay.text}</Text>
        <Text style={styles.formatText}>{overlay.formatString}</Text>
      </View>
    ))}
  </SafeAreaView>
);

Live frames repeat the same code many times. Clear the overlay layer after a short quiet period so a box does not linger after the label leaves the frame:

if (clearTimeoutRef.current) {
  clearTimeout(clearTimeoutRef.current);
}
clearTimeoutRef.current = setTimeout(() => setBarcodeOverlays([]), 2000);

On unmount, remove the receiver and release the camera:

return () => {
  sub.remove();
  cvr.stopCapturing();
  camera.close();
  cvr.removeResultReceiver(receiver);
  if (clearTimeoutRef.current) {
    clearTimeout(clearTimeoutRef.current);
  }
};

Step 5: Style the Overlay Layer

const styles = StyleSheet.create({
  barcodeOverlay: {
    position: 'absolute',
    backgroundColor: 'rgba(0, 0, 0, 0.7)',
    borderRadius: 8,
    padding: 8,
    alignItems: 'center',
    minWidth: 150,
    maxWidth: 250,
  },
  barcodeText: {
    color: 'white',
    fontSize: 14,
    fontWeight: 'bold',
    textAlign: 'center',
    marginBottom: 2,
  },
  formatText: {
    color: '#00FF00',
    fontSize: 10,
    textAlign: 'center',
  },
  contourLine: {
    position: 'absolute',
    backgroundColor: '#00FF00',
    transformOrigin: 'left center',
  },
  cornerDot: {
    position: 'absolute',
    width: 8,
    height: 8,
    borderRadius: 4,
    backgroundColor: '#FF0000',
  },
});

Measured Overlay Result

The screenshot below is the working overlay from the sample app: green contours, red corner dots, white decoded text and green symbology labels, all drawn over a live camera frame containing many codes at once.

Working React Native barcode scanner overlay with real-time contours

Visible callouts in that single frame include:

Decoded text (visible callout) Symbology label
BRP.CR0006M.CO.14/12.A.2.DS2 DATAMATRIX
BRP.CR0002M.CO.14/12.A.2.DS2 DATAMATRIX
BRP.CR0001M.CO.14/12.A.2.DS2 DATAMATRIX
4021404036699 EAN_13
021404036699 DATAMATRIX
Z014391851FFF0000212916814 DATAMATRIX

At least seven quadrilaterals are drawn simultaneously across two symbology families, which is the multi-code behaviour the items array enables. The contours track the printed labels rather than sitting at fixed screen positions — that alignment is the result of the transform in Step 1, not of any SDK-provided UI.

Watch the Final Result: Real-Time Barcode Scanner Overlay in Action

Tested Environment

Item Value
React Native / React 0.79.0 / 19.0.0
New Architecture newArchEnabled=true, Hermes enabled
dynamsoft-capture-vision-react-native 3.6.2000 — latest on npm, checked 2026-11-12
dynamsoft-barcode-reader-bundle-react-native 11.6.2000 — latest on npm, checked 2026-11-12
Peer ranges React >= 16.8.1, RN >= 0.71.0-rc.0 < 1.0.x; DBR bundle requires Capture Vision >= 3.4.3010; both packages publish codegenConfig
Android toolchain Gradle 8.10.2, Java 17, compileSdk 35, minSdk 24, targetSdk 34, NDK 27.1.12297006, Kotlin 2.0.21
CI evidence sample CI run 42550773700 — Android and iOS simulator builds passed for react-native-barcode-scanner
Local checks (2026-11-12) npx eslint src/App.tsx clean; npx tsc --noEmit reports two pre-existing type errors inside the SDK package’s own TypeScript sources, none in the app code; ./gradlew assembleDebug --no-daemon → BUILD SUCCESSFUL, debug APK 196,666,060 bytes, built with the domestic Gradle mirror
Not verified Overlay alignment after rotation on a physical device; iOS device run

CI builds prove the project compiles on both platforms; they do not confirm that the contours line up with the printed labels on a real camera feed. The overlay screenshot above is a captured device result, while the rotation fix is verified by static analysis and build, not by a new device run.

Prerequisites

  • React Native development environment set up (React Native CLI setup guide), with Node >= 18 and a physical Android or iOS device for live camera testing.
  • Dynamsoft Barcode Reader license - Get your 30-day free trial license and replace License in src/App.tsx. The online trial key needs network access on first activation.

Start from the sample rather than scaffolding a scanner from scratch:

git clone https://github.com/yushulx/android-camera-barcode-mrz-document-scanner.git
cd android-camera-barcode-mrz-document-scanner/examples/react-native-barcode-scanner
npm install --legacy-peer-deps
npx react-native run-android   # or run-ios

Common Issues & Edge Cases

  • Overlays misaligned on tablets or wide-aspect devices: The crop-offset formula assumes the CameraView fills its parent with aspect-fill. Constrain the view to a known region, pass its exact onLayout dimensions, and recompute offsets whenever that layout changes.
  • Boxes jump to the wrong place after rotating the phone: A once-registered result receiver captures mount-time dimensions. Read orientation and view size from refs updated by useWindowDimensions() and onLayout, and swap the image basis between 1080 × 1920 and 1920 × 1080.
  • Points seem rotated by 90° even before scaling: location.points are camera-frame coordinates, and the SDK reports the rotated basis in portrait. Do not apply an extra rotation on top; use the portrait/landscape image dimensions shown in Step 1. If your device reports a different sensor resolution, read it from the camera configuration rather than assuming 1920 × 1080.
  • No overlays appear on iOS: Grant camera permission in Info.plist (NSCameraUsageDescription) and initialize the license before startCapturing(). A missing, expired or unreachable online trial license silently prevents result callbacks.
  • Overlays flicker or lag on low-end Android devices: Debounce setBarcodeOverlays, keep stable IDs so React does not remount contour views each frame, and consider limiting tracked results in SimplifiedBarcodeReaderSettings.
  • Contours linger after the label leaves the frame: The 2-second clear timer only fires when no new results arrive. Reset it on every callback and clear it on unmount.
  • Windows build fails with Filename longer than 260 characters in ComponentDescriptors.cpp: React Native New Architecture codegen creates deeply nested paths under node_modules. Build the project from a short root path (for example D:/rn/) or enable Windows long paths; the error is a filesystem limit, not an SDK or overlay problem.
  • Camera stays black on Android: Ensure the runtime camera permission is granted and that camera.open() and cvr.startCapturing() both run; wrap the latter in a .catch() and surface the error instead of leaving a blank preview.

Conclusion

A good overlay is a coordinate problem, not a drawing problem. The Dynamsoft React Native SDK stays headless and hands you items with four image-space corners per barcode; your app scales those corners into the CameraView measured by onLayout, compensates for aspect-fill cropping, keeps refs current across rotations, and draws contours and callouts with plain View components. Because the recognition layer and the UI layer are separate, the same transform works for a single QR code, a shelf full of DataMatrix labels, or a mixed 1D/2D frame — and the whole look and feel stays under developer control.

Source Code

Get the complete sample project source code on GitHub