Building a Cross-Platform Barcode Scanner for Mobile, Desktop, and Web with Flutter

Xiao Ling
6 min read4 days ago

--

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.

  1. 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.

--

--

Xiao Ling
Xiao Ling

Written by Xiao Ling

Manager of Dynamsoft Open Source Projects | Tech Lover

No responses yet