Skip to content
Client v5: BLE, BLE Hosting, HTTP, Jobs - Linux, MacOS, & Blazor Support! Full AOT, RX on BLE only & MANY other features! Power up!

A NEW CameraView to Rule Them All

NuGet package Shiny.Maui.Controls.Camera NuGet package Shiny.Blazor.Controls.Camera

A camera control sounds simple until you ship one. You want live preview, zoom, torch, lens selection, photo and video capture — fine. Then you want to scan a barcode, box a face, read a receipt, parse a driver’s license, apply a live filter, and have it all run on iOS, Android, Windows, macOS AppKit, and Blazor WebAssembly without rewriting the pipeline five times.

That’s what CameraView is: one control, one API surface, every platform — with a pluggable frame-analysis pipeline bolted on.

Terminal window
dotnet add package Shiny.Maui.Controls.Camera
builder
.UseShinyControls()
.UseShinyCamera();
xmlns:cam="http://shiny.net/maui/camera"
<cam:CameraView x:Name="Camera"
Facing="Back"
ScaleMode="AspectFill"
Filter="None" />

The preview auto-starts (IsActive defaults true) and the control requests camera permission itself — you handle a denial (or any error) through CameraError, and toggle IsActive for lifecycle:

this.Camera.CameraError += (_, e) => status = e.Message; // e.g. "Camera permission denied"
protected override void OnDisappearing()
{
base.OnDisappearing();
this.Camera.IsActive = false; // release the camera off-screen
}
// JPEG bytes — the current Filter is baked in, so the photo matches the preview
CameraPhoto photo = await this.Camera.CapturePhotoAsync();
// Video (audio optional). Recorded video records the raw, unfiltered feed.
await this.Camera.StartVideoRecordingAsync(new VideoRecordingOptions { IncludeAudio = true });
CameraVideo video = await this.Camera.StopVideoRecordingAsync(); // a file path you can stream

The same CameraView lights up on five hosts, each over the platform’s native stack:

HostBackend
Apple (iOS / Mac Catalyst)AVFoundation
macOS (AppKit)AVFoundation over AppKit
AndroidCameraX (min SDK 23)
WindowsMedia Capture
Blazor WebAssemblygetUserMedia / MediaRecorder / BarcodeDetector

Facing picks a lens by position (Back / Front / External); CameraId pins an exact device — which is how you choose between multiple back lenses on a phone or a specific USB webcam on macOS:

IReadOnlyList<CameraInfo> cameras = await this.Camera.GetAvailableCamerasAsync();
this.Camera.CameraId = cameras.First(c => c.Name.Contains("USB")).Id;

Set Filter and the look is applied to the live preview and baked into captured photos, so what you see is what you get:

this.Camera.Filter = CameraFilter.Noir;

Eleven filters ship — Mono, Noir, Sepia, Invert, Vivid, Cool, Warm, Fade, Chrome, Instant, Tonal — plus None. A couple of honest platform caveats: recorded video records the unfiltered feed, the Android live-preview filter needs API 31+ (it uses RenderEffect; captured photos are still filtered on older Android), and Windows has no live filter.

This is where CameraView stops being “a camera” and becomes a platform. Assign an IFrameAnalyzer to Camera.Analyzer and the pipeline streams frames off the UI thread with drop-on-busy back-pressure (only one frame in flight — it never backs up). It’s one analyzer at a time, and you set or swap it freely while the camera is running.

Every analyzer has two channels:

  1. A strongly-typed event carrying the semantic result (the barcodes, the faces, the recognized text, the structured document).
  2. The return value of its analysis: styled OverlayBoxes to draw. A returned set persists until the analyzer returns a different set (replace) or null (clear).

The boxes draw live the whole time the analyzer runs; the typed event is gated on a scan. The analyzer stays quiet until you arm it — Camera.Scan(), or bind a shutter button / Fab to Camera.ScanCommand — and then it delivers its next confirmed detection exactly once. Single-shot is the default, which is usually what you want (“point, tap, get the code”). To keep scanning, give the analyzer an OnDetected handler that returns true:

