QRScanner

A camera view that reads QR codes and hands over their text. A scanner that fills the screen is a destination of its own — Router.push(scanner), its buttons in scanner.widgets(...) — and goes among a screen's children only as a part of that screen. To take a photo, use CameraView.

  • scan a QR code with the camera and get the text or link inside it
  • open a full-screen scanner with a cancel button over the camera
  • go back and stop the camera once a code is read
  • hide the scan button where the device can't scan (QRScanner.isSupported)
  • tell the user when the camera permission was refused
  • show a small live scanner inside a card on a screen

Platforms: iOS, Android; the web and the web player only in a browser with BarcodeDetector (not Safari or Firefox); not on desktop — check QRScanner.isSupported.

At a glance

TypeScript
// app.json: "plugins": { "qr-scanner": "*" }
const result = signal('Nothing scanned yet')

const scan = async () => {
  const scanner = QRScanner().onScan(text => {
    if (text === null) return                    // the code left the view
    result.value = text                          // a link, a ticket id — any text
    Router.pop()                                 // back to the home screen; a phone's camera stops
  })
  scanner.widgets(
    UIWidget(UIButton(UIText('Cancel').style({ color: 'white' })).onClick(() => Router.pop()))
      .style({ bottom: 'comfort-bottom', left: 0, right: 0, alignItems: 'center' }),
  )
  try {
    await Router.push(scanner)                   // asks for the camera, then shows the scanner
  } catch (e) {
    toast(e.message)                             // refused, or no scanner on this host
  }
}

Router.init(UIScreen(
  UIText(() => result.value).style({ color: 'white' }),
  QRScanner.isSupported
    ? UIButton(UIText('Scan a QR code').style({ color: 'white' })).onClick(scan)
    : UIText('This device cannot scan QR codes').style({ color: 'gray' }),
).style({ bgColor: 'black', p: 20, pt: 'safe-top', gap: 16 }))

Add the QR scanner plugin to the app: app.json

The scanner is the qr-scanner plugin: list it in the project's app.json — "plugins": { "qr-scanner": "*" }. The list is one for every platform (the bundle's // plugins: header): a plugin it does not name is not there — in a shell built from the project, on the web and in the web player alike — and the compile warns about one the app uses and does not list. The le.codes viewer app has the plugin whatever the list says.

Check that the device can scan QR codes: QRScanner.isSupported

TypeScript
QRScanner.isSupported: boolean     // this host has the scanner: check it before showing a Scan button

To know whether this device can scan QR codes, check QRScanner.isSupported before showing a Scan button, as the At a glance screen does. It is false on desktop, in a browser without BarcodeDetector or the camera API (getUserMedia, given to https pages and localhost), and in an app whose app.json does not list the plugin (the le.codes viewer app has it either way); there Router.push(scanner) and scanner.open() reject at once, before any permission prompt.

In headless tests (lecodes render / lecodes test) isSupported is false unless the run names the view: --native-view qrScanner draws the scanner as a placeholder box that opens, carries its widgets and goes back like the real one, and never scans.

QR codes only — no other barcodes

The scanner reads QR codes only, on every host: a product barcode (EAN, UPC), Code 128, Data Matrix or PDF417 is not decoded, and there is no option for other formats. With several QR codes in view, one of them is reported.

Get the code's text: onScan

TypeScript
scanner.onScan(callback: (text: string | null) => void): this

onScan hands over what the QR code in view says — a link, a ticket id, any text — as a string, and null once no code has been in view for a moment (under a second on a phone, 1.5 s on the web). It fires when that changes, not for every camera frame: a code that stays in view is reported once, and again only after it has left the view and come back. Go back with Router.pop() on the first code the app can use.

A scanner shown a second time remembers the code it reported last and may not report that same code when it is scanned again — so the examples here make a new QRScanner() for each scan, and the same code scanned twice in a row is reported both times.

Open a full-screen scanner with a Cancel button over the camera: Router.push

TypeScript
Router.push(scanner): Promise<void>                 // ask for the camera, then show the scanner
scanner.widgets(...widgets: UIWidget[]): QRScanner  // a Cancel button and hints over the camera

