Universal Barcode Scanner

Barcode and QR code scanning for Flutter, on Android, iOS, Linux, macOS, web and Windows, from one entry point.

Scanning a barcode on Android    The camera opening inside a tile of the example app, on Android

Scanning a QR code in a browser

A barcode on Android, the camera inside the app's own layout, and a QR code in a browser.

Video tour Live demo Pub Version Build Maintainer Licence Platforms Donate with PayPal

Platforms

Platform How it scans Embedded view
Android Native, CameraX and ML Kit Native view
iOS Native, AVFoundation Native view
macOS Native, AVFoundation and Vision Native view
Web zxing-cpp in WebAssembly, in a worker, bundled, no CDN call The same page in an iframe
Windows zxing-cpp in WebAssembly, in a WebView2 The same page in a WebView2
Linux zxing-cpp in WebAssembly, in a WebKitGTK view The same page in a WebKitGTK view

Windows needs the WebView2 runtime, which ships with Windows 11 and with any recent Edge. Linux needs WebKitGTK, libwebkit2gtk-4.1-0 on Debian and Ubuntu, which most desktop installs already carry.

The same bundled page serves all three. Where the browser has a barcode detector of its own, Chrome on Android and macOS among them, the page uses it; elsewhere it decodes with zxing-cpp compiled to WebAssembly, in a worker where the page can start one, so on the web the decoding stays off the app's thread. On the desktop the plugin answers the page's camera permission request on the host side. That last part is what usually stops a webview from scanning on Linux: WebKitGTK denies a media request the embedder does not handle.

Install

flutter pub add universal_barcode_scanner

Requires Flutter 3.35 or later.

Android

Camera permission goes in your own manifest, android/app/src/main/AndroidManifest.xml:

<uses-permission android:name="android.permission.CAMERA" />

The plugin asks for minSdkVersion 21 and builds against compileSdk 36, the level two of its dependencies, flutter_plugin_android_lifecycle and webview_all_android, already require. An app has to compile against the highest of them:

android {
    compileSdk = 36
}

iOS

A usage description goes in ios/Runner/Info.plist. iOS kills the app without it:

<key>NSCameraUsageDescription</key>
<string>Camera permission is required for barcode scanning.</string>

Windows

Nothing to declare, but if you build with Visual Studio 2026 or later the build fails on error STL1011 from <experimental/coroutine>. That comes from the Windows Implementation Library pinned by the webview dependency, not from this plugin, and both Windows webviews on pub.flutter-io.cn pin the same 2022 release. Until it is bumped upstream, add this to your app's windows/CMakeLists.txt, above add_subdirectory(${FLUTTER_MANAGED_DIR}):

add_compile_definitions(_SILENCE_EXPERIMENTAL_COROUTINE_DEPRECATION_WARNINGS)

macOS

Two things, and the scanner fails silently without either. A usage description in macos/Runner/Info.plist:

<key>NSCameraUsageDescription</key>
<string>Camera permission is required for barcode scanning.</string>

And the camera entitlement in both macos/Runner/DebugProfile.entitlements and macos/Runner/Release.entitlements, since a macOS app is sandboxed:

<key>com.apple.security.device.camera</key>
<true/>

Requires macOS 10.15 or later.

Web

Browsers only hand out the camera on a secure origin, so the page must be served over HTTPS. localhost counts as secure during development.

Scan one code

Opens the scanner as a route and comes back with what it read, or null if the user backed out:

final String? code = await UniversalBarcodeScanner.scan(context);

scanResult does the same and says which symbology the code was printed in:

