qr_lens
A polished Flutter QR code scanner widget with animated capture overlay, live corner tracking, torch control, scan history, and URL detection. Built on camera and Google ML Kit.
Features
- Clean scan overlay with animated sweep line
- Live corner tracking that follows QR codes in frame
- Smooth capture animation: box expand → QR fly-to-center → result card
- Haptic feedback on capture
- Torch / flashlight toggle
- Front/back camera switching
- Scan from gallery image (
ImagePickeror custom picker) - Scan history with URL detection, copy, and open-in-browser
- Fully replaceable: app bar, viewfinder overlay, result card, history sheet
Getting Started
Add to your pubspec.yaml:
dependencies:
qr_lens: latest
Platform Setup
iOS — add camera usage description to ios/Runner/Info.plist:
<key>NSCameraUsageDescription</key>
<string>Camera needed to scan QR codes</string>
Android — ensure android/app/build.gradle sets minSdkVersion to 21 or higher.
Basic Usage
import 'package:qr_lens/qr_lens.dart';
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => QrLensScannerPage(
onScanComplete: (value) {
// Called once the capture animation finishes.
Navigator.pop(context);
handleResult(value);
},
),
),
);
QrLensScannerPage handles camera initialisation, ML Kit scanning, overlay animations, and result display with zero configuration.
Customisation
Accent & success colours
QrLensScannerPage(
accentColor: const Color(0xFF6C63FF), // corners, scan line, result border
successColor: const Color(0xFF4CFF91), // glow when a QR is captured
)
Custom app bar — appBarBuilder
Replace the entire app bar. Receives isBackCamera so you can conditionally
show a torch button.
QrLensScannerPage(
appBarBuilder: (context, {required bool isBackCamera}) {
return AppBar(
backgroundColor: Colors.black54,
leading: BackButton(color: Colors.white),
title: const Text('Scan QR'),
actions: [
if (isBackCamera)
IconButton(
icon: const Icon(Icons.flashlight_off_rounded, color: Colors.white70),
onPressed: () {}, // wire up a QrLensScannerController to drive torch
),
],
);
},
)
Custom result card — resultBuilder
Replace the bottom result card. The scanner still handles timing and auto-reset.
QrLensScannerPage(
resultBuilder: (context, value) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1C1C1C),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.greenAccent.withValues(alpha: 0.5)),
),
child: Text(value, style: const TextStyle(color: Colors.white)),
);
},
)
Custom viewfinder — viewFinderBuilder
viewFinderBuilder replaces the entire scanner overlay. The returned widget
fills the full screen, so you can use Positioned with screen-coordinate
values from ViewfinderState.
ViewfinderState fields
| Field | Type | Description |
|---|---|---|
boundingRect |
Rect |
Viewfinder window in screen coordinates |
scanProgress |
double |
Normalised scan-line position (0 → 1) |
overlayAlpha |
double |
Current overlay opacity (fades in/out as QR enters/leaves frame) |
isCapturing |
bool |
Box-expand animation is running (QR just detected) |
isShowingResult |
bool |
Capture animation finished; result card is visible |
detectedRect |
Rect? |
Live bounding rect of detected QR, or null |
scannedValue |
String? |
The scanned string, available from capture onward |
Minimal example — custom dim + border
QrLensScannerPage(
viewFinderBuilder: (context, state) {
final rect = state.boundingRect;
return CustomPaint(
painter: _MyOverlayPainter(rect: rect, scanProgress: state.scanProgress),
);
},
)
class _MyOverlayPainter extends CustomPainter {
final Rect rect;
final double scanProgress;
_MyOverlayPainter({required this.rect, required this.scanProgress});
@override
void paint(Canvas canvas, Size size) {
// Dim background with clear window
final path = Path()
..addRect(Offset.zero & size)
..addRRect(RRect.fromRectAndRadius(rect, const Radius.circular(12)));
path.fillType = PathFillType.evenOdd;
canvas.drawPath(path, Paint()..color = Colors.black.withValues(alpha: 0.65));
// Border
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(12)),
Paint()
..color = Colors.white.withValues(alpha: 0.5)
..strokeWidth = 2
..style = PaintingStyle.stroke,
);
// Scan line using state.scanProgress
canvas.save();
canvas.clipRect(rect);
final y = rect.top + rect.height * scanProgress;
canvas.drawLine(
Offset(rect.left, y),
Offset(rect.right, y),
Paint()..color = Colors.greenAccent..strokeWidth = 2,
);
canvas.restore();
}
@override
bool shouldRepaint(_MyOverlayPainter old) => true;
}
Using all state fields — animated corner brackets
// In a StatefulWidget with SingleTickerProviderStateMixin:
late final AnimationController _pulseCtrl;
late final Animation<double> _pulseAnim;
@override
void initState() {
super.initState();
_pulseCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat(reverse: true);
_pulseAnim = CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut);
}
@override
Widget build(BuildContext context) {
return QrLensScannerPage(
viewFinderBuilder: (context, state) {
// Wrap in AnimatedBuilder to also rebuild on the local pulse tick.
return AnimatedBuilder(
animation: _pulseAnim,
builder: (context, _) {
final rect = state.boundingRect;
return Stack(
fit: StackFit.expand,
children: [
// Main overlay via CustomPainter
CustomPaint(
painter: _CornerPainter(state: state, pulse: _pulseAnim.value),
),
// Live detected-QR rect — visible before capture starts
if (state.detectedRect != null &&
!state.isCapturing &&
!state.isShowingResult)
Positioned(
left: state.detectedRect!.left - 4,
top: state.detectedRect!.top - 4,
width: state.detectedRect!.width + 8,
height: state.detectedRect!.height + 8,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: Colors.purpleAccent.withValues(alpha: 0.7),
width: 1.5,
),
),
),
),
// Status hint below the viewfinder
Positioned(
bottom: rect.bottom + 20,
left: 0,
right: 0,
child: Center(
child: Text(
state.isShowingResult
? 'Scanned ✓'
: state.isCapturing
? 'Capturing…'
: state.detectedRect != null
? 'QR detected'
: 'Align QR code inside the frame',
style: const TextStyle(color: Colors.white70, fontSize: 13),
),
),
),
],
);
},
);
},
hintText: null, // replaced by the custom label above
);
}
See example/lib/screens/custom_viewfinder_screen.dart for the full _CornerPainter implementation with scan line, capture ring, and success glow.
Image or widget as the viewfinder frame
Place any widget — a PNG asset, an SVG, or a custom-painted widget — over
state.boundingRect and animate it with ScaleTransition. Pair with
autoResume: false and a QrLensScannerController to freeze the frame after
a scan and resume only after your own result dialog is dismissed.
// In a StatefulWidget with SingleTickerProviderStateMixin:
final _controller = QrLensScannerController();
late final AnimationController _pulseCtrl;
late final Animation<double> _pulseAnim;
@override
void initState() {
super.initState();
_pulseCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
)..repeat(reverse: true);
_pulseAnim = Tween<double>(begin: 1.0, end: 1.08).animate(
CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut),
);
}
@override
Widget build(BuildContext context) {
return QrLensScannerPage(
controller: _controller,
autoResume: false, // keep frame frozen until we call resumeStream()
// Stop the pulse the moment a value is detected.
onScanned: (_) => _pulseCtrl.stop(),
// After capture animation finishes, show your own result UI,
// then restart scanning.
onScanComplete: (value) async {
await showMyResultDialog(value);
_pulseCtrl.repeat(reverse: true);
_controller.resumeStream();
},
viewFinderBuilder: (context, state) {
final rect = state.boundingRect;
return Stack(
children: [
// Optional dim overlay (omit if your image already has dark edges).
CustomPaint(
size: MediaQuery.of(context).size,
painter: _DimPainter(rect: rect),
),
// Position the frame widget over the viewfinder rect.
Positioned(
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
child: ScaleTransition(
scale: _pulseAnim,
// Replace with your real asset:
// Image.asset('assets/qr_frame.png', fit: BoxFit.contain)
// SvgPicture.asset('assets/qr_frame.svg')
child: Image.asset('assets/qr_frame.png', fit: BoxFit.contain),
),
),
],
);
},
// Suppress the built-in result card — we show our own dialog.
resultBuilder: (context, value) => const SizedBox.shrink(),
hintText: null,
);
}
The image asset should be transparent in the centre so the live camera feed
shows through. Dark or semi-transparent edges on the asset act as the natural
dim overlay, removing the need for _DimPainter.
See example/lib/screens/image_viewfinder_screen.dart for the full runnable version with a placeholder frame drawn via CustomPaint.
Programmatic control — QrLensScannerController
final _controller = QrLensScannerController();
QrLensScannerPage(
controller: _controller,
autoResume: false, // scanner stays frozen after each scan
onScanComplete: (value) {
validate(value); // do your work
_controller.resumeStream(); // then restart when you're ready
},
)
// Scan from a file (e.g. from photo_manager or file_picker):
final file = await ImagePicker().pickImage(source: ImageSource.gallery);
if (file != null) await _controller.scanFromFile(file);
Custom gallery picker — imagePickerBuilder
Replace the default ImagePicker invoked by the upload button:
QrLensScannerPage(
imagePickerBuilder: () async {
// Return an XFile, or null to cancel.
return await MyCustomPicker.pick();
},
)
Timing
QrLensScannerPage(
scanLineAnimationDuration: const Duration(milliseconds: 2400),
boxAnimationDuration: const Duration(milliseconds: 350),
qrMoveAnimationDuration: const Duration(milliseconds: 900),
qrSnippetDelay: const Duration(milliseconds: 500),
onScanCompleteDelay: Duration.zero,
resultDuration: const Duration(milliseconds: 2800),
autoResume: true,
)
Examples
The example/ directory contains a runnable app with four screens:
| Screen | File | Demonstrates |
|---|---|---|
| Default Scanner | screens/default_scanner_screen.dart |
Zero-config drop-in usage |
| Custom Viewfinder | screens/custom_viewfinder_screen.dart |
viewFinderBuilder with all ViewfinderState fields, local AnimationController |
| Branded Scanner | screens/branded_scanner_screen.dart |
appBarBuilder + resultBuilder + minimal rounded overlay |
| Image Viewfinder | screens/image_viewfinder_screen.dart |
Image / widget over boundingRect with ScaleTransition pulse, autoResume: false, controller-driven resume |
Run it with:
cd example
flutter run
License
MIT