To show the QR scanner as a screen with a Cancel button over the camera, push it: Router.push(scanner) opens it as a destination of its own, the Cancel button and hints go in scanner.widgets(...), and the back button and the back swipe go back by themselves. Widgets are laid out over the camera while it is shown and leave with it; a touch outside a widget reaches the camera (UIWidget). The At a glance block is the whole flow.

Router.push(scanner) prepares before it shows: the current screen stays while the camera permission is asked, and the scanner replaces it once the permission is granted. The promise rejects, and nothing is pushed, when the permission is refused, when the host has no scanner, or when another navigation happened meanwhile.

Go back and stop the camera: Router.pop

TypeScript
Router.pop()                                        // back to the screen under the scanner
scanner.open(opts?: { transition }): Promise<void>  // show it without the router
scanner.close()                                     // take it off screen; nothing takes its place

Once a code is read — or the user taps Cancel — go back with Router.pop(), as the At a glance block does: the screen under the scanner returns, and on iOS and Android the scanner's camera stops (it starts again when the scanner comes back). It stops the same way when the scanner is covered by another destination or closed. On the web the camera can start as soon as QRScanner() is made and stays on after the scanner leaves the screen: make the scanner when the user asks to scan, not when the app starts.

scanner.open() shows the scanner without the router (an active router is suspended until Router.restore()), and scanner.close() takes it off screen with nothing in its place — after a Router.push, go back with Router.pop(), never close().

When the camera permission is refused

Router.push(scanner) asks for the camera permission and rejects when the user refuses it; the current screen stays. A refused permission is turned back on in the system settings, so offer them: permissions.query('camera') says 'denied' then, and permissions.openSettings() opens the app's page there on iOS and Android (on the web it does nothing — Permissions).

TypeScript
const denied = signal(false)

const scan = async () => {
  const scanner = QRScanner().onScan(text => { if (text) { toast(text); Router.pop() } })
  try {
    await Router.push(scanner)                          // asks for the camera first
  } catch (e) {
    denied.value = (await permissions.query('camera')) === 'denied'
    if (!denied.value) toast(e.message)                 // no scanner here, or another screen opened
  }
}

Router.init(UIScreen(
  UIButton(UIText('Scan').style({ color: 'white' })).onClick(scan),
  UIButton(UIText('Allow the camera in Settings').style({ color: 'white' }))
    .style({ display: () => (denied.value ? 'flex' : 'none') })
    .onClick(() => permissions.openSettings()),
).style({ bgColor: 'black', p: 20, pt: 'safe-top', gap: 12 }))

A live scanner inside a screen

When scanning is a part of a screen — a scan window in a card above a form — put the scanner among the screen's children and size it with styles, like an image. An embedded scanner does not ask for the camera permission itself (only Router.push and open() do; on the web the browser asks when the scanner's camera starts): ask with permissions.request('camera') when the user starts scanning, and add the scanner once it is 'granted'.

TypeScript
const code = signal('')
const card = UIColumn().style({ height: 240, borderRadius: 12, overflow: 'hidden', bgColor: '#1c1c1e' })

const startScanning = async () => {
  if (!QRScanner.isSupported) return toast('This device cannot scan QR codes')
  if ((await permissions.request('camera')) !== 'granted') return toast('No camera access')
  card.setContent([QRScanner().style({ flexGrow: 1 }).onScan(text => { if (text) code.value = text })])
}

UIScreen(
  card,
  UIText(() => code.value || 'Point the camera at the table code').style({ color: 'white' }),
  UIButton(UIText('Start the camera').style({ color: 'white' })).onClick(startScanning),
).style({ bgColor: 'black', p: 20, pt: 'safe-top', gap: 16 }).open()

Pitfalls

TypeScript
const scanner = QRScanner()
// ✗ a full-screen scanner among a screen's children, its buttons absolute over it: no permission step
UIScreen(scanner.style({ flexGrow: 1 }), UIButton(UIText('Cancel')).style({ position: 'absolute', bottom: 40 }))
// ✓ a destination of its own, its buttons in its widgets
Router.push(scanner.widgets(UIWidget(UIButton(UIText('Cancel')).onClick(() => Router.pop())).style({ bottom: 40 })))

// ✗ close() after a push: the scanner goes, nothing takes its place, the stack still holds it
scanner.onScan(text => { if (text) scanner.close() })
// ✓ go back the way it came
scanner.onScan(text => { if (text) Router.pop() })

See also