Flutter barcode scanning with the camera plugin: a production-ready guide

Build a fast, on‑device Flutter barcode scanner using the camera plugin and ML Kit, with code, overlays, performance tips, and platform setup.

ASOasis
9 min read
Flutter barcode scanning with the camera plugin: a production-ready guide

Image used for representation purposes only.

Overview

Building a fast, accurate barcode scanner in Flutter is straightforward with the camera plugin plus an on‑device detector. This guide shows you how to wire up the camera preview, stream frames, run detection using Google’s ML Kit, draw bounding boxes, and ship a production‑ready experience with strong performance on both Android and iOS.

What you’ll build:

  • A CameraPreview with live barcode detection
  • Support for common formats (QR, EAN‑13, Code 128, etc.)
  • Real‑time overlays, torch toggle, and simple throttling

When to use the camera plugin vs. “all‑in‑one” scanners

  • camera + ML Kit: Maximum control over UX, formats, overlays, and performance. Slightly more boilerplate.
  • mobile_scanner/qr_code_scanner: Quicker start, fewer moving parts, less customization.

If you need custom UI/overlays, rate limiting, analytics hooks, or specialized formats, building on camera is the flexible path.

Architecture at a glance

  • CameraController streams YUV/BGRA frames.
  • Convert CameraImage to ML Kit’s InputImage with correct rotation/metadata.
  • BarcodeScanner processes frames on‑device; results feed the overlay and callback.
  • Throttle/work queue avoids blocking the UI thread.

Dependencies and setup

Add the camera plugin and ML Kit’s barcode package.

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  camera: ^latest
  google_mlkit_barcode_scanning: ^latest

Android permissions (android/app/src/main/AndroidManifest.xml):

<uses-permission android:name="android.permission.CAMERA" />
<application ...>
    <!-- Required if you use torch/flash or autofocus is typically automatic -->
</application>

iOS permissions (ios/Runner/Info.plist):

<key>NSCameraUsageDescription</key>
<string>We use the camera to scan barcodes.</string>

Tip: Test on real devices. Emulators often lack full camera capabilities or deliver low frame rates.

Initializing the camera

Create a stateful page that picks the back camera, sets a reasonable resolution, and shows the preview.

import 'dart:async';
import 'dart:typed_data';
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:google_mlkit_barcode_scanning/google_mlkit_barcode_scanning.dart';

class BarcodeScannerPage extends StatefulWidget {
  const BarcodeScannerPage({super.key});
  @override
  State<BarcodeScannerPage> createState() => _BarcodeScannerPageState();
}

class _BarcodeScannerPageState extends State<BarcodeScannerPage> {
  CameraController? _controller;
  late final BarcodeScanner _scanner;
  bool _isProcessing = false;
  List<Barcode> _barcodes = const [];
  bool _torchOn = false;

  @override
  void initState() {
    super.initState();
    _scanner = BarcodeScanner(formats: [
      BarcodeFormat.qrCode,
      BarcodeFormat.ean13,
      BarcodeFormat.code128,
      BarcodeFormat.dataMatrix,
      BarcodeFormat.upcA,
    ]);
    _init();
  }

  Future<void> _init() async {
    final cameras = await availableCameras();
    final back = cameras.firstWhere((c) => c.lensDirection == CameraLensDirection.back);
    _controller = CameraController(
      back,
      ResolutionPreset.medium,
      enableAudio: false,
      imageFormatGroup: ImageFormatGroup.yuv420,
    );
    await _controller!.initialize();

    // Start streaming frames
    await _controller!.startImageStream(_processCameraImage);
    if (mounted) setState(() {});
  }

