Dynamsoft Barcode SDK: Build Mobile Barcode Scanners with React Native, Flutter, and .NET MAUI
Choosing the right cross-platform framework for a mobile barcode scanning app means weighing API ergonomics against rendering performance and developer experience. This article compares React Native, Flutter, and .NET MAUI side by side on Android and iOS, each integrated with the Dynamsoft Capture Vision SDK, to show how they differ in setup, camera control, barcode decoding, and overlay rendering when building real-time scanning apps.

What you’ll build: A real-time mobile barcode scanner that decodes barcodes from a live camera feed and draws bounding-box overlays on each detected code, implemented in three frameworks using the Dynamsoft Capture Vision SDK.
Key Takeaways
- All three frameworks use the same Dynamsoft Capture Vision architecture (CameraEnhancer + CaptureVisionRouter), but the API surface differs significantly in verbosity and type safety
- Flutter and React Native share a near-identical SDK pattern with singleton instances and callback-based receivers; .NET MAUI uses C# interfaces and instance-based construction
- React Native relies on absolutely-positioned Views for overlay drawing, Flutter uses CustomPainter, and .NET MAUI uses SkiaSharp — each with different coordinate-mapping complexity
- Flutter’s
FittedBox.coverhandles coordinate mapping automatically, while React Native and .NET MAUI require manual scale calculations - For inventory and batch-scanning scenarios, Flutter’s built-in state management (Provider) and dual-view mode offer the most feature-complete starting point
Common Developer Questions
- “Which cross-platform framework is best for real-time barcode scanning on mobile?”
- “How do I draw barcode bounding boxes as an overlay in React Native vs Flutter vs MAUI?”
- “How do I handle camera coordinate-to-screen coordinate mapping for barcode overlays?”
- “Which framework requires the least code to build a barcode scanner with Dynamsoft?”
- “How does overlay rendering performance compare across Flutter, React Native, and MAUI?”
Prerequisites
| Framework | SDK | Version | Dev Environment |
|---|---|---|---|
| Flutter | dynamsoft_capture_vision_flutter |
3.4.1200 | Flutter 3.x, Dart SDK ≥3.3.0 |
| React Native | dynamsoft-capture-vision-react-native |
3.4.1200 | Node ≥18, React Native 0.79 |
| .NET MAUI | Dynamsoft.CaptureVisionBundle.Maui |
3.4.1200 | .NET 10 SDK, Visual Studio 2022 |
Get a 30-day free trial license for Dynamsoft Barcode Reader.
Initialize the Dynamsoft Barcode SDK License
All three frameworks require license initialization before any scanning can proceed, but the placement and error handling differ.
Flutter — called in the root widget’s initState():
Future<void> _initLicense() async {
try {
final licenseResult = await LicenseManager.initLicense(
'LICENSE-KEY');
if (kDebugMode) print('License init: $licenseResult');
} catch (e) {
if (kDebugMode) print(e);
}
}
React Native — called at module level before component render:
const License = 'LICENSE-KEY';
LicenseManager.initLicense(License).catch(e => {
Alert.alert('License error', e.message);
});
.NET MAUI — called in MauiProgram.cs during app startup. The mobile SDK uses the same LicenseManager from the Capture Vision Bundle:
string license = "LICENSE-KEY";
string errorMsg;
int errorCode = LicenseManager.InitLicense(license, out errorMsg);
if (errorCode != (int)Dynamsoft.Core.EnumErrorCode.EC_OK)
Debug.WriteLine("License initialization error: " + errorMsg);
Comparison: Flutter and React Native both use async/await patterns with exceptions; .NET MAUI uses a synchronous call with error codes. React Native’s module-level initialization is the most concise, while Flutter’s widget-lifecycle approach gives more control over timing. .NET MAUI’s synchronous approach is straightforward but requires platform-specific placement.
Install and Configure the SDK
Flutter — a single package covers all Dynamsoft functionality:
# pubspec.yaml
dependencies:
dynamsoft_capture_vision_flutter: ^3.4.1200
provider: ^6.1.5+1
url_launcher: ^6.3.2
share_plus: ^12.0.1
image_picker: ^1.2.1
flutter_exif_rotation: ^0.5.2
React Native — two packages: the core router and the barcode reader bundle:
{
"dependencies": {
"dynamsoft-capture-vision-react-native": "^3.4.1200",
"dynamsoft-barcode-reader-bundle-react-native": "^11.4.1200",
"react": "19.0.0",
"react-native": "0.79.0"
}
}
.NET MAUI — the Dynamsoft.CaptureVisionBundle.Maui package covers Android and iOS:
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-android36.0' Or '$(TargetFramework)' == 'net10.0-ios26.0'">
<PackageReference Include="Dynamsoft.CaptureVisionBundle.Maui" Version="3.4.1200" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="SkiaSharp.Views.Maui.Controls" Version="3.116.1" />
<PackageReference Include="CommunityToolkit.Maui" Version="9.0.1" />
</ItemGroup>
Comparison: Flutter wins on simplicity — one package, one import. React Native’s two-package split is still straightforward. .NET MAUI sits in the middle with a single Capture Vision Bundle but needs additional dependencies like SkiaSharp for overlay drawing.
Set Up the Camera and Start Capturing
All three frameworks follow the same Capture Vision architecture: create a CameraEnhancer, create a CaptureVisionRouter, bind them together, and start capturing with a preset template.
Flutter:
Future<void> _sdkInit() async {
_cvr = CaptureVisionRouter.instance;
_cameraEnhancer = CameraEnhancer.instance;
SimplifiedCaptureVisionSettings? currentSettings =
await _cvr.getSimplifiedSettings(EnumPresetTemplate.readBarcodes);
currentSettings!.barcodeSettings!.barcodeFormatIds =
EnumBarcodeFormat.all;
currentSettings.barcodeSettings!.expectedBarcodesCount = 0;
await _cvr.updateSettings(EnumPresetTemplate.readBarcodes, currentSettings);
_cvr.setInput(_cameraEnhancer);
final CapturedResultReceiver receiver = CapturedResultReceiver()
..onDecodedBarcodesReceived = (DecodedBarcodesResult result) async {
List<BarcodeResultItem>? res = result.items;
if (mounted) {
decodeRes = res ?? [];
setState(() {});
}
};
_cvr.addResultReceiver(receiver);
await _cvr.startCapturing(EnumPresetTemplate.readBarcodes);
await _cameraEnhancer.open();
}
React Native:
const cvr = CaptureVisionRouter.getInstance();
const camera = CameraEnhancer.getInstance();
useEffect(() => {
CameraEnhancer.requestCameraPermission();
cvr.setInput(camera);
camera.setCameraView(cameraView.current);
const receiver = cvr.addResultReceiver({
onDecodedBarcodesReceived: ({ items }) => {
if (items?.length) {
const overlays = items.map((item) => ({
id: `${item.text}-${item.formatString}`,
text: item.text,
formatString: item.formatString,
corners: item.location.points.map(point => ({
x: point.x * scaleFactor - cropOffsetX,
y: point.y * scaleFactor - cropOffsetY
})),
}));
setBarcodeOverlays(overlays);
}
},
});
camera.open();
cvr.startCapturing(EnumPresetTemplate.PT_READ_BARCODES)
.catch(e => Alert.alert('Start error', e.message));
return () => {
cvr.stopCapturing();
camera.close();
cvr.removeResultReceiver(receiver);
};
}, [cvr, camera]);
.NET MAUI (Android/iOS):
public AndroidCameraPage()
{
InitializeComponent();
CameraPreview = new Dynamsoft.CameraEnhancer.Maui.CameraView();
CameraPreview.SizeChanged += OnImageSizeChanged;
MainGrid.Children.Insert(0, CameraPreview);
enhancer = new CameraEnhancer();
router = new CaptureVisionRouter();
router.SetInput(enhancer);
router.AddResultReceiver(this);
}
protected override void OnHandlerChanged()
{
base.OnHandlerChanged();
if (this.Handler != null && enhancer != null)
{
enhancer.SetCameraView(CameraPreview);
enhancer.Open();
}
}
protected override async void OnAppearing()
{
base.OnAppearing();
await Permissions.RequestAsync<Permissions.Camera>();
router?.StartCapturing(EnumPresetTemplate.PT_READ_BARCODES, this);
}
Comparison: Flutter and React Native use singleton instances (CaptureVisionRouter.instance, CaptureVisionRouter.getInstance()) with callback-based result receivers. .NET MAUI uses instance construction (new CaptureVisionRouter()) and C# interface-based callbacks (ICapturedResultReceiver). Flutter and React Native initialize the camera in the same call chain as the router; .NET MAUI requires the camera view to be set in OnHandlerChanged() after the native handler is ready.
Render Barcode Overlays on the Camera Feed
Overlay rendering is where the frameworks diverge most significantly. Each uses a fundamentally different drawing approach.
Flutter uses CustomPaint with a CustomPainter that draws directly on a Canvas:
class OverlayPainter extends CustomPainter {
final List<BarcodeResultItem> results;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.blue
..strokeWidth = 30
..style = PaintingStyle.stroke;
for (var result in results) {
List<Point> points = result.location.points;
canvas.drawLine(
Offset(points[0].x.toDouble(), points[0].y.toDouble()),
Offset(points[1].x.toDouble(), points[1].y.toDouble()), paint);
canvas.drawLine(
Offset(points[1].x.toDouble(), points[1].y.toDouble()),
Offset(points[2].x.toDouble(), points[2].y.toDouble()), paint);
canvas.drawLine(
Offset(points[2].x.toDouble(), points[2].y.toDouble()),
Offset(points[3].x.toDouble(), points[3].y.toDouble()), paint);
canvas.drawLine(
Offset(points[3].x.toDouble(), points[3].y.toDouble()),
Offset(points[0].x.toDouble(), points[0].y.toDouble()), paint);
TextPainter textPainter = TextPainter(
text: TextSpan(
text: result.text,
style: const TextStyle(color: Colors.yellow, fontSize: 100.0),
),
textAlign: TextAlign.center,
textDirection: TextDirection.ltr,
);
textPainter.layout(minWidth: 0, maxWidth: size.width);
textPainter.paint(canvas, Offset(minX, minY));
}
}
}
The camera view is wrapped in a FittedBox with BoxFit.cover, and the overlay is positioned on top:
SizedBox(
width: screenWidth,
height: screenHeight,
child: FittedBox(
fit: BoxFit.cover,
child: Stack(
children: [
SizedBox(
width: isPortrait ? _previewHeight : _previewWidth,
height: isPortrait ? _previewWidth : _previewHeight,
child: CameraView(cameraEnhancer: _cameraEnhancer),
),
Positioned(
left: 0, top: 0, right: 0, bottom: 0,
child: createOverlay(decodeRes))
],
),
),
);
React Native draws overlays using absolutely-positioned View components — no canvas API is needed:
const BarcodeContour: React.FC<{ corners: Array<{ x: number, y: number }> }> = ({ corners }) => {
if (corners.length !== 4) return null;
return (
<>
{corners.map((corner, index) => {
const nextCorner = corners[(index + 1) % 4];
const deltaX = nextCorner.x - corner.x;
const deltaY = nextCorner.y - corner.y;
const length = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
const angle = Math.atan2(deltaY, deltaX) * (180 / Math.PI);
return (
<View
key={index}
style={[
styles.contourLine,
{
left: corner.x,
top: corner.y - 1.5,
width: length,
height: 3,
transform: [{ rotate: `${angle}deg` }],
},
]}
/>
);
})}
{corners.map((corner, index) => (
<View
key={`dot-${index}`}
style={[styles.cornerDot, { left: corner.x - 4, top: corner.y - 4 }]}
/>
))}
</>
);
};
Coordinate mapping from camera space to screen space is handled manually:
const scaleFactorX = cameraViewWidth / cameraResolutionWidth;
const scaleFactorY = cameraViewHeight / cameraResolutionHeight;
const scaleFactor = Math.max(scaleFactorX, scaleFactorY);
const scaledCameraWidth = cameraResolutionWidth * scaleFactor;
const scaledCameraHeight = cameraResolutionHeight * scaleFactor;
const cropOffsetX = (scaledCameraWidth - viewWidth) / 2;
const cropOffsetY = (scaledCameraHeight - viewHeight) / 2;
const scaledCorners = item.location.points.map(point => ({
x: (point.x * scaleFactor) - cropOffsetX,
y: (point.y * scaleFactor) - cropOffsetY
}));
.NET MAUI (iOS) uses SkiaSharp (SKCanvasView) for overlay rendering. The Android implementation uses a GraphicsView with a custom Drawable, while iOS uses SkiaSharp for precise drawing:
public void OnDecodedBarcodesReceived(DecodedBarcodesResult result)
{
if (imageWidth == 0 && imageHeight == 0)
{
IntermediateResultManager manager = router.GetIntermediateResultManager();
ImageData data = manager.GetOriginalImage(result.OriginalImageHashId);
imageWidth = data.Width;
imageHeight = data.Height;
}
lock (_lockObject)
{
_barcodeResult = result;
CameraPreview.GetDrawingLayer(EnumDrawingLayerId.DLI_DBR).Visible = false;
MainThread.BeginInvokeOnMainThread(() =>
{
canvasView.InvalidateSurface();
});
}
}
void OnCanvasViewPaintSurface(object sender, SKPaintSurfaceEventArgs args)
{
SKCanvas canvas = args.Surface.Canvas;
canvas.Clear();
SKPaint skPaint = new SKPaint
{
Style = SKPaintStyle.Stroke,
Color = SKColors.Blue,
StrokeWidth = 4,
};
SKPaint textPaint = new SKPaint
{
Style = SKPaintStyle.Stroke,
Color = SKColors.Red,
StrokeWidth = 4,
};
float textSize = 18;
SKFont font = new SKFont() { Size = textSize };
lock (_lockObject)
{
if (_barcodeResult != null)
{
var items = _barcodeResult.Items;
if (items != null)
{
foreach (var barcodeItem in items)
{
Microsoft.Maui.Graphics.Point[] points = barcodeItem.Location.Points;
float x1 = (float)(points[0].X / scale);
float y1 = (float)(points[0].Y / scale);
float x2 = (float)(points[1].X / scale);
float y2 = (float)(points[1].Y / scale);
float x3 = (float)(points[2].X / scale);
float y3 = (float)(points[2].Y / scale);
float x4 = (float)(points[3].X / scale);
float y4 = (float)(points[3].Y / scale);
canvas.DrawText(barcodeItem.Text, x1, y1 - 10, SKTextAlign.Left, font, textPaint);
canvas.DrawLine(x1, y1, x2, y2, skPaint);
canvas.DrawLine(x2, y2, x3, y3, skPaint);
canvas.DrawLine(x3, y3, x4, y4, skPaint);
canvas.DrawLine(x4, y4, x1, y1, skPaint);
}
}
}
}
}
Comparison:
| Aspect | Flutter | React Native | .NET MAUI |
|---|---|---|---|
| Drawing engine | CustomPainter / Canvas | Absolute-positioned Views | SkiaSharp / GraphicsView Drawable |
| Coordinate mapping | FittedBox.cover handles it automatically |
Manual scale + crop offset calculation | Manual scale calculation with orientation handling |
| Built-in overlay option | No | No | No |
| Thread safety | Single-threaded (UI thread) | Single-threaded (JS thread) | Requires lock for cross-thread result access |
| Overlay complexity | Low (Canvas API) | Medium (CSS-style transforms for lines) | Medium (SkiaSharp, clean API, orientation logic) |
Handle App Lifecycle and Camera State
Managing camera start/stop when the app goes to background is critical for resource management and battery life.
Flutter uses WidgetsBindingObserver:
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
switch (state) {
case AppLifecycleState.resumed:
start();
break;
case AppLifecycleState.inactive:
stop();
break;
default:
break;
}
}
React Native uses AppState:
const sub = AppState.addEventListener('change', nextState => {
if (nextState === 'active') {
camera.open();
cvr.startCapturing(EnumPresetTemplate.PT_READ_BARCODES);
} else if (nextState.match(/inactive|background/)) {
cvr.stopCapturing();
camera.close();
}
});
.NET MAUI (Android) uses WeakReferenceMessenger with lifecycle events registered in MauiProgram.cs:
.ConfigureLifecycleEvents(events =>
{
#if ANDROID
events.AddAndroid(android => android
.OnResume((activity) => NotifyPage("Resume"))
.OnStop((activity) => NotifyPage("Stop")));
#endif
})
Received in the page:
WeakReferenceMessenger.Default.Register<LifecycleEventMessage>(this, (r, message) =>
{
if (message.EventName == "Resume") enhancer?.Open();
else if (message.EventName == "Stop") enhancer?.Close();
});
Comparison: Flutter and React Native handle lifecycle directly in the scanning component. .NET MAUI’s messenger pattern decouples lifecycle detection from the page, which is cleaner for larger apps but adds indirection for simple use cases.
Compare SDK Architecture and API Patterns
| Feature | Flutter | React Native | .NET MAUI |
|---|---|---|---|
| Router creation | CaptureVisionRouter.instance (singleton) |
CaptureVisionRouter.getInstance() (singleton) |
new CaptureVisionRouter() (instance) |
| Camera creation | CameraEnhancer.instance (singleton) |
CameraEnhancer.getInstance() (singleton) |
new CameraEnhancer() (instance) |
| Result receiver | Callback object | Callback object | ICapturedResultReceiver interface |
| Camera view | CameraView(cameraEnhancer: ...) |
<CameraView ref={...} /> |
Dynamsoft.CameraEnhancer.Maui.CameraView() |
| Start capturing | startCapturing(template) async |
startCapturing(template) async |
StartCapturing(template, listener) |
| Overlay library | Built-in Canvas | Built-in Views | SkiaSharp (3rd-party) |
Flutter and React Native use singleton patterns, which simplifies access across widgets but can complicate testing. .NET MAUI uses instance construction, which is more testable and explicit but requires managing object lifetimes.
Compare Framework Metrics Side by Side
| Metric | Flutter | React Native | .NET MAUI |
|---|---|---|---|
| SDK packages | 1 | 2 | 1 + SkiaSharp |
| Camera setup | 3 lines | 3 lines | 5 lines |
| Overlay approach | CustomPainter | Absolutely-positioned Views | SkiaSharp |
| Coordinate mapping | Automatic (FittedBox) | Manual | Manual (with orientation handling) |
| Router pattern | Singleton | Singleton | Instance |
| State management | Provider (built in) | useState hooks | CommunityToolkit.Mvvm |
| Thread safety | UI thread only | JS thread only | Requires explicit locking |
Common Issues & Edge Cases
- Android portrait coordinate rotation: On Android in portrait mode, the Flutter SDK returns barcode coordinates in the camera’s native landscape orientation. The Flutter project includes a
rotate90barcode()function (currently a pass-through) and theFittedBox.coverapproach handles the visual mapping. If you render coordinates without accounting for orientation, bounding boxes will appear misaligned. - React Native overlay anti-aliasing: Drawing diagonal lines using absolutely-positioned
Viewcomponents produces jagged edges because CSS transforms onViewelements do not support anti-aliasing. For production apps requiring smooth overlays, consider a native canvas module or SVG library. - React Native coordinate scaling with cropping: The camera preview uses “fill” mode (similar to
aspectFill), which crops the camera frame. The overlay code must calculate both a scale factor and a crop offset to map barcode coordinates from camera space to screen space. Incorrect offsets cause overlays to drift away from actual barcode positions. - .NET MAUI cross-thread result access: The
OnDecodedBarcodesReceivedcallback fires on a background thread, but SkiaSharp’sInvalidateSurface()must update the UI. The sample usesMainThread.BeginInvokeOnMainThread()combined with alockobject to safely pass barcode results to the paint callback. Missing either the dispatch or the lock causes race conditions.
Conclusion
For mobile barcode scanning apps, Flutter offers the best balance of SDK simplicity, automatic coordinate mapping, and feature completeness — its dual-view mode, settings screen, and scan history make it the most production-ready starting point. React Native is the most concise — the entire scanner fits in a single component — but its View-based overlay approach has visual limitations due to lack of anti-aliasing. .NET MAUI provides the most professional overlay rendering with SkiaSharp, and uses instance-based patterns that scale well for larger apps, at the cost of manual coordinate mapping and explicit thread synchronization. Regardless of framework choice, all three leverage the same Dynamsoft Capture Vision decoding engine, so barcode recognition speed and accuracy are consistent across implementations. For your next step, explore the Dynamsoft Capture Vision documentation to configure barcode formats, enable MRZ scanning, or add document detection to any of these implementations.
Source Code
- Flutter: https://github.com/yushulx/flutter-barcode-mrz-document-scanner/tree/main/examples/dynamsoft_camera
- React Native: https://github.com/yushulx/android-camera-barcode-mrz-document-scanner/tree/main/examples/react-native-barcode-scanner
- .NET MAUI: https://github.com/yushulx/maui-barcode-mrz-document-scanner/tree/main/examples/WindowsDesktop