
【Expo】expo-cameraのバーコード読み取りで枠外の誤検出と多重発火を防ぐ

はじめに
こんにちは、株式会社TOKOSの鳥井です!
今回はExpoのexpo-cameraでバーコードを読み取る方法についてまとめてみました💪
最近開発しているモバイルアプリで、商品のバーコードを読み取る機能を実装しました。
読み取り自体はすぐに実装できたのですが、実際に使える状態にするまでには2つの問題がありました🥲
1つは、同じバーコードを何度も処理してしまう問題です。
もう1つは、読み取り枠の外にあるバーコードまで読んでしまう問題です。
本記事ではexpo-cameraを用いたバーコード読み取りの基本的な実装方法と上記の問題の解決方法を紹介していきます!
対象読者
expo-cameraのバーコード読み取り機能を実装する方- バーコード読み取りで誤検出や多重発火に悩んでいる方
完成イメージ
本記事で最終的に作る画面はこちらです。
画面全体にカメラのプレビューを表示し、中央に読み取り枠を重ねています。
また、下部のボタンの長押し中に枠内で検出されたバーコードを処理するようにしています。
まずはexpo-cameraの基本的な使い方から紹介します!
expo-cameraの基本的な使い方
expo-cameraは、Expo SDKに含まれるカメラのパッケージです!
写真や動画の撮影に加えて、バーコードの読み取りにも対応しています!
A React component that renders a preview for the device's front or back camera.
docs.expo.dev読み取りに使うのはCameraViewと2つのpropsです。
import { CameraView, useCameraPermissions } from "expo-camera"
import { StyleSheet, Text } from "react-native"
export default function ScanScreen() {
const [permission] = useCameraPermissions({ request: true })
if (!permission?.granted) {
return <Text>カメラの使用許可が必要です</Text>
}
return (
<CameraView
style={StyleSheet.absoluteFill}
barcodeScannerSettings={{ barcodeTypes: ["ean13", "ean8", "upc_a", "upc_e"] }}
onBarcodeScanned={(result) => {
console.log(result.data)
}}
/>
)
}barcodeScannerSettingsのbarcodeTypesで、読み取りたい形式を配列で指定します。
読み取りに成功するとonBarcodeScannedが呼ばれます。
引数のresultには、読み取った結果がBarcodeScanningResult型のオブジェクトで渡されます。
resultに含まれる値の一覧は、公式ドキュメントで確認できます。
ひとまずこれにて読み取りはできるようになりました!
barcodeTypesに指定できる代表的な形式は以下の通りです!
これ以外の形式も指定可能で、詳しくは公式ドキュメントをご覧ください!
| 値 | 規格 | 種類 | 主な用途 |
|---|---|---|---|
ean13 | EAN-13 | 1次元 | 日本国内の商品。13桁でJANコードの標準タイプ |
ean8 | EAN-8 | 1次元 | 日本国内の小さい商品。8桁でJANコードの短縮タイプ |
upc_a | UPC-A | 1次元 | 北米の商品。12桁 |
upc_e | UPC-E | 1次元 | 北米の小さい商品。UPC-Aを短縮した形式 |
code128 | Code 128 | 1次元 | 英数字と記号を扱う。物流のラベル |
qr | QR Code | 2次元 | URLや決済 |
今回扱うのはJANコードとU.P.C.という2つの商品コードです。
JANコードは耳馴染みのある方が多いと思いますが、こちらは日本で使われる商品コードの規格名です。
U.P.C.はUniversal Product Codeの略で、北米での商品コードの規格名です。
また、JANコード・U.P.C.の商品コードはそれぞれEAN-13とEAN-8、UPC-AとUPC-Eという規格のバーコードで表現されます。
そのため、この4つの規格に対応するean13・ean8・upc_a・upc_eを指定しています。
カメラの権限を要求する
カメラを使うにはOSの許可が必要です!
先ほどのコードで使っているuseCameraPermissionsは、現在の状態と許可を要求する関数を返します。
状態が分かるまでpermissionはnullで、許可されているかどうかはgrantedで判定します。
useCameraPermissionsの引数に{ request: true }を渡すことで、マウント時にフックが許可を要求し、OSのダイアログが表示されます。
既定では状態を取得するだけなので、渡さない場合は許可を要求する関数requestPermissionを自分で呼ぶ必要があります。
公式ドキュメントの例では、ボタンの押下時にrequestPermissionを呼んでいます。
許可を求めるときの文言は、設定ファイルのapp.jsonで指定します。
expoのpluginsにexpo-cameraを追加し、オプションのcameraPermissionに文言を書きます。
この文言は、ビルド時にiOSのInfo.plistのNSCameraUsageDescriptionへ書き出されます。
NSCameraUsageDescriptionは、許可ダイアログに表示される説明文です。
recordAudioAndroidは既定がtrueで、AndroidにRECORD_AUDIOの権限が追加されます。
バーコードの読み取りだけなら不要なのでfalseにしています。
{
"expo": {
"plugins": [
[
"expo-camera",
{
"cameraPermission": "バーコードを読み取るためにカメラへのアクセスを許可してください",
"recordAudioAndroid": false
}
]
]
}
}12桁で返るバーコードを13桁に戻す
このアプリでは、読み取ったコードを使って商品テーブルを検索しています。
ところが、商品テーブルに登録されているはずの商品が一部見つかりませんでした。
読み取り結果を確認すると、13桁のはずのJANコードが12桁で返っていました🥲
JANコードの標準タイプは13桁のGTIN-13です。
一方、北米で使われるU.P.C.は12桁のGTIN-12で、桁数が1つ少なくなっています。
GS1 Japan (一般財団法人流通システム開発センター)公式サイト
www.gs1jp.org北米のUPC-Aを13桁で表すと、先頭に0が付きます。
expo-cameraの読み取り結果では、この先頭の0が外れて12桁になります。
商品テーブルはJANコードを13桁で持っているため、12桁のままでは検索が一致しません。
そこで、12桁で返ってきたときは先頭に0を補って13桁に揃える関数を用意しました!
export const normalizeJanCode = (data: string) => (data.length === 12 ? `0${data}` : data)短縮された形式のean8とupc_eは桁数の扱いが異なるため、この関数では変換していません。
短縮された形式の商品も扱う場合は、商品テーブルのデータに合わせた変換を別途用意してください。

