DEV Community

Xiao Ling
Xiao Ling

Posted on Originally published at dynamsoft.com

How to Build an Expo Barcode Scanner for Android and iOS

Scanning barcodes in an Expo app usually means installing a React Native plugin that wraps some native decoder. This sample takes a different route: the native bridge is written by hand inside the app as a local Expo module, so the app talks directly to the Dynamsoft Capture Vision native SDK on Android and iOS — no third-party scanning plugin in between.

What you'll build: An Expo SDK 57 app (expo-barcode-scanner) with a full-screen native camera scanner that decodes QR codes and other 1D/2D barcodes with the Dynamsoft Capture Vision template ReadBarcodes_Default, draws live overlays around every detected barcode, and returns a results list containing the decoded text, the barcode format, and the four corner points. A system photo-picker path decodes barcodes from existing images, and a scanFile method decodes from a local file path.

Demo Video: Expo Barcode Scanner in Action

Prerequisites

Before you start, make sure you have the following:

  • Node.js (LTS) and npm for the Expo toolchain
  • Android Studio (latest) with JDK 17 and a connected Android device or emulator
  • Xcode (latest, macOS only) with a connected iPhone and a signing team configured in Xcode → Settings → Accounts
  • A Dynamsoft license key — the sample embeds a time-limited trial key that requires a network connection on first use

Get a 30-day free trial license at dynamsoft.com/customer/license/trialLicense

How the Sample Project Is Organized

The complete app lives in the examples/expo-barcode-scanner folder of the sample repository:

expo-barcode-scanner/
├── app.json                          # App config: permissions, bundle IDs
├── App.tsx                           # Home + results list (React Native UI)
├── assets/                           # App icons
└── modules/
    └── expo-dynamsoft-barcode-scanner/  # Local Expo module (the native bridge)
        ├── expo-module.config.json   # Registers the module for apple + android
        ├── src/ExpoDynamsoftBarcodeScannerModule.ts   # TypeScript surface
        ├── android/                  # Kotlin module + ScannerActivity
        │   ├── build.gradle          # Dynamsoft capturevisionbundle dependency
        │   └── src/main/AndroidManifest.xml
        └── ios/                      # Swift module + BarcodeCameraScanViewController
            ├── ExpoDynamsoftBarcodeScanner.podspec
            ├── ExpoDynamsoftBarcodeScannerModule.swift
            └── SharedImagePicker.swift
Enter fullscreen mode Exit fullscreen mode

Step 1: Scaffold the Expo App and the Local Module

Create the project with the blank TypeScript template and scaffold the module:

npx create-expo-app@latest expo-barcode-scanner --template blank-typescript
cd expo-barcode-scanner
npx create-expo-module@latest --local modules/expo-dynamsoft-barcode-scanner
Enter fullscreen mode Exit fullscreen mode

If the scaffolder creates the module under modules/modules/, move it up one level and delete the empty wrapper. Restrict expo-module.config.json to the native platforms and align the module class names:

