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 withrequireNativeModule. - The
ReadBarcodes_Defaulttemplate 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-pointlocationfor drawing overlays. - Because activity results cannot be observed from an Expo module on Android, the module launches an invisible bridge activity that hosts
startActivityForResultand 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:
Common Issues & Edge Cases
- Black camera preview on iOS when additional Dynamsoft pods are installed:
DynamsoftCaptureVisionBundlealready embeds the core and license modules. AddingDynamsoftCoreorDynamsoftLicenseseparately registers duplicate Objective-C classes at launch — remove them and re-runpod 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:iosor 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_CANCELEDon Android). The sample filters that message with/cancel/iinstead 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 inConstants.licenseKeyof the Swift module before shipping. - Module name mismatches:
requireNativeModule('ExpoDynamsoftBarcodeScanner')must match the class names andexpo-module.config.jsonentries (ExpoDynamsoftBarcodeScannerModuleon 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.