Barcode scanner

Add a button that opens the native camera scanner. On a successful scan the value fills a field on your page and a ruby-native:scan event fires, so it works the same in plain Rails, Turbo, and Inertia.

Works in both Normal Mode and Advanced Mode, on iOS and Android.

#Basic usage

Put a field and a scan button in a form. The scanned value fills the field and fires input and change, so any listener notices.

<%# app/views/lookups/new.html.erb %>
<%= form_with url: lookups_path do |form| %>
  <%= form.text_field :isbn, id: "isbn" %>
  <%= native_scan_button_tag "Scan", target: "#isbn" %>
<% end %>
<button onClick={() => RubyNative.scan({ event: "scanned" })}>Scan</button>

useEffect(() => {
  const onScan = (e) => {
    if (e.detail.status === "ok") setIsbn(e.detail.value)
  }
  document.addEventListener("scanned", onScan)
  return () => document.removeEventListener("scanned", onScan)
}, [])
<input id="isbn" name="isbn">
<button onclick="RubyNative.scan({ target: '#isbn' })">Scan</button>

Add submit: true to submit the field's form automatically after a successful scan.

Inertia apps should read Building a scan button before wiring this up. target and submit behave differently under React.

#Result and errors

Every scan dispatches one ruby-native:scan event, or your event: name. Its detail is one of:

detail.status Meaning
ok A code was read. detail.value is the string, detail.format the matched symbology.
cancelled The user tapped Cancel.
error The device can't scan or camera access was refused. detail.reason is unsupported or denied. Reveal a manual-entry field.

A Simulator or emulator always reports unsupported. Its camera can't resolve a real barcode, so scanning is only testable on hardware. Handle unsupported and you'll see your fallback there instead of a viewfinder that never reads anything.

#Building a scan button

There's no NativeScanButton component, because a scan button is an ordinary button plus a listener and your app owns both. RubyNative.scan() is on window inside the app, so wrap it in whatever your framework calls a reusable piece.

Give each call site its own event name so two scanners on one page don't hear each other.

// app/javascript/hooks/useNativeScan.js
import { useCallback, useEffect, useRef } from "react"

export function useNativeScan(event, onResult) {
  const latest = useRef(onResult)
  latest.current = onResult

  useEffect(() => {
    const listener = (e) => latest.current(e.detail)
    document.addEventListener(event, listener)
    return () => document.removeEventListener(event, listener)
  }, [event])

  return useCallback(
    (options = {}) => window.RubyNative?.scan({ ...options, event }),
    [event]
  )
}
import { useState } from "react"
import { useNativeScan } from "@/hooks/useNativeScan"

export default function NewLookup() {
  const [isbn, setIsbn] = useState("")
  const [manual, setManual] = useState(false)

  const scan = useNativeScan("lookup:scan", (result) => {
    if (result.status === "ok") setIsbn(result.value)
    if (result.status === "error") setManual(true)
  })

  return (
    <>
      <input value={isbn} onChange={(e) => setIsbn(e.target.value)} />
      <button type="button" onClick={() => scan({ formats: "ean13" })}>Scan</button>
      {manual && <p>Enter the ISBN by hand.</p>}
    </>
  )
}
// app/javascript/composables/useNativeScan.js
import { onMounted, onUnmounted } from "vue"

export function useNativeScan(event, onResult) {
  const listener = (e) => onResult(e.detail)
  onMounted(() => document.addEventListener(event, listener))
  onUnmounted(() => document.removeEventListener(event, listener))

  return (options = {}) => window.RubyNative?.scan({ ...options, event })
}
<script setup>
import { ref } from "vue"
import { useNativeScan } from "@/composables/useNativeScan"

const isbn = ref("")
const manual = ref(false)

const scan = useNativeScan("lookup:scan", (result) => {
  if (result.status === "ok") isbn.value = result.value
  if (result.status === "error") manual.value = true
})
</script>

<template>
  <input v-model="isbn">
  <button type="button" @click="scan({ formats: 'ean13' })">Scan</button>
  <p v-if="manual">Enter the ISBN by hand.</p>
</template>

Three things to know:

Skip target in a React component. It fills the field by setting element.value and dispatching input, which is right for a plain form or a Vue v-model, but React installs its own setter on the input and tracks the last value it wrote. A value written around it looks like no change, so onChange never fires and the next render restores the old one. submit: true depends on the same fill, so it goes too. Listen for the event and set state yourself, as above. An uncontrolled input with defaultValue is fine.

Use type="button". A bare <button> inside a form submits it, and the tap would navigate away before the scan result arrives.

Nothing happens on the web. window.RubyNative is undefined in a browser, so ?. makes the button a no-op there. Hide it with nativePlatform() if a web visitor shouldn't see it.

#Options

Option Type Description
target string CSS selector of the input to fill on a successful scan.
event string Custom event name to dispatch. Defaults to ruby-native:scan.
submit boolean Submit the filled field's form after scanning. Defaults to false.
formats string or array Barcode types to read, as neutral names. Defaults to QR plus common retail codes.

Supported formats: qr, ean13, ean8, upca, upce, code128, code39, code93, pdf417, aztec, datamatrix. Each platform reads the ones it supports and ignores the rest.

One quirk: iOS reads UPC-A barcodes as EAN-13 (a UPC-A code is an EAN-13 with a leading zero), so the same product reports format: "ean13" on iOS and format: "upca" on Android. Branch on detail.value, not detail.format, when both platforms matter.

#Camera permission

The scanner needs a camera usage description, set on the Permissions page of your app settings. Without one, scanning reports an error rather than crashing.