Building a Cross-Platform Barcode Scanner for Mobile, Desktop, and Web with Flutter
When discussing cross-platform development with Flutter, many developers immediately think of mobile app development. As a result, most Flutter plugins only support Android and iOS. However, Flutter is capable of much more than just mobile development-it also supports desktop and web platforms. Imagine building a cross-platform barcode scanner app from a single codebase that runs on web, Windows, Linux, macOS, iOS and Android. You’ve probably never seen anything like it. In this article, we’ll help you achieve this goal using Flutter and Dynamsoft Barcode Reader SDK.
Cross-Platform Barcode Scanner for Web, Windows, macOS, Linux, iOS, and Android
Prerequisites
- Flutter SDK
- flutter_barcode_sdk: A Flutter barcode plugin that wraps the Dynamsoft Barcode Reader SDK. This plugin supports Windows, Linux, macOS, iOS, Android, and Web platforms. You’ll also need to apply for a trial license to use the SDK.
- camera: The official Flutter plugin for accessing the camera on Android, iOS and web platforms.
- flutter_lite_camera:A lightweight camera plugin for Flutter, designed to capture camera frames on Windows, Linux, and macOS platforms.
These two Flutter camera plugins provide camera access across different platforms, while the Flutter Barcode SDK offers unified APIs for barcode detection on all supported platforms.
The Essential Steps to Build a Cross-Platform Barcode Scanner with Flutter
In the following paragraphs, we will not cover all the details of building a Flutter project. Instead, we will focus on the key logic. To explore the sample, please download the source code.
How to Construct a Camera Preview for Six Platforms
The camera
plugin provides a CameraPreview
widget to display camera frames and a CameraController
class to manage the camera. The following code snippet shows how to create a camera preview for Android, iOS, and web platforms:
class _CameraAppState extends State<CameraApp> {
late CameraController controller;
@override
void initState() {
super.initState();
controller = CameraController(_cameras[0], ResolutionPreset.max);
controller.initialize().then((_) {
if (!mounted) {
return;
}
setState(() {});
}).catchError((Object e) {
});
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!controller.value.isInitialized) {
return Container();
}
return MaterialApp(
home: CameraPreview(controller),
);
}
}
The flutter_lite_camera
plugin, on the other hand, only provides a simple captureFrame()
method. Constructing a camera preview with this method is more complex compared to the camera
plugin.
- Use
Future.delayed
to continuously trigger the frame capturing.
Future<void> _captureFrames() async {
if (!_isCameraOpened || !_shouldCapture || !cbIsMounted()) return;
try {
Map<String, dynamic> frame =
await _flutterLiteCameraPlugin.captureFrame();
if (frame.containsKey('data')) {
Uint8List rgbBuffer = frame['data'];
await _convertBufferToImage(rgbBuffer, frame['width'], frame['height']);
}
} catch (e) {
}
if (_shouldCapture) {
Future.delayed(const Duration(milliseconds: 30), _captureFrames);
}
}
2. Convert the RGB buffer to a ui.Image
object. This step can be computationally expensive.
ui.Image? _latestFrame;
Future<void> _convertBufferToImage(
Uint8List rgbBuffer, int width, int height) async {
final pixels = Uint8List(width * height * 4);
for (int i = 0; i < width * height; i++) {
int r = rgbBuffer[i * 3];
int g = rgbBuffer[i * 3 + 1];
int b = rgbBuffer[i * 3 + 2];
pixels[i * 4] = b;
pixels[i * 4 + 1] = g;
pixels[i * 4 + 2] = r;
pixels[i * 4 + 3] = 255;
}
final completer = Completer<ui.Image>();
ui.decodeImageFromPixels(
pixels,
width,
height,
ui.PixelFormat.rgba8888,
completer.complete,
);
final image = await completer.future;
_latestFrame = image;
}
3. Display the image in a CustomPaint
widget. This acts as a camera preview.
Widget _buildCameraStream() {
if (_latestFrame == null) {
return Image.asset(
'images/default.png',
);
} else {
return CustomPaint(
painter: FramePainter(_latestFrame!),
child: SizedBox(
width: _width.toDouble(),
height: _height.toDouble(),
),
);
}
}
We combine the logic of both plugins to create a unified camera preview for all platforms:
Widget getPreview() {
if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
return _buildCameraStream();
}
if (controller == null || !controller!.value.isInitialized || isFinished) {
return Container(
child: const Text('No camera available!'),
);
}
return CameraPreview(controller!);
}
How to Decode Barcodes from Camera Frames on Web, Desktop, and Mobile Platforms
While the camera
plugin provides camera previews for Web, Android, and iOS, it only supports frame callbacks on Android and iOS. Additionally, platform-specific code is required to distinguish between Android and iOS. For Web, the takePicture()
function is the only way to retrieve camera frames.
Android and iOS:
List<BarcodeResult>? barcodeResults;
Future<void> mobileCamera() async {
await controller!.startImageStream((CameraImage availableImage) async {
assert(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS);
if (cbIsMounted() == false || isFinished) return;
int format = ImagePixelFormat.IPF_NV21.index;
switch (availableImage.format.group) {
case ImageFormatGroup.yuv420:
format = ImagePixelFormat.IPF_NV21.index;
break;
case ImageFormatGroup.bgra8888:
format = ImagePixelFormat.IPF_ARGB_8888.index;
break;
default:
format = ImagePixelFormat.IPF_RGB_888.index;
}
if (!_isScanAvailable) {
return;
}
_isScanAvailable = false;
processId(availableImage.planes[0].bytes, availableImage.width,
availableImage.height, availableImage.planes[0].bytesPerRow, format);
});
}
void processId(
Uint8List bytes, int width, int height, int stride, int format) {
barcodeReader
.decodeImageBuffer(bytes, width, height, stride, format)
.then((results) {
if (!cbIsMounted()) {
return;
}
if (MediaQuery.of(context).size.width <
MediaQuery.of(context).size.height) {
if (Platform.isAndroid && results.isNotEmpty) {
results = rotate90barcode(results, previewSize!.height.toInt());
}
}
barcodeResults = results;
_isScanAvailable = true;
});
}
Web:
Future<void> webCamera() async {
_isWebFrameStarted = true;
while (!(controller == null || isFinished || cbIsMounted() == false)) {
XFile? file = await controller?.takePicture();
if (file != null) {
var results = await barcodeReader.decodeFile(file.path);
barcodeResults = results;
}
}
_isWebFrameStarted = false;
}
In contrast, using the flutter_lite_camera
plugin simplifies the process:
Future<void> _captureFrames() async {
if (!_isCameraOpened || !_shouldCapture || !cbIsMounted()) return;
try {
Map<String, dynamic> frame =
await _flutterLiteCameraPlugin.captureFrame();
if (frame.containsKey('data')) {
Uint8List rgbBuffer = frame['data'];
_decodeFrame(rgbBuffer, frame['width'], frame['height']);
await _convertBufferToImage(rgbBuffer, frame['width'], frame['height']);
}
} catch (e) {
}
if (_shouldCapture) {
Future.delayed(const Duration(milliseconds: 30), _captureFrames);
}
}
Future<void> _decodeFrame(Uint8List rgb, int width, int height) async {
if (isDecoding) return;
isDecoding = true;
barcodeResults = await barcodeReader.decodeImageBuffer(
rgb,
width,
height,
width * 3,
ImagePixelFormat.IPF_RGB_888.index,
);
isDecoding = false;
}
How to Draw the Overlay with Barcode Detection Results
Visualizing barcode detection results in real-time is crucial for a barcode scanner app. In Flutter, you can create a custom widget that fully covers the camera preview. And then place both widgets inside a FittedBox
widget to maintain the aspect ratio automatically.
Positioned(
top: 0,
right: 0,
left: 0,
bottom: 0,
child: FittedBox(
fit: BoxFit.cover,
child: Stack(
children: createCameraPreview(),
),
),
),
List<Widget> createCameraPreview() {
if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
return [
SizedBox(width: 640, height: 480, child: _cameraManager.getPreview()),
Positioned(
top: 0.0,
right: 0.0,
bottom: 0,
left: 0.0,
child: createOverlay(
_cameraManager.barcodeResults,
),
),
];
} else {
if (_cameraManager.controller != null &&
_cameraManager.previewSize != null) {
double width = _cameraManager.previewSize!.width;
double height = _cameraManager.previewSize!.height;
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
if (MediaQuery.of(context).size.width <
MediaQuery.of(context).size.height) {
width = _cameraManager.previewSize!.height;
height = _cameraManager.previewSize!.width;
}
}
return [
SizedBox(
width: width, height: height, child: _cameraManager.getPreview()),
Positioned(
top: 0.0,
right: 0.0,
bottom: 0,
left: 0.0,
child: createOverlay(
_cameraManager.barcodeResults,
),
),
];
} else {
return [const CircularProgressIndicator()];
}
}
}
Widget createOverlay(
List<BarcodeResult>? barcodeResults,
) {
return CustomPaint(
painter: OverlayPainter(barcodeResults),
);
}
class OverlayPainter extends CustomPainter {
List<BarcodeResult>? barcodeResults;
OverlayPainter(this.barcodeResults);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.blue
..strokeWidth = 5
..style = PaintingStyle.stroke;
if (barcodeResults != null) {
for (var result in barcodeResults!) {
double minX = result.x1.toDouble();
double minY = result.y1.toDouble();
if (result.x2 < minX) minX = result.x2.toDouble();
if (result.x3 < minX) minX = result.x3.toDouble();
if (result.x4 < minX) minX = result.x4.toDouble();
if (result.y2 < minY) minY = result.y2.toDouble();
if (result.y3 < minY) minY = result.y3.toDouble();
if (result.y4 < minY) minY = result.y4.toDouble();
canvas.drawLine(Offset(result.x1.toDouble(), result.y1.toDouble()),
Offset(result.x2.toDouble(), result.y2.toDouble()), paint);
canvas.drawLine(Offset(result.x2.toDouble(), result.y2.toDouble()),
Offset(result.x3.toDouble(), result.y3.toDouble()), paint);
canvas.drawLine(Offset(result.x3.toDouble(), result.y3.toDouble()),
Offset(result.x4.toDouble(), result.y4.toDouble()), paint);
canvas.drawLine(Offset(result.x4.toDouble(), result.y4.toDouble()),
Offset(result.x1.toDouble(), result.y1.toDouble()), paint);
TextPainter textPainter = TextPainter(
text: TextSpan(
text: result.text,
style: const TextStyle(
color: Colors.yellow,
fontSize: 22.0,
),
),
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
textPainter.layout(minWidth: 0, maxWidth: size.width);
textPainter.paint(canvas, Offset(minX, minY));
}
}
}
@override
bool shouldRepaint(OverlayPainter oldDelegate) => true;
}
Running the Cross-Platform Barcode Scanner for Web, Windows, macOS, Linux, iOS, and Android
You only need a single command to run the Flutter project on different platforms. Below are the commands for each platform:
- Web
flutter run -d chrome
- Windows
flutter run -d windows
- macOS
flutter run -d macos
- Linux
flutter run -d linux
- iOS
flutter run
- Android
flutter run
Source Code
https://github.com/yushulx/flutter-barcode-mrz-document-scanner/tree/main/examples/flutter_camera
Originally published at https://www.dynamsoft.com on February 7, 2025.