final ScanResult? result = await UniversalBarcodeScanner.scanResult(context);
if (result?.format == BarcodeFormat.ean13) {
  // A product.
}
Parameter Default Effect
lineColor Color(0xFFFF6666) Colour of the scan line, on every platform.
scanFormat ScanFormat.all Symbologies to accept, on every platform. Fewer formats also scan faster.
scanWindow ScanWindow.wide Shape of the scan window: wide for barcodes, square for QR codes, or none to read the whole frame with nothing drawn over it.
scanWindowSize null Size of the scan window in logical pixels, or one picked from scanWindow. On every platform.
cameraFace CameraFace.back Which camera to open.
showTorchButton false Whether the native torch button is shown, when the camera has a torch. Android and iOS.
bar null A ScannerBar above the scanner. Without one, a close button sits over the camera. Web, Windows and Linux, except its cancelLabel, which labels the native cancel button.
child null Drawn over the scanner, for instance a manual entry field. Web, Windows and Linux.
backgroundColor black Colour around the camera. Web, Windows and Linux.
flip null Mirrors the camera left to right. null mirrors it where it is a webcam facing the user, on a desktop and in a desktop browser, and not on a phone or a tablet. Web, Windows and Linux.
flipVertical false Shows the camera upside down. Web, Windows and Linux.
buttons none Buttons over the camera, from ScannerButton: torch, pause, flipHorizontal, flipVertical, zoom, switchCamera. Web, Windows and Linux; torch also shows the native torch button on Android and iOS.
labels ScannerLabels.english The words of the buttons, for screen readers, and of the page when the camera will not start. ScannerLabels.french, .dutch and .german are ready to use, and copyWith changes any of them. Web, Windows and Linux.
animate true Fades the camera in when it starts and turns it over when flipped. Off when the platform asks for reduced motion. Web, Windows and Linux.
buttonsAlignment Alignment.centerRight Where the buttons sit. Centred on the left or the right, they run down the side; elsewhere, across.
buttonStyle round and dark A ScannerButtonStyle: the size, spacing, colours off and on, focus ring, corners and tooltips of the buttons and of the close button. Web, Windows and Linux.
vibrate false A short vibration for each code read, where the device has one. On every platform.
beep false A short beep for each code read: a tone on Android, iOS, Windows and the web, the system's alert sound on macOS and Linux. A code the validator refuses gets two lower ones, the system's error sound on macOS; Linux has the alert sound for both. On every platform.
validator null Decides which codes count; see Accept only some codes. On every platform.

The buttons work with the mouse and the keyboard as well as by touch: the hand shows over them, a tooltip says what each one does, Tab moves between them with a ring around the one reached, and Enter or Space presses it.

UniversalBarcodeScanner.scan(
  context,
  buttons: const <ScannerButton>{ScannerButton.torch, ScannerButton.zoom},
  buttonStyle: const ScannerButtonStyle(
    size: 52,
    backgroundColor: Color(0xCCFFFFFF),
    foregroundColor: Color(0xFF0E1014),
    activeBackgroundColor: Color(0xFF2E9E6A),
    borderRadius: BorderRadius.all(Radius.circular(10)),
  ),
);

ScannerButtonStyle takes:

Parameter Default Effect
size 44 Width and height of a button, in logical pixels. 44 is the least a finger reliably hits.
iconSize 20 Size of the icon inside it.
spacing 8 Space between two buttons of the group.
backgroundColor translucent black Fill of a button whose setting is off.
foregroundColor white Icon of a button whose setting is off.
activeBackgroundColor near-opaque white Fill of a button whose setting is on: the torch lit, reading paused, a flip applied, a zoom.
activeForegroundColor black Icon of a button whose setting is on.
focusColor white Ring drawn around the button the keyboard is on, a little outside it.
borderRadius null Corners of a button, or a circle when null.
showTooltips true Whether a button says what it does when the mouse rests on it or the keyboard reaches it.

copyWith changes some of them and keeps the rest.

Android, iOS and macOS open a native screen over the route, so the parameters that shape the Flutter page do nothing there. Escape closes the scanner on macOS, the web, Windows and Linux, and popping the route from your own code closes it on every platform.

When the camera cannot be used

On Android, iOS and macOS, scan throws a ScannerException and closes the route itself:

try {
  final String? code = await UniversalBarcodeScanner.scan(context);
} on ScannerException catch (error) {
  switch (error.code) {
    case ScannerErrorCode.permissionDenied: // the user refused the camera
    case ScannerErrorCode.cameraUnavailable: // no camera, or it did not start
    case ScannerErrorCode.alreadyActive: // another scanner is on screen
    case ScannerErrorCode.unknown:
  }
}

The scanner shows no dialog of its own: what to tell the user is the app's call. On the web, Windows and Linux the scanner page says why the camera did not start, and the user backs out.

Keep scanning

Same route, but every code read is emitted instead of the first one closing it. The stream closes by itself when the route goes away, whichever way it goes, and cancelling the subscription closes the route:

final StreamSubscription<String> sub =
    UniversalBarcodeScanner.stream(context).listen((String code) {
  debugPrint(code);
});

A code held in front of the camera is emitted once, and again only after it has been out of sight for a second. scanDelay adds a least time between any two codes. A camera that cannot be used is emitted as a ScannerException, then the stream closes.

