Barcode Scanner
Camera-based barcode scanning with an animated reticle overlay and a bottom-sheet result card. Used in Scanflo for building physical media catalogs.
Overview
Two cooperating views form the scanning experience:
- →ScanReticleOverlay — full-screen layer on top of the camera preview. Dims the surroundings with a rectangular cutout window, draws corner brackets, and animates a sweep line while scanning.
- →CaptureCardView — bottom-sheet shown on detect. Runs a background metadata lookup and cycles through
loading → resolved → savedorfailed, letting the user edit before saving to SwiftData.
Scan Reticle
States
Scanning — white corner brackets + animated sweep line
Detected — brackets turn green (0.25 s ease-in-out), sweep line removed
Implementation notes
- →
ScanCutoutShapeuses even-odd fill so SwiftUI never draws inside the window - →
Canvasdraws all four corner brackets in a single pass - → Sweep line uses
.repeatForever(autoreverses: true)— setisAnimating = falseon detect to stop it
Capture Card
Lookup states
.loadingProgressView spinner; title field disabled; shown immediately on detect
.resolvedTitle populated from API; user can edit; media-type icon badge shown
.failedError icon; manual title entry required; still saveable
- → Uses
.regularMaterialto inherit camera feed colour - → Lookup runs in
.task— auto-cancelled if the sheet is dismissed - → Save button disabled while
title.isEmptyto avoid orphaned records
Code
SwiftUI tabs show ScanReticleOverlay (iOS) and CaptureCardView (macOS tab). React and HTML tabs show the equivalent web implementation using the BarcodeDetector API.
BarcodeScanner.tsx
tsx
"use client";
import { useEffect, useRef, useState } from "react";
// Web barcode scanner using the BarcodeDetector API (Chrome/Edge 83+)
// or a library like @zxing/library for broader browser support.
type LookupPhase = "idle" | "scanning" | "resolved" | "failed";
export function BarcodeScanner() {
const videoRef = useRef<HTMLVideoElement>(null);
const [phase, setPhase] = useState<LookupPhase>("idle");
const [result, setResult] = useState("");
const [title, setTitle] = useState("");
useEffect(() => {
if (!("BarcodeDetector" in window)) return;
let stream: MediaStream;
let raf: number;
navigator.mediaDevices.getUserMedia({ video: { facingMode: "environment" } }).then((s) => {
stream = s;
if (videoRef.current) videoRef.current.srcObject = s;
setPhase("scanning");
const detector = new (window as any).BarcodeDetector({ formats: ["ean_13", "qr_code", "upc_a"] });
const scan = async () => {
if (videoRef.current?.readyState === 4) {
const codes = await detector.detect(videoRef.current);
if (codes.length > 0) {
setResult(codes[0].rawValue);
setPhase("resolved");
lookup(codes[0].rawValue);
return; // stop scanning
}
}
raf = requestAnimationFrame(scan);
};
raf = requestAnimationFrame(scan);
});
return () => { stream?.getTracks().forEach((t) => t.stop()); cancelAnimationFrame(raf); };
}, []);
const lookup = async (barcode: string) => {
try {
const res = await fetch(`https://openlibrary.org/api/books?bibkeys=ISBN:${barcode}&format=json&jscmd=data`);
const data = await res.json();
const book = data[`ISBN:${barcode}`];
setTitle(book?.title ?? "");
} catch {
setPhase("failed");
}
};
return (
<div style={{ maxWidth: 340, margin: "0 auto" }}>
{/* Camera viewport */}
<div style={{ position: "relative", aspectRatio: "4/3", borderRadius: 16, overflow: "hidden", background: "#000" }}>
<video ref={videoRef} autoPlay playsInline muted style={{ width: "100%", height: "100%", objectFit: "cover" }} />
{/* Reticle overlay */}
<div style={{
position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center",
background: "rgba(0,0,0,0.45)",
WebkitMaskImage: "radial-gradient(150px 150px at 50% 50%, transparent 99%, black 100%)",
maskImage: "radial-gradient(150px 150px at 50% 50%, transparent 99%, black 100%)",
}} />
<p style={{ position: "absolute", bottom: 12, left: 0, right: 0, textAlign: "center", color: "rgba(255,255,255,0.7)", fontSize: 13 }}>
{phase === "scanning" ? "Point at barcode" : phase === "resolved" ? "Found!" : "Starting camera…"}
</p>
</div>
{/* Result card */}
{phase === "resolved" && (
<div style={{ marginTop: 12, padding: "16px", borderRadius: 12, background: "var(--surface)", border: "1px solid var(--border)" }}>
<p style={{ fontSize: 12, color: "var(--text-tertiary)", textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: 8 }}>Title</p>
<input value={title} onChange={(e) => setTitle(e.target.value)} style={{ width: "100%", padding: "8px 12px", borderRadius: 8, border: "1px solid var(--border)", background: "transparent", color: "var(--text-primary)", fontSize: 14 }} />
<button style={{ marginTop: 12, width: "100%", padding: "10px", borderRadius: 8, background: "var(--accent)", color: "#fff", fontWeight: 600, border: "none", cursor: "pointer" }}>
Save to Collection
</button>
</div>
)}
</div>
);
}Accessibility
| Concern | Approach |
|---|---|
| Camera permission | Request only on first scan; include NSCameraUsageDescription reason string |
| VoiceOver | Reticle overlay sets .accessibilityHidden(true); card announces state changes via .accessibilityValue |
| Reduce Motion | Sweep-line animation checks @Environment(\.accessibilityReduceMotion) |
| Dynamic Type | All card text uses system font styles — scales automatically |