  @override
  void dispose() {
    _controller?.dispose();
    _scanner.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final controller = _controller;
    if (controller == null || !controller.value.isInitialized) {
      return const Scaffold(body: Center(child: CircularProgressIndicator()));
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text('Barcode Scanner'),
        actions: [
          IconButton(
            icon: Icon(_torchOn ? Icons.flash_on : Icons.flash_off),
            onPressed: () async {
              if (!controller.value.isInitialized) return;
              _torchOn = !_torchOn;
              await controller.setFlashMode(
                _torchOn ? FlashMode.torch : FlashMode.off,
              );
              if (mounted) setState(() {});
            },
          ),
        ],
      ),
      body: LayoutBuilder(
        builder: (context, constraints) {
          final preview = CameraPreview(controller);
          return Stack(
            fit: StackFit.expand,
            children: [
              Center(
                child: AspectRatio(
                  aspectRatio: controller.value.aspectRatio,
                  child: preview,
                ),
              ),
              // Draw results on top
              Positioned.fill(
                child: CustomPaint(
                  painter: _BarcodeOverlayPainter(
                    barcodes: _barcodes,
                    camera: controller,
                  ),
                ),
              ),
            ],
          );
        },
      ),
    );
  }

  // Throttled frame processing
  Future<void> _processCameraImage(CameraImage image) async {
    if (_isProcessing) return; // simple throttle; see performance notes below
    _isProcessing = true;
    try {
      final inputImage = _inputImageFromCameraImage(image, _controller!);
      final results = await _scanner.processImage(inputImage);
      if (mounted) setState(() => _barcodes = results);

      // Example: auto‑stop when a desired barcode is found
      final first = results.firstWhere(
        (b) => b.rawValue != null && b.rawValue!.isNotEmpty,
        orElse: () => const Barcode(rawValue: null),
      );
      if (first.rawValue != null) {
        // Handle match (navigate, show dialog, etc.)
      }
    } catch (_) {
      // swallow/handle gracefully to keep stream alive
    } finally {
      _isProcessing = false;
    }
  }
}

Converting CameraImage to InputImage

The critical part is passing bytes and metadata (size, rotation, plane info) correctly. The camera plugin emits YUV420 on Android and BGRA8888 on iOS by default; ML Kit accepts both when metadata matches.

InputImage _inputImageFromCameraImage(
  CameraImage image,
  CameraController controller,
) {
  // 1) Concatenate plane bytes (Y, U, V or BGRA)
  final WriteBuffer allBytes = WriteBuffer();
  for (final Plane plane in image.planes) {
    allBytes.putUint8List(plane.bytes);
  }
  final bytes = allBytes.done().buffer.asUint8List();

  // 2) Rotation based on sensor orientation
  final rotation = InputImageRotationValue.fromRawValue(
        controller.description.sensorOrientation,
      ) ??
      InputImageRotation.rotation0deg;

  // 3) Image format
  final format = InputImageFormatValue.fromRawValue(image.format.raw) ??
      InputImageFormat.nv21; // common on Android; ML Kit handles BGRA on iOS

  // 4) Plane metadata
  final planeData = image.planes
      .map(
        (Plane p) => InputImagePlaneMetadata(
          bytesPerRow: p.bytesPerRow,
          height: p.height,
          width: p.width,
        ),
      )
      .toList();

  final metadata = InputImageMetadata(
    size: Size(image.width.toDouble(), image.height.toDouble()),
    rotation: rotation,
    format: format,
    planeData: planeData,
  );

  return InputImage.fromBytes(bytes: bytes, metadata: metadata);
}

Notes:

  • Do not rotate bytes yourself; provide rotation metadata and let ML Kit handle it.
  • For performance, reuse the BarcodeScanner instance; don’t create/close per frame.

Drawing bounding boxes

ML Kit returns each barcode’s boundingBox in image coordinates. Map those to your preview size, accounting for rotation/aspect.

class _BarcodeOverlayPainter extends CustomPainter {
  _BarcodeOverlayPainter({required this.barcodes, required this.camera});
  final List<Barcode> barcodes;
  final CameraController camera;

  @override
  void paint(Canvas canvas, Size size) {
    if (!camera.value.isInitialized) return;
    final previewSize = camera.value.previewSize!; // in landscape (width>height)

    // Because the preview is rotated to portrait, swap axes when computing scale.
    final scaleX = size.width / previewSize.height;
    final scaleY = size.height / previewSize.width;

    final paint = Paint()
      ..style = PaintingStyle.stroke
      ..color = Colors.cyanAccent
      ..strokeWidth = 3;

    for (final b in barcodes) {
      final rect = b.boundingBox;
      if (rect == null) continue;

      // Map image rect -> widget rect
      final left = rect.left * scaleX;
      final top = rect.top * scaleY;
      final right = rect.right * scaleX;
      final bottom = rect.bottom * scaleY;

      final r = Rect.fromLTRB(left, top, right, bottom);
      canvas.drawRRect(
        RRect.fromRectAndRadius(r, const Radius.circular(8)),
        paint,
      );
    }
  }