stream takes the same parameters as scan, plus scanDelay. resultStream emits a ScanResult for each code, with its symbology.

Embed the camera

To put the camera inside your own layout rather than on its own route, on every platform:

The camera opening inside a tile of the example app, on Android

UniversalBarcodeScanner(
  continuous: true,
  onScanned: (String code) => debugPrint(code),
  onError: (ScannerException error) => debugPrint('$error'),
  onCreated: (ScannerController controller) {
    this.controller = controller;
  },
);

The view asks for the camera permission itself and only reads codes that sit entirely inside its scan window. The camera fills the view, cropped rather than letterboxed, and the scan window is drawn the same way everywhere. On Android and iOS the camera also stops while the app is in the background.

The controller drives the running camera:

final bool torchOn = await controller.toggleFlash();
await controller.pauseScanning();
await controller.resumeScanning();
final double zoom = await controller.setZoom(2);

isPaused, isTorchOn and zoom on the controller say how the view stands, whoever changed it: the app, the buttons over the camera, or the view pausing itself after a code. The buttons show the same state.

A paused view keeps its camera running and its scan line stops where it is, until resumeScanning. A view that is not continuous pauses the same way on its first code.

toggleFlash answers false where there is no torch to drive: a Mac camera, and most webcams, which browsers and webviews give a page no control over. setZoom answers with the zoom the camera applied, 1 where it cannot zoom, which is the case of the same cameras.

Parameter Default Effect
onCreated required Called once the view exists, with its ScannerController.
onScanned null Called with every code read.
onResult null Called with every code read, as a ScanResult with its symbology.
onError null Called when the camera cannot be used. On the web, Windows and Linux the view also says why.
continuous false When false, the view pauses on the first code accepted until resumeScanning.
scanWindowSize null Size of the scan window in logical pixels, or one picked from scanWindow.
lineColor, scanWindow, cameraFace, scanFormat, scanDelay, flip, flipVertical, buttons, buttonsAlignment, buttonStyle, animate, labels, vibrate, beep, validator, child see above As in scan and stream, on every platform: the view is drawn by Flutter everywhere, so its buttons are too. On Android, iOS and macOS the native view says when its first frame is on screen, and the camera fades in then.

The view fills the constraints it is given. What to scan is read once, when the view is created: give the widget a new key to change it. The flip, continuous and validator are the exceptions: they follow the widget, and the camera keeps running. The callbacks are always the current widget's, and none is called once the widget is gone.

Read the frames yourself

onFrame hands the app the camera's frames, for it to read what is not a code: the machine readable zone of a passport or an identity card, say, with the MRZ scanner of eid_icao. Each ScanFrame holds the luminance under the scan window, one byte per pixel, and the quarter turns that stand it upright. Frames come at most every frameInterval, 200 ms by default, and none while the view is paused. With ScanFormat.none the view reads no code at all.

UniversalBarcodeScanner(
  scanFormat: ScanFormat.none,
  continuous: true,
  onFrame: (ScanFrame frame) => reader.add(frame),
  onCreated: (ScannerController controller) {},
);

Android, iOS and macOS send the camera's own frames, at most 1280 pixels on their longest side; the web, Windows and Linux send the page's, at the same size. A view without onFrame asks for nothing more.

Accept only some codes

validator decides which codes count, on scan, stream and the embedded view. A code it refuses does not close the scanner and is not handed on: the scanner says ScannerLabels.rejected, "Code not accepted" in English, and reads on until a code is accepted.

final ScanResult? product = await UniversalBarcodeScanner.scanResult(
  context,
  validator: (ScanResult code) => code.format == BarcodeFormat.ean13,
);

final ScanResult? link = await UniversalBarcodeScanner.scanResult(
  context,
  validator: (ScanResult code) => code.content is UrlContent,
);

A code held in front of the camera is refused once, not on every frame. The web, Windows and Linux pages and the embedded view show the words over the camera, and flash its edge red twice; the native screens of Android, iOS and macOS show them themselves, in a toast on Android and on the system's dark material on iOS and macOS. A screen reader reads them out, vibrate adds two short pulses, with the two flashes, and beep a lower sound than for a code accepted. An embedded view that is not continuous pauses on the first code accepted, as it does without a validator.

With or without a validator, the web, Windows and Linux pages and the embedded view flash the edge of the camera green once for each code accepted.

Read an image

