Rork Max が生成したネイティブ Swift のひな型に「商品バーコードを読む画面が欲しい」と書き足してもらったとき、ブラウザのライブシミュレータでは枠だけが表示されてカメラが立ち上がらず、しばらく原因を探していました。結論から書きますと、VisionKit の DataScannerViewController はシミュレータでは動作せず、対応端末の実機でしか起動しません。ちょうど Rork は 2026 年に入って Companion アプリ経由の実機テストやクラウド Mac でのコンパイルを整えてきていますが、この種の「カメラ実機依存」の機能こそ、最初から実機検証を前提に組む必要があります。
ここでは Rork Max のネイティブ Swift コードに、バーコードとテキストをライブで同時認識するスキャナ画面を組み込む手順を、SwiftUI への橋渡しと実機での確認まで含めて整理します。私自身、2014 年から個人でアプリを作ってきて、カメラ系機能はとりわけ「権限」と「端末差」でつまずきやすいと感じています。その勘所も合わせて書いておきます。
なぜ AVFoundation を手書きせず DataScanner を選ぶのか
iOS でカメラからコードを読む方法は大きく二つあります。AVCaptureSession を自分で組んで AVCaptureMetadataOutput でバーコードを拾う従来の方法と、iOS 16 で入った VisionKit の DataScannerViewController です。後者はカメラのプレビュー、被写体のハイライト表示、ピンチズーム、ガイダンス UI までを一体で提供してくれます。
商品コードとパッケージの型番テキストをまとめて読みたい、といった用途では DataScanner が圧倒的に短く書けます。一方で対応端末が限られる点とシミュレータで動かない点は、設計の最初から織り込む必要があります。私はこの種の機能では、コードを書き始める前に「非対応時に何を表示するか」を先に決めるようにしています。
Step 1: 権限の宣言とカメラ許可の要求
まず Info.plist に NSCameraUsageDescription を入れます。Rork Max のプロジェクト設定からも編集できますが、用途が具体的に伝わる文言にしておくと、App Store 審査でのリジェクト理由を一つ減らせます。
< key >NSCameraUsageDescription</ key >
< string >商品のバーコードや型番を読み取るためにカメラを使用します。</ string >
許可の要求は画面表示の前に明示的に行います。DataScannerViewController.isAvailable はカメラ未許可だと false を返すため、許可ダイアログを出さないまま可用性だけ見ると「非対応端末」と誤判定してしまいます。これが最初のつまずきどころでした。
import AVFoundation
func requestCameraAccess () async -> Bool {
switch AVCaptureDevice. authorizationStatus ( for : .video) {
case .authorized :
return true
case .notDetermined :
// 初回はここでダイアログが出る。可用性判定より前に呼ぶのが肝心
return await AVCaptureDevice. requestAccess ( for : .video)
case .denied, .restricted :
return false
@unknown default:
return false
}
}
Step 2: 可用性を 4 状態で持つ
DataScannerViewController には isSupported(ハードウェアと OS が対応しているか)と isAvailable(対応かつカメラ許可済みで利用可能か)の二つがあります。この二つを単純な真偽値ではなく、画面に出し分けられる状態として持っておくと、実機での挙動が一気に読みやすくなります。
import VisionKit
enum ScannerAvailability {
case ready
case cameraDenied // 許可されていない → 設定アプリへ誘導
case unsupportedDevice // シミュレータ / A11 以前
case restricted // ペアレンタルコントロール等
static func current () -> ScannerAvailability {
// isSupported は端末・OS の対応可否のみ。許可状態は見ない
guard DataScannerViewController.isSupported else {
return .unsupportedDevice
}
// isAvailable は「対応 かつ 許可済み かつ 利用可能」のとき true
guard DataScannerViewController.isAvailable else {
let status = AVCaptureDevice. authorizationStatus ( for : .video)
return status == .restricted ? .restricted : .cameraDenied
}
return .ready
}
}
Step 3: DataScanner を SwiftUI に橋渡しする
DataScannerViewController は UIKit のビューコントローラなので、SwiftUI からは UIViewControllerRepresentable で包みます。検出結果はクロージャで外へ渡し、連続検出を抑えるスロットリングはコーディネータ側に持たせます。ここを入れないと、同じバーコードが視界にある間ずっと didAdd が呼ばれ続け、結果表示が点滅したり、サーバ問い合わせが連打されたりします。
import SwiftUI
import VisionKit
struct DataScannerView : UIViewControllerRepresentable {
let recognizedTypes: Set <DataScannerViewController.RecognizedDataType>
@Binding var isScanning: Bool
let onScan: (RecognizedItem) -> Void
func makeUIViewController ( context : Context) -> DataScannerViewController {
let controller = DataScannerViewController (
recognizedDataTypes : recognizedTypes,
qualityLevel : .balanced,
recognizesMultipleItems : false ,
isHighFrameRateTrackingEnabled : true ,
isPinchToZoomEnabled : true ,
isGuidanceEnabled : true ,
isHighlightingEnabled : true
)
controller.delegate = context.coordinator
return controller
}
func updateUIViewController ( _ controller: DataScannerViewController, context : Context) {
// 親の状態に合わせてスキャンを開始・停止する
if isScanning {
try? controller. startScanning ()
} else {
controller. stopScanning ()
}
}
func makeCoordinator () -> Coordinator { Coordinator ( self ) }
final class Coordinator : NSObject , DataScannerViewControllerDelegate {
private let parent: DataScannerView
private var lastFired: [ String : Date] = [ : ]
init ( _ parent: DataScannerView) { self .parent = parent }
func dataScanner ( _ scanner: DataScannerViewController,
didAdd addedItems: [RecognizedItem],
allItems : [RecognizedItem]) {
for item in addedItems { handle (item) }
}
func dataScanner ( _ scanner: DataScannerViewController,
didTapOn item: RecognizedItem) {
handle (item)
}
// 同一内容は 1.5 秒間は再通知しない
private func handle ( _ item: RecognizedItem) {
let key = stableKey ( for : item)
let now = Date ()
if let last = lastFired[key], now. timeIntervalSince (last) < 1.5 { return }
lastFired[key] = now
parent. onScan (item)
}
private func stableKey ( for item: RecognizedItem) -> String {
switch item {
case . barcode ( let code) :
return "barcode:" + (code.payloadStringValue ?? "" )
case . text ( let text) :
return "text:" + text.transcript
@unknown default:
return "unknown"
}
}
}
}
認識する種類の指定
recognizedDataTypes で何を読むかを宣言します。バーコードは記号体系(symbologies)を絞ると誤検出が減り、テキストは言語を指定すると精度が上がります。
let types: Set <DataScannerViewController.RecognizedDataType> = [
. barcode ( symbologies : [.qr, .ean13, .code128]),
. text ( languages : [ "ja" , "en" ])
]
Step 4: 画面側で状態を出し分ける
可用性に応じて、スキャナ・権限誘導・非対応案内を切り替えます。非対応端末やシミュレータでは ContentUnavailableView で「実機で確認してください」と明示しておくと、自分が後でテストするときにも、ユーザーにとっても親切です。
struct ScannerScreen : View {
@State private var availability: ScannerAvailability = .unsupportedDevice
@State private var isScanning = true
@State private var lastResult = ""
private let types: Set <DataScannerViewController.RecognizedDataType> = [
. barcode ( symbologies : [.qr, .ean13, .code128]),
. text ( languages : [ "ja" , "en" ])
]
var body: some View {
Group {
switch availability {
case .ready :
DataScannerView ( recognizedTypes : types, isScanning : $isScanning) { item in
switch item {
case . barcode ( let code) :
lastResult = code.payloadStringValue ?? "(空のコード)"
case . text ( let text) :
lastResult = text.transcript
@unknown default:
break
}
}
. overlay ( alignment : .bottom) {
Text (lastResult. isEmpty ? "コードをかざしてください" : lastResult)
. padding ()
. background (.thinMaterial, in : .capsule)
. padding (.bottom, 32 )
}
case .cameraDenied :
ContentUnavailableView (
"カメラの許可が必要です" ,
systemImage : "camera.fill" ,
description : Text ( "設定アプリからカメラの使用を許可してください。" )
)
case .unsupportedDevice :
ContentUnavailableView (
"この端末では利用できません" ,
systemImage : "iphone.slash" ,
description : Text ( "シミュレータや A11 以前の端末は DataScanner に対応していません。実機で確認してください。" )
)
case .restricted :
ContentUnavailableView (
"カメラが制限されています" ,
systemImage : "lock.fill" ,
description : Text ( "スクリーンタイム等の制限を確認してください。" )
)
}
}
. task {
_ = await requestCameraAccess ()
availability = ScannerAvailability. current ()
}
}
}
実機検証で実際にハマったところ
ここからが、公式ドキュメントだけ読んでいると見落としがちな実運用上の注意点です。
シミュレータでは絶対に起動しない。 isSupported が false を返すので、シミュレータで動作確認しようとすると永遠に非対応扱いになります。Rork の Companion アプリやクラウドコンパイルで早めに実機へ載せ、カメラ周りは最初から実機で確認するのが結局いちばん速い、というのが私の結論です。
許可ダイアログを出す前に可用性を見ない。 isAvailable は未許可だと false です。先に requestAccess を済ませてから判定しないと、許可すれば動く端末を「非対応」と誤って弾いてしまいます。
スロットリングは必須。 didAdd は被写体が視界にある限り繰り返し呼ばれます。本番では、同一ペイロードを一定時間ブロックするだけで体感が大きく安定します。1.5 秒という値は、私が壁紙アプリ以外の検証用ビルドで試して落ち着いた目安です。アプリの性質に合わせて 1.0〜2.0 秒で調整すると良いと感じています。
省電力とサーマル。 高フレームレート追従は発熱します。長時間スキャンし続ける画面では、結果が確定したら stopScanning() を呼んで一旦止める設計をお勧めします。
読み取り後にやるべき一手間
スキャナは「読めた」で終わりではありません。バーコードのペイロードは前後の空白や制御文字を含むことがあるため、サーバ問い合わせの前に trimmingCharacters(in: .whitespacesAndNewlines) で整えます。テキスト認識(OCR)は誤読が混じるので、型番のように形式が決まっているものは正規表現で軽くバリデーションしてから使うと、後段の処理が安定します。
func normalize ( _ raw: String ) -> String ? {
let trimmed = raw. trimmingCharacters ( in : .whitespacesAndNewlines)
guard ! trimmed. isEmpty else { return nil }
return trimmed
}
Rork Max が生成する SwiftUI は素直な構造なので、こうしたカメラ系のネイティブ機能は「生成されたひな型に、可用性判定とスロットリングという二つの守りを足す」だけで、ぐっと実用に耐える形になります。まずは対応端末を一台用意して、ContentUnavailableView が出ないことを確認するところから始めてみてください。