  @override
  bool shouldRepaint(covariant _BarcodeOverlayPainter oldDelegate) {
    return oldDelegate.barcodes != barcodes || oldDelegate.camera != camera;
  }
}

Caveat: Camera transforms differ by device orientation and lens. For perfect mapping across rotations, consider:

  • Using the controller’s value.deviceOrientation to adapt scaleX/scaleY.
  • Accounting for front camera mirroring when needed.
  • Clipping/pillarboxing: if your preview is letterboxed, compute effective displayed region and offset accordingly.

Handling focus and tap‑to‑meter

Expose tap‑to‑focus for better performance in dim light.

GestureDetector(
  behavior: HitTestBehavior.opaque,
  onTapDown: (d) async {
    final box = context.findRenderObject() as RenderBox?;
    if (box == null) return;
    final offset = box.globalToLocal(d.globalPosition);
    final point = Offset(
      offset.dx / box.size.width,
      offset.dy / box.size.height,
    );
    try {
      await _controller?.setFocusPoint(point);
      await _controller?.setExposurePoint(point);
    } catch (_) {}
  },
  child: CameraPreview(_controller!),
)

Throttling and isolates for smooth 60 fps

  • Simple flag throttle: Process the next frame only after the current one finishes. This keeps UI responsive and is often sufficient.
  • Time‑based throttle: Process at 10–15 FPS while rendering 60 FPS.
  • Heavy lifting in an isolate: Convert bytes in a compute() call to offload work from the UI thread, especially when concatenating planes or cropping a region of interest.
  • Reduce resolution: Use ResolutionPreset.medium or low; detection accuracy for 1D barcodes remains strong.
  • Early‑exit: Once you’ve found a valid payload, pause the stream: await _controller?.stopImageStream();

Example time‑based throttle:

DateTime _last = DateTime.fromMillisecondsSinceEpoch(0);
const minGap = Duration(milliseconds: 70); // ~14 fps

Future<void> _processCameraImage(CameraImage image) async {
  if (DateTime.now().difference(_last) < minGap) return;
  if (_isProcessing) return;
  _last = DateTime.now();
  _isProcessing = true;
  try {
    final input = _inputImageFromCameraImage(image, _controller!);
    final res = await _scanner.processImage(input);
    if (mounted) setState(() => _barcodes = res);
  } finally {
    _isProcessing = false;
  }
}

Reading barcode contents

Each Barcode exposes type‑specific data and rawValue.

for (final b in _barcodes) {
  final value = b.rawValue ?? '';
  switch (b.type) {
    case BarcodeType.url:
      // b.url?.url
      break;
    case BarcodeType.wifi:
      // ssid: b.wifi?.ssid, password: b.wifi?.password
      break;
    default:
      // Fallback to raw string
  }
}

Region of interest (ROI) scanning

Improve UX and speed by scanning only the center region.

  • Show a framing box and tell users to align the code.
  • Crop bytes before detection (requires manual YUV cropping) or simply ignore results outside your ROI rectangle after detection.

Quick filter after detection:

Rect roi = Rect.fromLTWH(
  size.width * 0.1, // left margin
  size.height * 0.3,
  size.width * 0.8,
  size.height * 0.4,
);

final inRoi = _barcodes.where((b) {
  final r = _mapImageRectToWidget(b.boundingBox!);
  return roi.overlaps(r) && roi.contains(r.center);
});

Torch/flash and low‑light strategy

  • Use a visible torch button; don’t auto‑enable flash without user consent.
  • Consider bumping exposure via setExposureMode/Point.
  • Fall back to a manual entry field if scanning consistently fails under harsh lighting.

Error handling and lifecycle