var barcode = new BarcodeAnalyzer(); // native — Apple Vision (iOS/macOS) / Android MLKit
barcode.BarcodesDetected += (_, e) =>
status = $"{e.First.Format}: {e.First.Value}"; // e.Barcodes holds every code in the frame
Camera.Analyzer = barcode;
Camera.Scan(); // arm — deliver the next decode, once
// continuous: stay armed as long as the handler returns true
barcode.OnDetected = async e => { await Save(e.First.Value); return true; };

Swapping the analyzer is just an assignment — the running session picks it up:

Camera.Analyzer = new FaceAnalyzer(); // Apple Vision / Android MLKit / Windows.FaceAnalysis
Camera.Analyzer = new MotionAnalyzer(); // pure-managed frame differencing — every platform

Events are marshalled to the UI thread for you, so handlers can touch UI directly.

To switch the analyzer off without dropping it (keeping its bindings and internal state), set FrameAnalyzer.IsEnabled = false — it resumes instantly when re-enabled, and while disabled the camera behaves as if it had no analyzer (so on Android you can record video). That’s distinct from ShowBoundingBox = false (run and deliver, just draw nothing).

MVVM: declare the analyzer in XAML, bind commands

Section titled “MVVM: declare the analyzer in XAML, bind commands”

Analyzers are BindableObjects, and CameraView’s content property is Analyzer, so you can declare it inline under the one cam: prefix and bind its …Command to your ViewModel — fired on the UI thread with the same args as the event. Bind a shutter button to the camera’s ScanCommand to arm it:

<Grid>
<cam:CameraView x:Name="Camera" Facing="Back" Filter="Chrome">
<cam:BarcodeAnalyzer BarcodesDetectedCommand="{Binding ScanResultCommand}" />
</cam:CameraView>
<Button Text="Scan"
Command="{Binding Source={x:Reference Camera}, Path=ScanCommand}"
VerticalOptions="End" />
</Grid>

Drop a CameraOverlayView over the CameraView in the same Grid cell — it auto-subscribes and redraws:

<Grid>
<cam:CameraView x:Name="Camera" ScaleMode="AspectFill" />
<cam:CameraOverlayView Camera="{x:Reference Camera}" InputTransparent="True" />
</Grid>

OverlayBox.Rect is normalized (0..1), upright, and mirror-corrected — the overlay maps it into view space, so you never deal in raw pixels. Set the analyzer’s ScanWindow to a normalized rectangle to restrict detection to a band: only detections centered inside it are reported and drawn, and the overlay dims everything outside it and frames a viewfinder reticle. Want custom styling? Each analyzer exposes an OverlayProvider to return exactly the boxes you want (or null for none):

barcode.OverlayProvider = e => e.First.Value.StartsWith("OK")
? [ new OverlayBox(e.First.BoundingBox, Colors.Lime, e.First.Value) ]
: null;

MotionAnalyzer is a nice example of the pipeline’s range: it clusters movement into separate regions, so motion in two spots yields two boxes rather than one box spanning both — handy for a security-cam view. Tune it with PixelThreshold / AreaThreshold, SampleStride, and GridColumns / CellThreshold.

Document analyzers — structured data, not just text

Section titled “Document analyzers — structured data, not just text”

The Shiny.Maui.Controls.Camera.Documents package turns the camera into a scanner that hands you strongly-typed records, not raw strings. Every document type is its own analyzer with its own typed DocumentDetected event, and every payload is a record with nullable fields — only what was actually found is set. Like every analyzer, delivery is arm-gated, so call Camera.Scan() (or bind ScanCommand) to capture the next read.

