Universal Barcode Scanner
Barcode and QR code scanning for Flutter, on Android, iOS, Linux, macOS, web and Windows, from one entry point.
A barcode on Android, the camera inside the app's own layout, and a QR code in a browser.
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:
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.