特定の商品だけ検索しても見つからないので、最初はAPI側を疑っていました。読み取った値をログに出して、ようやく桁数の違いに気づきました🥲
問題点1:同じバーコードを何度も処理してしまう
今の実装のままだと、カメラにバーコードが映った際に検出が無限ループしてしまいます。
動画では、検出した回数と処理した回数を画面の左上に出しています。
バーコードを映している間、どちらの数字も増え続けています。
onBarcodeScannedは、カメラがバーコードを検出するたびに呼ばれる仕様となっています。
ここで読み取った商品を取得するAPIを叩くと、同じ商品のリクエストが何度も飛んでしまいます。
この処理を1回で止めるために、次の2つを実装します!
- ボタンの長押し中だけ読み取る
- refで同期的にガードする
1. ボタンの長押し中だけ読み取る
今回のアプリは、複数の商品のバーコードが近くに並んだ状態で使われる想定です。
枠に合わせてからボタンを押してもらえば、途中で映ったバーコードを読まずに済みます。
そのため、読み取りのタイミングはボタンの長押し中だけにすることにしました!
これを実現するため、PressableのonPressInとonPressOutで押している状態を取得します。
import { Pressable, StyleSheet, Text } from "react-native"
type Props = {
isPressed: boolean
onPressIn: () => void
onPressOut: () => void
}
export function ScanButton({ isPressed, onPressIn, onPressOut }: Props) {
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={isPressed ? "スキャン中" : "スキャンボタン"}
onPressIn={onPressIn}
onPressOut={onPressOut}
style={isPressed && styles.pressed}
>
<Text>読み取り</Text>
</Pressable>
)
}
const styles = StyleSheet.create({
pressed: { opacity: 0.6 },
})画面側では、ScanButtonをCameraViewの上に重ねます。
9行目で押している状態をuseStateで持ち、21行目でボタンを押していないときの検出を弾いています。
import { CameraView, useCameraPermissions } from "expo-camera"
import { useState } from "react"
import { StyleSheet, Text, View } from "react-native"
import { ScanButton } from "@/components/ScanButton"
export default function ScanScreen() {
const [permission] = useCameraPermissions({ request: true })
const [isButtonPressed, setIsButtonPressed] = useState(false)
if (!permission?.granted) {
return <Text>カメラの使用許可が必要です</Text>
}
return (
<View style={styles.container}>
<CameraView
style={StyleSheet.absoluteFill}
barcodeScannerSettings={{ barcodeTypes: ["ean13", "ean8", "upc_a", "upc_e"] }}
onBarcodeScanned={(result) => {
if (!isButtonPressed) return
console.log(result.data)
}}
/>
<View style={styles.footer}>
<ScanButton
isPressed={isButtonPressed}
onPressIn={() => setIsButtonPressed(true)}
onPressOut={() => setIsButtonPressed(false)}
/>
</View>
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1 },
footer: { position: "absolute", right: 32, bottom: 48, left: 32 },
})2. refで同期的にガードする
押している状態をuseStateだけで持つと、ガードが間に合いません。
setStateで更新した値を読めるのは次のレンダリング以降です。
onBarcodeScannedはそれを待たずに何度も呼ばれるため、更新前の値で判定してしまいます。
そこで、判定にはuseRefを使います!
refは再レンダリングを挟まずに値が反映されるので、次の検出をすぐに弾けます!
ボタンの見た目は再レンダリングが必要なため、useStateも併せて持ちます。
判定用がref、表示用がstateという分担です。
今回、判定用のrefと表示用のstateを更新する処理と一緒にカスタムフックのuseScannerにまとめています!
import type { BarcodeScanningResult } from "expo-camera"
import { useRef, useState } from "react"
import { normalizeJanCode } from "@/libs/normalizeJanCode"
export function useScanner() {
const isButtonPressedRef = useRef(false)
const hasScannedRef = useRef(false)
const [isButtonPressed, setIsButtonPressed] = useState(false)
const [scannedJan, setScannedJan] = useState("")
const handleBarcodeScanned = (result: BarcodeScanningResult) => {
if (!isButtonPressedRef.current || hasScannedRef.current) return
hasScannedRef.current = true
setScannedJan(normalizeJanCode(result.data))
}
const startScanning = () => {
if (hasScannedRef.current) return
isButtonPressedRef.current = true
setIsButtonPressed(true)
}
const stopScanning = () => {
isButtonPressedRef.current = false
setIsButtonPressed(false)
}
const reset = () => {
isButtonPressedRef.current = false
hasScannedRef.current = false
setIsButtonPressed(false)
setScannedJan("")
}
return {
isButtonPressed,
scannedJan,
startScanning,
stopScanning,
reset,
handleBarcodeScanned,
}
}13行目でボタンを押していない場合と読み取り済みの場合を弾き、14行目で読み取り済みのフラグを立てるので、1回の長押しの中で2回目以降は処理されません。
次のバーコードを読むときはresetを呼び、フラグと値をリセットします。
検出の回数は増え続けますが、処理の回数を1回で止めることができました!