scanImage reads every code in a picture, with no camera and no permission. It takes the image as bytes, PNG, JPEG or any format the platform opens, so they can come from anywhere: a photo the user picks, a file, an asset, a download.

final List<ScanResult> codes = await UniversalBarcodeScanner.scanImage(
  await file.readAsBytes(),
  scanFormat: ScanFormat.onlyQrCode, // every symbology by default
);

The list is empty when the picture holds no code, and a ScannerException with ScannerErrorCode.invalidImage means the bytes are no image. ML Kit reads it on Android and Vision on iOS and macOS, turning a photo the way its EXIF says. The web, Windows and Linux read it with the scanner page, the same decoder as the camera, off the app's thread where it can.

The package picks no file itself, so it adds no dependency: the example lets the user pick one with file_picker, and ships a picture per kind of content to try without one.

What a code holds

A QR code only holds text, but that text often follows a format phones know. ScanResult.content reads it, the same way on every platform, and is null for any other text, such as the number of a product:

switch (result.content) {
  case WifiContent wifi:
    join(wifi.ssid, wifi.password);
  case UrlContent link:
    open(link.url);
  case ContactContent contact:
    save(contact.name, contact.phones);
  default:
    show(result.text);
}
Class Read from Gives
UrlContent http://, https://, MEBKM: url, title
WifiContent WIFI: ssid, security, password, hidden
ContactContent vCard 2.1, 3.0 and 4.0, MECARD: name, organization, title, phones, emails, urls, address, note
EmailContent mailto:, MATMSG:, SMTP: address, subject, body
PhoneContent tel: number
SmsContent SMSTO:, sms:, MMSTO:, mms: number, message
GeoContent geo: latitude, longitude, altitude, query
EventContent iCalendar VEVENT summary, start, end, allDay, location, description

Scanner bar

Passing a ScannerBar puts a bar above the web, Windows and Linux scanner. Its back button is on unless you turn it off, since on a desktop it is the way out:

UniversalBarcodeScanner.scan(
  context,
  bar: const ScannerBar(
    title: 'Scan',
    backIcon: Icon(Icons.arrow_back_ios),
  ),
);
Parameter Default Effect
title null Text of the bar, or none.
centerTitle false Centres the title rather than starting it after the back button.
showBackButton true Shows the back button, which closes the scanner.
backIcon a chevron Icon of the back button. An Icon takes foregroundColor.
backgroundColor black Colour of the bar.
foregroundColor white Colour of the title and of the back icon, and of the ring around the back button when the keyboard is on it.
cancelLabel 'Cancel' The text of the cancel button of the native screens of Android, iOS and macOS, and what a screen reader says for the back button of the bar.

On Android, iOS and macOS the native screen draws its own controls and has no bar: only cancelLabel reaches it.

Migrating from 1.x

The breaking changes of 2.0 and what to write instead are listed at the top of the changelog.

Example

example/ is one screen with the three ways to scan, and what each one returns.

cd example && flutter run

Tests

flutter test

The camera page the web, Windows and Linux scanners run has its own tests, in a browser with a camera drawn on a canvas. They need Node:

cd tool/page_test
npm ci
npx playwright install chromium
npx playwright test

The example has an integration test for scanImage, run on a device against the platform's own decoder:

cd example
flutter test integration_test -d windows

Dependencies

webview_all for the Windows and Linux scanners, and web for the iframe on the web. Android carries CameraX and the ML Kit barcode model, which is bundled, so the scanner works on a device with no Play services and downloads nothing on first use. The Android code is Kotlin: under AGP 9 it uses the built-in Kotlin support, and under earlier versions the Kotlin plugin every Flutter app already has. Nothing on iOS or macOS beyond the SDKs.

No design system. The package is written against package:flutter/widgets.dart alone, so it sits under Material, under material_ui, or under neither, and imposes none of them on your app. The scanner bar is drawn here rather than taken from a widget library; ScannerBar carries its colours.

License

MIT, see LICENSE.

The web, Windows and Linux scanner bundles barcode-detector and zxing-wasm, both MIT, and zxing-cpp, which zxing-wasm compiles and which is Apache 2.0. The licences travel with the files, in lib/assets/THIRD_PARTY_LICENSES.txt.

More from COMAPPS

Every package COMAPPS publishes is listed at packages.comapps.be.

Libraries

universal_barcode_scanner
Barcode and QR code scanner for Flutter, on Android, iOS, Linux, macOS, web and Windows.