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

Key Takeaways

  • You can call Dynamsoft’s native mobile SDKs from Expo without a third-party plugin: implement a local Expo module (Kotlin on Android, Swift on iOS) under the app’s modules/ folder and expose it to TypeScript with requireNativeModule.
  • The ReadBarcodes_Default template reads all supported 1D and 2D formats from live camera frames with no per-format configuration, and each decoded item comes with text, formatString, and a four-point location for drawing overlays.
  • Because activity results cannot be observed from an Expo module on Android, the module launches an invisible bridge activity that hosts startActivityForResult and settles the promise — a pattern that keeps the bridge promise-based and cancel-aware.
  • Camera, photo-picker, and file scanning share one native engine and one result type, so the React Native UI stays identical regardless of the image source.

Common Developer Questions

How do I decode barcodes in an Expo app with the Dynamsoft native SDK?

Add a local Expo module that wraps the SDK and call three methods from JavaScript: initLicense(), then startScan() for the live camera scanner or scanFromGallery() / scanFile() for still images. The sample module is named ExpoDynamsoftBarcodeScanner and is implemented in ExpoDynamsoftBarcodeScannerModule.kt and .swift.

What formats does the scanner support?

The Dynamsoft Capture Vision ReadBarcodes_Default template covers 1D formats (Code 39, Code 128, Code 93, Codabar, EAN-13, EAN-8, ITF, UPC-A, UPC-E, and more) and 2D formats (QR Code, Data Matrix, PDF417, Aztec, and Micro QR), all in one scan session with no template tuning.

What does each scan result contain?

Each item in the resolved results array contains text (the decoded payload), formatString (for example QR_CODE or CODE_128), and points — four {x, y} corner locations of the barcode in the scanned frame. The React Native UI renders them as a list, and the native scanner draws matching overlays on the live preview.

Can the same native code handle a photo-picker image?

Yes. scanFromGallery() opens the system photo picker and decodes the selected image on the native side with the same engine and template; scanFile({ uri }) does the same for a path or content URI the app already holds. Both resolve with the identical ScanResult shape.

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

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

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"]
  }
}

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."
  }
}

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>;
}

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>
))}

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'
}

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()))
}

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()
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)
}

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'

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)
}

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])

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

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

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

Common Issues & Edge Cases

  • Black camera preview on iOS when additional Dynamsoft pods are installed: DynamsoftCaptureVisionBundle already embeds the core and license modules. Adding DynamsoftCore or DynamsoftLicense separately registers duplicate Objective-C classes at launch — remove them and re-run pod install.
  • The app cannot run in Expo Go: the module registers custom activities and view controllers, so use npx expo run:android / npx expo run:ios or a release build.
  • “No barcodes were found in the image” on file scans: the image needs a complete, in-focus barcode. Damaged labels or extreme skew can fail; retry with a sharper capture. On the live camera, the Confirm button only enables after the first decode.
  • Cancel behavior: pressing back or Cancel rejects the promise with canceled (RESULT_CANCELED on Android). The sample filters that message with /cancel/i instead of surfacing an error.
  • Trial license expires or the device is offline: the embedded key is time-limited and validates against Dynamsoft’s license server. Replace it in License.kt (Android) and in Constants.licenseKey of the Swift module before shipping.
  • Module name mismatches: requireNativeModule('ExpoDynamsoftBarcodeScanner') must match the class names and expo-module.config.json entries (ExpoDynamsoftBarcodeScannerModule on both platforms); otherwise autolinking registers nothing and the import throws at startup.

Source Code

Get the complete sample project source code on GitHub

The native bridge sources live in the modules/expo-dynamsoft-barcode-scanner folder of that project. For more on the underlying SDK, see the Dynamsoft Capture Vision documentation.