同じ意味の値をrefとstateで2つ持つのは冗長に見えますが、判定用と表示用で役割は異なるため分ける必要があります!
問題点2:読み取り枠の外のバーコードまで読んでしまう
次に、読み取り枠の外にあるバーコードを読んでしまう問題を解消していきます!
枠を描いていても、カメラが映している範囲すべてが検出の対象となっています。
分かりにくいですが、動画では枠内に入れたバーコードではなく、その上のバーコードが読み取られてしまっています。
expo-cameraには読み取り範囲を指定するpropがない
まず、expo-camera側で範囲を絞れないかを確認しました。
barcodeScannerSettingsが受け取るBarcodeSettingsのプロパティはbarcodeTypesだけで、範囲を指定するpropはありません🥲
そのため、検出した結果の座標を見て、枠の内側かどうかを自分で判定する必要があります。
onBarcodeScannedが受け取るBarcodeScanningResultにはboundsが含まれます。
これは検出したバーコードを囲う矩形で、originとsizeを持ちます。
ただし、公式ドキュメントには次の注記があります。
boundsin some case will be representing an empty rectangle. Moreover,boundsdoesn't have to bound the whole barcode. For some types, they will represent the area used by the scanner.(訳)
boundsは、空の矩形を表す場合があります。さらにboundsは、バーコード全体を囲うとは限りません。形式によっては、スキャナーが使用した領域を表します。
boundsを正確な矩形として扱おうとすると、判定が厳しくなりすぎます。
バーコード全体を囲うとは限らないため、四隅が枠に収まっているかを条件にすると、枠に合わせた商品まで弾いてしまいます。
そこで判定には、矩形の中心点だけを使います。
読み取り枠の座標をonLayoutで測る
boundsと枠の位置を比べるには、2つを同じ基準の座標で表す必要があります。
boundsの座標の詳しい説明は公式ドキュメントを確認してみてください!
今回はCameraViewを画面全体に広げているので、boundsは画面の左上を起点とした座標になります。
そのため、枠の位置も画面の左上を起点とした座標で用意する必要があります。
ただし、枠のサイズをJS側に定数で持つと、スタイルと二重管理になってしまいます。
そこで、実際に描画されたレイアウトをonLayoutで取得します!
The most fundamental component for building a UI, View is a container that supports layout with flexbox, style, some touch handling, and accessibility controls. View maps directly to the native view equivalent on whatever platform React Native is running on, whether that is a UIView, `, android.view`, etc.
reactnative.dev枠は、半透明の黒いオーバーレイをくり抜く形で作っています。
まず画面を縦に3分割し、上下をオーバーレイ、中央を帯にします。
帯は読み取り枠と同じ高さで、画面の左端から右端までを占める部分です。
次にその帯を横に3分割し、左右をオーバーレイ、中央を読み取り枠にします。
帯はコードのrow、読み取り枠はholeにあたります。
import { useRef } from "react"
import type { LayoutChangeEvent, LayoutRectangle } from "react-native"
import { StyleSheet, View } from "react-native"
import type { ScanArea } from "@/types/scan"
type Props = {
onScanAreaChange: (scanArea: ScanArea) => void
}
export function ScanFrame({ onScanAreaChange }: Props) {
const rowLayoutRef = useRef<LayoutRectangle | null>(null)
const holeLayoutRef = useRef<LayoutRectangle | null>(null)
const emitScanArea = () => {
const rowLayout = rowLayoutRef.current
const holeLayout = holeLayoutRef.current
if (rowLayout === null || holeLayout === null) return
onScanAreaChange({
x: rowLayout.x + holeLayout.x,
y: rowLayout.y + holeLayout.y,
width: holeLayout.width,
height: holeLayout.height,
})
}
const handleRowLayout = (event: LayoutChangeEvent) => {
rowLayoutRef.current = event.nativeEvent.layout
emitScanArea()
}
const handleHoleLayout = (event: LayoutChangeEvent) => {
holeLayoutRef.current = event.nativeEvent.layout
emitScanArea()
}
return (
<View style={StyleSheet.absoluteFill} pointerEvents="none">
<View style={styles.mask} />
<View style={styles.row} onLayout={handleRowLayout}>
<View style={styles.sideMask} />
<View style={styles.hole} onLayout={handleHoleLayout} />
<View style={styles.sideMask} />
</View>
<View style={styles.mask} />
</View>
)
}
const styles = StyleSheet.create({
mask: { flex: 1, backgroundColor: "rgba(0, 0, 0, 0.8)" },
row: { flexDirection: "row", height: 200 },
sideMask: { width: "12.5%", backgroundColor: "rgba(0, 0, 0, 0.8)" },
hole: { flex: 1 },
})枠の座標を組み立てているのは21行目と22行目です!
onLayoutが返すxとyは、親要素の左上を起点として、自分の左上までの距離を表します。
2つのレイアウトが持つ値は次のとおりです。
| 値 | 何を表すか | 値の中身 |
|---|---|---|
rowLayout.x | 画面の左上から帯の左上までの横の距離 | 0(帯は幅いっぱいに広がるため) |
rowLayout.y | 画面の左上から帯の左上までの縦の距離 | 上のオーバーレイの高さ |
holeLayout.x | 帯の左上から読み取り枠の左上までの横の距離 | 左のオーバーレイの幅 |
holeLayout.y | 帯の左上から読み取り枠の左上までの縦の距離 | 0(読み取り枠は帯の高さいっぱいに広がるため) |
xどうしとyどうしを足すと、画面の左上から読み取り枠の左上までの距離になります。
これがScanAreaのxとyです。
検出位置が枠の内側かを判定する
枠の矩形が取れたので、あとは判定するだけです!
boundsから中心点を求め、枠の内側に入っているかを調べます!
枠を測る前に検出が起きる場合もあるため、scanAreaがnullのときは判定せず、読み取りを通すようにしました。
また先ほどの注記のとおり、boundsは空の矩形になる場合があるため、サイズが0のときも同じく通します。
export type ScanArea = {
x: number
y: number
width: number
height: number
}import type { BarcodeScanningResult } from "expo-camera"
import type { ScanArea } from "@/types/scan"
export function isBarcodeInScanArea(bounds: BarcodeScanningResult["bounds"], scanArea: ScanArea | null) {
if (scanArea === null) return true
const { origin, size } = bounds
if (size.width === 0 || size.height === 0) return true
const centerX = origin.x + size.width / 2
const centerY = origin.y + size.height / 2
return (
centerX >= scanArea.x
&& centerX <= scanArea.x + scanArea.width
&& centerY >= scanArea.y
&& centerY <= scanArea.y + scanArea.height
)
}この判定をuseScannerに組み込みます!
枠の座標はレイアウトのたびに届くので、useStateではなくrefに持たせます。
const scanAreaRef = useRef<ScanArea | null>(null)
const setScanArea = (scanArea: ScanArea) => {
scanAreaRef.current = scanArea
}
const handleBarcodeScanned = (result: BarcodeScanningResult) => {
if (!isButtonPressedRef.current || hasScannedRef.current) return
if (!isBarcodeInScanArea(result.bounds, scanAreaRef.current)) return
hasScannedRef.current = true
setScannedJan(normalizeJanCode(result.data))
}これで、枠の中のバーコードのみが検出されるようになりました🙌
全体の実装
ここまでの内容をまとめると、フックは次のようになります!
読み取りに成功した合図として、expo-hapticsで触覚フィードバックも返しています。
import type { BarcodeScanningResult } from "expo-camera"
import * as Haptics from "expo-haptics"
import { useRef, useState } from "react"
import { isBarcodeInScanArea } from "@/libs/isBarcodeInScanArea"
import { normalizeJanCode } from "@/libs/normalizeJanCode"
import type { ScanArea } from "@/types/scan"
export function useScanner() {
const isButtonPressedRef = useRef(false)
const hasScannedRef = useRef(false)
const scanAreaRef = useRef<ScanArea | null>(null)
const [isButtonPressed, setIsButtonPressed] = useState(false)
const [scannedJan, setScannedJan] = useState("")
const setScanArea = (scanArea: ScanArea) => {
scanAreaRef.current = scanArea
}
const handleBarcodeScanned = (result: BarcodeScanningResult) => {
if (!isButtonPressedRef.current || hasScannedRef.current) return
if (!isBarcodeInScanArea(result.bounds, scanAreaRef.current)) return
hasScannedRef.current = true
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
setScannedJan(normalizeJanCode(result.data))
}
const startScanning = () => {
if (hasScannedRef.current) return
isButtonPressedRef.current = true
setIsButtonPressed(true)
}
const stopScanning = () => {
isButtonPressedRef.current = false
setIsButtonPressed(false)
}
const reset = () => {
isButtonPressedRef.current = false
hasScannedRef.current = false
setIsButtonPressed(false)
setScannedJan("")
}
return {
isButtonPressed,
scannedJan,
startScanning,
stopScanning,
reset,
setScanArea,
handleBarcodeScanned,
}
}画面側では、CameraViewとScanFrameを重ねてフックとつなぎます。
カメラの権限は、最初のコードと同じく{ request: true }で要求しています。
読み取った後の表示は、本来は商品を取得してシートなどに出す部分です。
ここでは読み取った商品コードをそのまま出しています。
import { CameraView, useCameraPermissions } from "expo-camera"
import { Pressable, StyleSheet, Text, View } from "react-native"
import { ScanButton } from "@/components/ScanButton"
import { ScanFrame } from "@/components/ScanFrame"
import { useScanner } from "@/hooks/useScanner"
export default function ScanScreen() {
const [permission] = useCameraPermissions({ request: true })
const { isButtonPressed, scannedJan, startScanning, stopScanning, reset, setScanArea, handleBarcodeScanned } =
useScanner()
if (!permission?.granted) {
return <Text>カメラの使用許可が必要です</Text>
}
return (
<View style={styles.container}>
<CameraView
style={StyleSheet.absoluteFill}
barcodeScannerSettings={{ barcodeTypes: ["ean13", "ean8", "upc_a", "upc_e"] }}
onBarcodeScanned={handleBarcodeScanned}
/>
<ScanFrame onScanAreaChange={setScanArea} />
<View style={styles.footer}>
<ScanButton isPressed={isButtonPressed} onPressIn={startScanning} onPressOut={stopScanning} />
</View>
{scannedJan === "" ? null : (
<View>
<Text>JAN : {scannedJan}</Text>
<Pressable accessibilityRole="button" onPress={reset}>
<Text>閉じる</Text>
</Pressable>
</View>
)}
</View>
)
}
const styles = StyleSheet.create({
container: { flex: 1 },
footer: { position: "absolute", right: 32, bottom: 48, left: 32 },
})おわりに
本記事では、expo-cameraでバーコードを読み取る実装を紹介しました。
onBarcodeScannedは検出のたびに呼ばれるため、多重発火はrefで同期的に止めます。
読み取り範囲を指定するpropはないので、枠の座標をonLayoutで測り、boundsの中心点で判定します。
どちらも数行で済みますが、入れるまでは業務で使える精度になりませんでした。
バーコードを扱う画面を作るときは、ぜひ参考にしてください💪
同じモバイルアプリでは、FlashListで固定ヘッダー付きのリストも実装しました。
タブを切り替えたときのチラつきを解消する方法は、以下の記事で紹介しています!ぜひ読んでみてください!
React NativeのFlashListとReactのuseDeferredValueを組み合わせて、スクロールに追従する固定ヘッダーと、タブ切り替え時のチラつき防止を実装する方法をコード例とともに解説します。

最後まで読んでいただきありがとうございました!カメラ周りは実機でしか確認できない挙動が多いので、また気づきがあれば記事にします😎