Terminal window
dotnet add package Shiny.Maui.Controls.Camera.Documents
var invoice = new InvoiceAnalyzer();
invoice.DocumentDetected += (_, e) =>
{
Invoice doc = e.Document;
status = $"Invoice {doc.Number} — total {doc.Total}, {doc.Lines.Count} line(s)";
};
Camera.Analyzer = invoice;
Camera.Scan(); // arm — delivers the next parsed invoice
var license = new DriversLicenseAnalyzer(); // PDF417 + AAMVA — deterministic, no ML
license.DocumentDetected += (_, e) =>
status = $"{e.Document.FirstName} {e.Document.LastName}{e.Document.Number}";

What ships:

  • InvoiceAnalyzerInvoice with order lines in .Lines.
  • ReceiptAnalyzerReceipt with purchased line items (.Lines), a per-tax breakdown (.Taxes), and subtotal / tip / discount / total, plus best-effort payment method, last-4, currency, date/time.
  • DriversLicenseAnalyzerDriversLicense, decoded from the PDF417 barcode on the back and parsed against the AAMVA standard — fully deterministic. Works for US states and the Canadian provinces that emit an AAMVA PDF417 (BC, AB, SK, MB, NS, NB, PEI, NL); dates auto-switch to Canadian CCYYMMDD order and the province surfaces as Jurisdiction. (Ontario and Quebec licences carry no PDF417, so they don’t scan — use a custom OCR parser for those.)
  • HealthCardAnalyzerHealthCard, OCR tuned for Canadian cards: it detects the issuing province from on-card keywords and applies that province’s number format — Quebec/RAMQ, Ontario/OHIP, BC PHN, Alberta/AHCIP, etc. — surfacing Province and Issuer.
  • CreditCardAnalyzerCreditCard: brand (Visa/Mastercard/Amex/…) and number validity from the IIN prefix + Luhn are deterministic; name/expiry are best-effort OCR. The CVV lives on the back, so a front scan almost always leaves it null.
  • PassportAnalyzerPassport, parsed from the MRZ (the two <<< lines, ICAO TD3) — deterministic.

The deterministic ones (driver’s license PDF417/AAMVA, passport MRZ, credit-card IIN/Luhn) are exactly that — no ML guesswork. The rule-based ones (invoice, receipt, health card) are best-effort, and when you need more accuracy you swap in your own parser without writing a new analyzer:

new InvoiceAnalyzer(new MyInvoiceParser()); // MyInvoiceParser : IDocumentParser<Invoice>

A document analyzer accumulates reads across a few frames before it commits (AccumulationFrames, default 5) — handy when one frame catches half the card and the next catches the rest — and resets after a run of empty frames (ResetAfterEmptyFrames).

Need to scan something we don’t ship — a business card, a shipping label, a lab form? Derive from DocumentAnalyzer<TDocument> and supply an IDocumentParser<TDocument>. The base class runs the shared OCR recognizer, calls your parser, raises the typed event (and command) on the UI thread, draws the boxes, and honours arming / IsEnabled / ShowBoundingBox / OverlayProvider. You write the payload and the parse rules — nothing else:

public record BusinessCard(string? Name, string? Company, string? Email, string? Phone, IReadOnlyList<DocumentField> Fields);
public sealed partial class BusinessCardParser : IDocumentParser<BusinessCard>
{
[GeneratedRegex(@"[\w.+-]+@[\w-]+\.[\w.-]+")] private static partial Regex Email();
public bool TryParse(IReadOnlyList<RecognizedText> text, out BusinessCard document, out IReadOnlyList<OverlayBox> boxes)
{
document = null!; boxes = [];
var emailLine = text.FirstOrDefault(t => Email().IsMatch(t.Text));
if (emailLine is null) return false; // cheap "is this my document?" check — bail fast
var email = Email().Match(emailLine.Text).Value;
var name = text.FirstOrDefault()?.Text;
var fields = new List<DocumentField> { new("Name", name, text.FirstOrDefault()?.BoundingBox), new("Email", email, emailLine.BoundingBox) };
document = new BusinessCard(name, null, email, null, fields);
boxes = fields.Where(f => f.Bounds is not null).Select(f => new OverlayBox(f.Bounds!.Value, Colors.Lime, f.Label)).ToList();
return true;
}
}
public sealed class BusinessCardAnalyzer : DocumentAnalyzer<BusinessCard>
{
public BusinessCardAnalyzer() : base(new BusinessCardParser()) { }
public override string Id => "myapp.camera.businesscard";
}

