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

Top comments (0)