Common issues and remedies:

  • Black preview on Android emulator: test on hardware, or cold boot the emulator and check camera permission.
  • CameraException(Disposed): guard setState after dispose; stop stream before navigating; re‑initialize on app resume if needed.
  • Rotated/offset boxes: verify orientation math; use deviceOrientation; consider front camera mirroring.
  • iOS BGRA crash: ensure you pass correct plane metadata; avoid manual color conversions.

Performance checklist

  • Reuse a single BarcodeScanner instance.
  • Throttle frame processing (flag or timed).
  • Medium resolution preset; avoid ultra‑high.
  • Use YUV420 (Android) and let ML Kit do rotation/format handling.
  • Prefer center ROI; skip tiny bounding boxes likely to be noise.
  • Keep overlay painting lightweight; avoid heavy rebuilds.

Packaging the feature

  • Provide callbacks like onBarcodeFound(String value) with a debounce to avoid duplicate events.
  • Add a cancel button and manual entry fallback.
  • Respect privacy: all detection is on‑device; avoid uploading frames unless clearly disclosed.

Minimal, complete example widget

This simplified widget encapsulates scanning and returns the first detected payload.

class SimpleBarcodeScanner extends StatefulWidget {
  const SimpleBarcodeScanner({super.key, this.onScanned});
  final ValueChanged<String>? onScanned;

  @override
  State<SimpleBarcodeScanner> createState() => _SimpleBarcodeScannerState();
}

class _SimpleBarcodeScannerState extends State<SimpleBarcodeScanner> {
  CameraController? _c;
  late final BarcodeScanner _scanner;
  bool _busy = false;

  @override
  void initState() {
    super.initState();
    _scanner = BarcodeScanner();
    _start();
  }

  Future<void> _start() async {
    final cams = await availableCameras();
    final back = cams.firstWhere((e) => e.lensDirection == CameraLensDirection.back);
    _c = CameraController(back, ResolutionPreset.medium, enableAudio: false);
    await _c!.initialize();
    await _c!.startImageStream((img) async {
      if (_busy) return;
      _busy = true;
      try {
        final input = _inputImageFromCameraImage(img, _c!);
        final result = await _scanner.processImage(input);
        final first = result.firstWhere(
          (b) => b.rawValue != null && b.rawValue!.isNotEmpty,
          orElse: () => const Barcode(rawValue: null),
        );
        if (first.rawValue != null) {
          widget.onScanned?.call(first.rawValue!);
          await _c?.stopImageStream();
        }
      } finally {
        _busy = false;
      }
    });
    if (mounted) setState(() {});
  }

  @override
  void dispose() {
    _c?.dispose();
    _scanner.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (_c == null || !_c!.value.isInitialized) {
      return const Center(child: CircularProgressIndicator());
    }
    return AspectRatio(
      aspectRatio: _c!.value.aspectRatio,
      child: CameraPreview(_c!),
    );
  }
}

Testing strategy

  • Use a sample sheet of barcodes at multiple sizes and contrasts.
  • Test distances: close (macro), medium (20–40 cm), far (up to 1 m for large 1D codes).
  • Verify all target formats in both orientations.
  • Low‑light and glare scenarios; torch behavior.
  • Front vs. back camera selection on devices with multiple lenses.

Security and privacy

  • Keep processing on‑device; ML Kit runs locally by default.
  • Explain data handling in your privacy policy.
  • If you log barcode values, avoid storing sensitive payloads (Wi‑Fi passwords, personal info) unless necessary and consented.

Alternatives and enhancements

  • mobile_scanner: Great drop‑in if you don’t need custom pipelines.
  • zxing2 for pure‑Dart decoding when you want zero native deps (usually slower on mobile cameras).
  • Add haptic feedback on successful scan.
  • Use a state machine (Idle → Detecting → Found → Confirmed) to avoid duplicate navigations.

Wrap‑up

With the camera plugin and ML Kit, you can deliver a robust, low‑latency barcode scanner entirely on‑device. Start with the minimal example, then layer in throttling, ROI, overlays, and UX touches like torch and tap‑to‑focus. The result is a production‑grade scanner tailored to your app’s needs.

Related Posts