Because the parser is just an interface, an LLM- or service-backed parser is perfectly fine — the analyzer drops frames while one parse is in flight, so a slow remote call won’t pile up.

AnalyzerPackageEnginePlatforms
BarcodeAnalyzer.Camera.BarcodeApple Vision / Android MLKit (native)iOS, Android, macOS
FaceAnalyzer.Camera.FaceVision / MLKit / Windows.FaceAnalysisiOS, Android, Windows, macOS
MotionAnalyzer.Camera.Motionmanaged frame differencingall
OcrAnalyzer.Camera.Ocrnative OCR (Vision / MLKit / Windows.Media.Ocr)iOS, Android, Windows, macOS
Document analyzers.Camera.DocumentsOCR + rules / native PDF417 / MRZiOS, Android, Windows, macOS

Add only the packages you need. MotionAnalyzer is pure-managed and runs everywhere; the barcode / face / OCR / document analyzers ride native ML/vision, so they produce results on the device platforms (not bare net10.0) — barcode and the PDF417-based driver’s license are iOS / macOS / Android.

The Blazor component mirrors the MAUI control with the same concepts in component clothing — including the single Analyzer and the arm-to-scan model:

<CameraView @ref="camera"
Facing="CameraFacing.Back"
Analyzer="barcode"
ShowOverlay="true"
Filter="filter"
BarcodesDetected="OnBarcodes"
OnError="m => status = m"
Style="width:100%;height:100%;" />
@code {
CameraView? camera;
readonly BarcodeAnalyzer barcode = new();
CameraFilter filter = CameraFilter.None;
// await the next code (the gated equivalent of MAUI's Scan)
async Task Scan() { var b = await camera!.RequestBarcodeAsync(); status = $"{b.Format}: {b.Value}"; }
void OnBarcodes(IReadOnlyList<CameraBarcode> codes) => status = $"{codes[0].Format}: {codes[0].Value}";
async Task Photo() { var jpeg = await camera!.CapturePhotoAsync(); } // byte[], filtered to match preview
async Task Rec() { await camera!.StartRecordingAsync(includeAudio: true); }
async Task Stop() { var webm = await camera!.StopRecordingAsync(); } // byte[] WebM
}

Preview, zoom, filters (CSS), photo, and video capture work in every browser. Barcode scanning uses the browser’s native BarcodeDetector (Chromium today): assign a BarcodeAnalyzer and await RequestBarcodeAsync() for the next code, or handle BarcodesDetected for every code in a frame while a request is outstanding. On Firefox/Safari OnError fires once and preview continues, so feature-detect if you need universal coverage. Face/motion/OCR/document analyzers are MAUI-native.

  • Permissions are yours to declareNSCameraUsageDescription on Apple (omitting it crashes iOS instantly), the CAMERA permission on Android, the webcam capability on Windows. getUserMedia needs a secure context (HTTPS or localhost).
  • Android: video vs. analyzers — CameraX caps concurrent use-cases, so the camera binds either image analysis (while an analyzer is enabled) or video capture. Clear Analyzer or set IsEnabled = false to record; StartVideoRecordingAsync throws a clear error otherwise.
  • Don’t gate startup on RequestPermissionAsync() — it routes through the handler and returns false before the view is connected (e.g. in OnAppearing on first show), which looks like a denial. Rely on auto-start + CameraError.

Grab the packages, call .UseShinyCamera(), and you’ve got a real camera — preview to structured documents — on every platform you ship to. Full docs are in the CameraView guide.