A NEW CameraView to Rule Them All


Every few years I talk myself into believing a camera control is a small job. Preview, a shutter button, maybe a torch toggle. Then the requests come in: scan this barcode, box that face, read this receipt, parse a driver’s license, apply a filter — and oh, it needs to work on iOS, Android, Windows, macOS, and the web. Suddenly the “small job” is five platform-specific pipelines held together with hope.

So I built the one I actually wanted: CameraView — a single control, one API surface, every platform Shiny targets, with a pluggable frame-analysis pipeline that turns the camera into whatever you need it to be.

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 and the control asks for camera permission itself — you just handle a denial through CameraError and flip IsActive for lifecycle. Capturing is a one-liner, and the active filter is baked into the JPEG so the photo matches what you saw:

this.Camera.CameraError += (_, e) => status = e.Message;   // "Camera permission denied"

CameraPhoto photo = await this.Camera.CapturePhotoAsync();                 // filtered JPEG bytes
await this.Camera.StartVideoRecordingAsync(new() { IncludeAudio = true });
CameraVideo video = await this.Camera.StopVideoRecordingAsync();           // a file path you can stream

One control, five hosts

This is the part I’m proudest of, because it’s the part that’s normally a lie. The same CameraView runs on:

  • Apple (iOS / Mac Catalyst) and macOS AppKit over AVFoundation
  • Android over CameraX
  • Windows over Media Capture
  • Blazor WebAssembly over getUserMedia / MediaRecorder / the browser’s BarcodeDetector

macOS AppKit matters more than it sounds — it means a real desktop camera surface, multiple webcams and all. Facing picks a lens by position, but when you’ve got several back lenses or a USB webcam plugged into a Mac, CameraId pins the exact device:

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

Filters that don’t lie

Set Filter and the look hits the live preview and the captured photo — no “preview looks one way, photo looks another” surprise:

this.Camera.Filter = CameraFilter.Noir;

Eleven of them ship — Mono, Noir, Sepia, Invert, Vivid, Cool, Warm, Fade, Chrome, Instant, Tonal. I’ll be honest about the edges: recorded video is the raw feed, the Android live preview filter wants API 31+, and Windows doesn’t do a live filter. Photos are filtered everywhere it counts.

The part that makes it interesting: frame analysis

A camera that only takes pictures is boring. The thing I really wanted was a pipeline I could plug things into. So CameraView takes a single Analyzer — barcode, face, motion, OCR, a document scanner — and streams frames to it off the UI thread with drop-on-busy back-pressure: one frame in flight at a time, so a slow analyzer never backs up the feed. You set it once and swap it freely while the camera is running.

Each analyzer talks back on two channels: a strongly-typed event for the result, and a set of bounding boxes to draw. The boxes draw the whole time the analyzer is running — but the result is gated on a scan. The analyzer stays quiet until you arm it with Camera.Scan() (or bind a shutter button to Camera.ScanCommand), then it hands you its next confirmed detection exactly once. That single-shot default is what you actually want most of the time — point, tap, get the code. Want it to keep going? Give the analyzer an OnDetected handler and return true:

var barcode = new BarcodeAnalyzer();                 // native — Apple Vision (iOS/macOS) / Android MLKit
barcode.BarcodesDetected += (_, e) =>
    status = $"{e.First.Format}: {e.First.Value}";    // e.Barcodes has every code in the frame
Camera.Analyzer = barcode;

Camera.Scan();                                        // arm — deliver the next decode, once
barcode.OnDetected = async e => { await Save(e.First.Value); return true; };   // …or keep scanning

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

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

Events come back marshalled to the UI thread, so you can touch the UI directly. Need to pause the analyzer without losing its bindings? Set IsEnabled = false; it resumes instantly, and while it’s off the camera acts like it has no analyzer (which, on Android, is how you free it up to record video). MotionAnalyzer even clusters movement into separate regions, so two people moving in two corners give you two boxes, not one giant box — exactly what you want for a security-cam view.

