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