{
  "platforms": ["apple", "android"],
  "apple": {
    "modules": ["ExpoDynamsoftBarcodeScannerModule"]
  },
  "android": {
    "modules": ["expo.modules.dynamsoftbarcodescanner.ExpoDynamsoftBarcodeScannerModule"]
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Add Camera and Photo Library Permissions

Declare the usage descriptions in app.json; the Android module manifest adds the CAMERA and photo-library permissions that are merged into the app manifest:

"ios": {
  "bundleIdentifier": "com.dynamsoft.expo.barcodescanner",
  "infoPlist": {
    "NSCameraUsageDescription": "Camera is used to scan barcodes.",
    "NSPhotoLibraryUsageDescription": "The app lets you pick a barcode image from your photo library to scan it."
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Define the TypeScript API and the Result List UI

The module surface declares the bridge methods and the result type. The location of every decoded barcode is delivered as four corner points:

export type BarcodeResult = {
  text: string;                      // decoded payload
  formatString: string;              // e.g. QR_CODE, CODE_128
  points: Array<{ x: number; y: number }>; // 4 corners in frame coordinates
};

export type ScanResult = { results: BarcodeResult[] };

declare class ExpoDynamsoftBarcodeScannerModule extends NativeModule<{}> {
  initLicense(license?: string): Promise<InitLicenseResult>;
  startScan(): Promise<ScanResult>;        // full-screen camera scanner
  scanFromGallery(): Promise<ScanResult>;  // system photo picker
  scanFile(options: ScanFileOptions): Promise<ScanResult>;
}
Enter fullscreen mode Exit fullscreen mode

The home screen in App.tsx offers a Camera / Image File toggle, runs the same license-and-scan flow for both sources, and renders each decoded barcode as a card with its format, text, and corners:

const scan = async () => {
  setBusy(true);
  setError(null);
  setResults([]);
  try {
    const license = await ExpoDynamsoftBarcodeScanner.initLicense();
    if (!license.success) {
      setError(`License failed: ${license.message}`);
      return;
    }
    const outcome = mode === 'camera'
      ? await ExpoDynamsoftBarcodeScanner.startScan()
      : await ExpoDynamsoftBarcodeScanner.scanFromGallery();
    setResults(outcome.results);
  } catch (e: any) {
    const message: string = e?.message ?? String(e);
    if (!/cancel/i.test(message)) {
      setError(message);   // user cancel: keep the home screen clean
    }
  } finally {
    setBusy(false);
  }
};

{results.map((r, i) => (
  <View key={`${r.text}-${i}`} style={styles.resultCard}>
    <Text style={styles.resultFormat}>{r.formatString}</Text>
    <Text style={styles.resultText}>{r.text}</Text>
    {r.points.length === 4 ? (
      <Text style={styles.resultPoints}>
        corners: {r.points.map((p) => `(${Math.round(p.x)}, ${Math.round(p.y)})`).join(' ')}
      </Text>
    ) : null}
  </View>
))}
Enter fullscreen mode Exit fullscreen mode

Step 4: Implement the Android Native Module

The Android module adds the self-contained Dynamsoft Capture Vision bundle and appcompat (the scanner activity needs a LifecycleOwner for the camera):

dependencies {
  implementation 'androidx.appcompat:appcompat:1.7.0'
  implementation 'com.dynamsoft:capturevisionbundle:3.6.2000'
}
Enter fullscreen mode Exit fullscreen mode

ScannerEngine.kt owns a single CaptureVisionRouter shared by the camera and file sources. The barcode preset template is used for both:

fun template(): String = EnumPresetTemplate.PT_READ_BARCODES

fun decodeBitmap(bitmap: Bitmap?): DecodedBarcodesResult {
  if (bitmap == null) throw ScannerException("Source bitmap is null")
  return unwrap(router.capture(bitmap, template()))
}
Enter fullscreen mode Exit fullscreen mode

ScannerActivity.kt is the full-screen scanner. A CapturedResultReceiver receives every decoded frame, enables the Confirm button as soon as at least one barcode is found, and draws the result quads on the DBR drawing layer of the camera view:

// inside onDecodedBarcodesReceived -> refreshUi()
val count = latestItems.size
if (count == 0) {
  statusView.text = getString(R.string.status_scanning)
  captureButton.isEnabled = false
  clearOverlay()
  return
}
statusView.text = resources.getQuantityString(R.plurals.barcodes_found, count, count)
captureButton.isEnabled = true
drawOverlay()
Enter fullscreen mode Exit fullscreen mode
private fun drawOverlay() {
  val layer = cameraView.getDrawingLayer(DrawingLayer.DBR_LAYER_ID) ?: return
  val items = ArrayList<DrawingItem<*>>()
  for (item in latestItems) {
    item.location?.let { items.add(QuadDrawingItem(it)) }
  }
  layer.setDrawingItems(items)
}
Enter fullscreen mode Exit fullscreen mode

Confirming serializes the items (text, formatString, points) to a JSON array and returns it as the activity result. As in the MRZ sample, BridgeResultActivity hosts the startActivityForResult call on behalf of the module and resolves the parked promise with the parsed JSON; a cancel produces a canceled rejection.

Step 5: Implement the iOS Native Module

The podspec depends on the self-contained Dynamsoft framework (never combine it with separate core or license pods — duplicate Objective-C classes break the camera preview):

s.dependency 'ExpoModulesCore'
s.dependency 'DynamsoftCaptureVisionBundle', '3.6.2000'
Enter fullscreen mode Exit fullscreen mode

ExpoDynamsoftBarcodeScannerModule.swift presents BarcodeCameraScanViewController on the main queue. The controller binds a CameraView + CameraEnhancer to a CaptureVisionRouter, loads the barcode templates shipped inside the framework, and applies the decoded items to a custom green drawing layer in its CapturedResultReceiver:

let bundleCandidates = Bundle.allFrameworks + Bundle.allBundles
if let templatePath = bundleCandidates.lazy
  .compactMap({ $0.path(forResource: "dbr-bundle-mobile-templates", ofType: "json") })
  .first {
  try? cvr.initSettingsFromFile(templatePath)
}
Enter fullscreen mode Exit fullscreen mode

For the file data source the module runs a background decode with the same template and converts each item's location points (wrapped as NSValue) into plain {x, y} dictionaries:

let result = router.captureFromFile(url.path, templateName: Constants.templateName)
guard let items = result.decodedBarcodesResult?.items else { /* reject "No barcodes found" */ }
for item in items {
  var points: [[String: Any]] = []
  for value in item.location.points {
    let p = value.cgPointValue
    points.append(["x": p.x, "y": p.y])
  }
  array.append(["text": item.text ?? "", "formatString": item.formatString ?? "", "points": points])
}
promise.resolve(["results": array])
Enter fullscreen mode Exit fullscreen mode

Step 6: Build and Run the App

npm install

# Android — builds and installs the debug app on the connected device/emulator
npx expo run:android

# iOS — builds and installs on a connected iPhone (Xcode signing required)
npx expo run:ios --device
Enter fullscreen mode Exit fullscreen mode

Self-contained release builds are produced with:

cd android && ./gradlew :app:assembleRelease   # APK in app/build/outputs/apk/release/
# or: npx expo run:ios --configuration Release --device
Enter fullscreen mode Exit fullscreen mode

Aim the camera at any barcode — the preview highlights every decoded barcode and the status bar shows the running count. Tap Confirm to open the results list with format, text, and corner coordinates:

Expo barcode scanner result list

Source Code

Get the complete sample project source code on GitHub

Top comments (0)