If you live in MVVM, you don’t touch events at all. The analyzer is a BindableObject and the camera’s content property is Analyzer, so declare it in XAML, bind its command, and wire a button to the camera’s ScanCommand:

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

Boxes are free: drop a CameraOverlayView over the camera in the same grid cell and it auto-subscribes. Coordinates are normalized, upright, and mirror-corrected, so you never wrestle with raw pixels — set an analyzer’s ScanWindow to a band and the overlay dims everything outside it and frames a viewfinder, and an OverlayProvider lets you restyle or suppress boxes per detection.

Documents: structured records, not strings

This is the part people don’t expect a camera library to do. The Shiny.Maui.Controls.Camera.Documents package hands you typed records — not a blob of OCR text. Each document type is its own analyzer with its own event, and every field is nullable so you only get what was actually found. Same arm-to-scan deal — Camera.Scan() captures the next read:

var invoice = new InvoiceAnalyzer();
invoice.DocumentDetected += (_, e) =>
    status = $"Invoice {e.Document.Number}{e.Document.Total}, {e.Document.Lines.Count} lines";
Camera.Analyzer = invoice;
Camera.Scan();

var license = new DriversLicenseAnalyzer();           // PDF417 + AAMVA, fully deterministic
license.DocumentDetected += (_, e) =>
    status = $"{e.Document.FirstName} {e.Document.LastName}";

What’s in the box:

  • Invoice — header fields plus order lines.
  • Receipt — line items, a per-tax breakdown, and subtotal / tip / discount / total, plus best-effort payment method and last-4.
  • Driver’s license — decoded from the back’s PDF417 barcode against the AAMVA standard. No ML, no guessing. US states and the Canadian provinces that emit an AAMVA PDF417 (BC, AB, SK, MB, NS, NB, PEI, NL) — dates flip to Canadian order automatically and the province comes back as Jurisdiction. (Ontario and Quebec don’t print a PDF417, so those need a custom OCR parser — I’d rather tell you than have it silently fail.)
  • Health card — OCR tuned for Canadian cards; it sniffs the province from on-card keywords and applies the right number format (RAMQ, OHIP, BC PHN, AHCIP…).
  • Credit card — brand and number validity from IIN + Luhn are deterministic; name and expiry are best-effort OCR. The CVV’s on the back, so a front scan leaves it null.
  • Passport — parsed from the MRZ, the ICAO TD3 <<< lines. Deterministic.

The deterministic parsers (license, passport, credit-card number) are exactly that. The rule-based ones (invoice, receipt, health card) are best-effort — and when “best-effort” isn’t enough, you swap in your own parser without writing a new analyzer:

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

And if I don’t ship the document you need — a business card, a shipping label, a lab form — you derive from DocumentAnalyzer<T>, supply an IDocumentParser<T>, and the base class does the OCR, raises the typed event, and draws the boxes. You write the record and the parse rules and nothing else. Because the parser is just an interface, an LLM- or service-backed parser drops right in — the analyzer only keeps one parse in flight, so a slow remote call won’t pile up frames.

A couple of honest edges

I’d rather you hear these from me than from a bug report:

  • Permissions are yours to declareNSCameraUsageDescription on Apple (skip it and iOS crashes instantly), CAMERA on Android, webcam on Windows. Blazor needs HTTPS or localhost.
  • Android won’t record video while an analyzer is enabled — CameraX caps concurrent use-cases, so it’s analysis or video. Clear the Analyzer (or set IsEnabled = false) to record; the call throws a clear error if you forget.
  • The native analyzers run on devices, not bare net10.0MotionAnalyzer is pure-managed and goes everywhere, but barcode/face/OCR/documents lean on Apple Vision, Android MLKit, and the Windows ML APIs, so they light up on the real platforms.
  • Don’t gate startup on RequestPermissionAsync() in OnAppearing — it returns false before the handler is wired up and looks like a denial. Trust the auto-start and CameraError.

That’s CameraView: preview, capture, filters, and a real analysis pipeline — barcodes to passports — on every platform I ship to, from one control. Add .UseShinyCamera() and go build something. The full reference lives in the Shiny docs.


comments powered by Disqus