How to Build a Barcode Scanner App in SwiftUI
A barcode scanner app lets users point their iPhone camera at any product barcode and instantly retrieve product details — name, brand, and image — from a public product database. It's ideal for shoppers tracking pantry inventory, developers building price-comparison tools, or anyone who wants quick product information without typing a single character.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store distribution
- Basic Swift/SwiftUI knowledge, including async/await for network calls
- A physical iPhone for testing — the iOS Simulator cannot access the camera, and
DataScannerViewController.isAvailablereturnsfalsein the Simulator - Familiarity with
UIViewControllerRepresentableis helpful but not required — the pattern is explained step by step below
Architecture overview
The app has three layers: a scanner layer (VisionKit's DataScannerViewController wrapped in UIViewControllerRepresentable), a service layer (ProductLookupService that calls the Open Food Facts REST API with async/await), and a persistence layer (SwiftData storing scan history via @Model and @Query). An @Observable view model bridges scanner output to the lookup service and drives a bottom-sheet product detail view. The tabs split the scanner from scan history, keeping navigation straightforward.
BarcodeScanner/ ├── App/ │ └── BarcodeScannerApp.swift # @main, .modelContainer setup ├── Models/ │ ├── ScannedProduct.swift # @Model (SwiftData persistence) │ └── OpenFoodFactsResponse.swift # Codable (API response shape) ├── Services/ │ └── ProductLookupService.swift # async fetch, Open Food Facts API ├── ViewModels/ │ └── ScannerViewModel.swift # @Observable, scan → lookup bridge ├── Views/ │ ├── ContentView.swift # TabView root │ ├── ScannerView.swift # UIViewControllerRepresentable │ ├── ScannerContainerView.swift # ZStack: camera + loading + sheet │ ├── ProductDetailView.swift # Sheet: save or discard a scan │ ├── HistoryView.swift # @Query list with search + delete │ └── PaywallView.swift # StoreKit one-time unlock └── PrivacyInfo.xcprivacy
Step-by-step
1. Project setup
Create a new Xcode 16 project using the iOS App template. Enable SwiftData (check the checkbox in the new project dialog). Add NSCameraUsageDescription to your target's Info.plist — App Store review rejects apps with vague strings like "needed for features," so be specific about why the camera is used.
// BarcodeScannerApp.swift
import SwiftUI
import SwiftData
@main
struct BarcodeScannerApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: ScannedProduct.self)
}
}
// Info.plist — add this key via Xcode target → Info tab:
// Key: NSCameraUsageDescription
// Value: "Scan product barcodes to look up names, brands,
// and nutritional information instantly."
2. Data model
Use a SwiftData @Model to persist scanned products locally. Storing the raw barcode alongside resolved product data means previously scanned items remain accessible offline. A unique constraint on barcode prevents duplicate history entries.
// Models/ScannedProduct.swift
import SwiftData
import Foundation
@Model
final class ScannedProduct {
@Attribute(.unique) var barcode: String
var name: String
var brand: String
var imageURL: String
var scannedAt: Date
var notes: String
init(
barcode: String,
name: String,
brand: String = "",
imageURL: String = ""
) {
self.barcode = barcode
self.name = name
self.brand = brand
self.imageURL = imageURL
self.scannedAt = .now
self.notes = ""
}
}
// Models/OpenFoodFactsResponse.swift
import Foundation
struct OpenFoodFactsResponse: Codable {
let status: Int // 1 = found, 0 = not found
let product: RawProduct?
struct RawProduct: Codable {
let productName: String?
let brands: String?
let imageFrontURL: String?
enum CodingKeys: String, CodingKey {
case productName = "product_name"
case brands = "brands"
case imageFrontURL = "image_front_url"
}
}
}
3. Core UI (ContentView with tabs)
A TabView gives users two destinations: a live scanner tab and a scan history tab. The view model is created once at the root and passed down so the scanner and its sheet share the same state object.
// Views/ContentView.swift
import SwiftUI
struct ContentView: View {
@State private var viewModel = ScannerViewModel()
var body: some View {
TabView {
NavigationStack {
ScannerContainerView(viewModel: viewModel)
.navigationTitle("Scanner")
.navigationBarTitleDisplayMode(.inline)
}
.tabItem { Label("Scan", systemImage: "barcode.viewfinder") }
HistoryView()
.tabItem { Label("History", systemImage: "clock") }
}
}
}
#Preview {
ContentView()
.modelContainer(for: ScannedProduct.self, inMemory: true)
}
4. Scanner view (VisionKit DataScannerViewController)
DataScannerViewController (VisionKit, iOS 16+) handles camera lifecycle, framing, and barcode recognition with a single delegate-based controller — no manual AVCaptureSession setup needed. The Coordinator debounces repeated callbacks for the same barcode so each physical product triggers exactly one API call.
// Views/ScannerView.swift
import SwiftUI
import VisionKit
struct ScannerView: UIViewControllerRepresentable {
let onBarcodeFound: (String) -> Void
func makeUIViewController(context: Context) -> DataScannerViewController {
let scanner = DataScannerViewController(
recognizedDataTypes: [.barcode(symbologies: [
.ean13, .ean8, .upce, .qr, .code128, .code39, .itf14
])],
qualityLevel: .accurate,
recognizesMultipleItems: false,
isHighlightingEnabled: true
)
scanner.delegate = context.coordinator
return scanner
}
func updateUIViewController(
_ uiViewController: DataScannerViewController,
context: Context
) {
guard DataScannerViewController.isSupported,
DataScannerViewController.isAvailable else { return }
try? uiViewController.startScanning()
}
func makeCoordinator() -> Coordinator {
Coordinator(onBarcodeFound: onBarcodeFound)
}
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
let onBarcodeFound: (String) -> Void
private var lastScanned: String?
init(onBarcodeFound: @escaping (String) -> Void) {
self.onBarcodeFound = onBarcodeFound
}
func dataScanner(
_ dataScanner: DataScannerViewController,
didAdd addedItems: [RecognizedItem],
allItems: [RecognizedItem]
) {
guard let item = addedItems.first,
case .barcode(let barcode) = item,
let payload = barcode.payloadStringValue,
payload != lastScanned
else { return }
lastScanned = payload
// Prevent the same barcode re-firing for 2 seconds
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
[weak self] in self?.lastScanned = nil
}
onBarcodeFound(payload)
}
}
}
// Views/ScannerContainerView.swift
import SwiftUI
import VisionKit
struct ScannerContainerView: View {
@Bindable var viewModel: ScannerViewModel
var body: some View {
ZStack(alignment: .bottom) {
if DataScannerViewController.isSupported {
ScannerView { barcode in
Task { await viewModel.handleScan(barcode) }
}
.ignoresSafeArea()
} else {
ContentUnavailableView(
"Camera Unavailable",
systemImage: "camera.slash",
description: Text("This device does not support barcode scanning.")
)
}
if viewModel.isLoading {
ProgressView("Looking up product…")
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
.padding(.bottom, 48)
}
if let errorMessage = viewModel.errorMessage {
Text(errorMessage)
.font(.footnote)
.multilineTextAlignment(.center)
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12))
.padding(.bottom, 48)
.onTapGesture { viewModel.errorMessage = nil }
}
}
.sheet(item: $viewModel.pendingProduct) { product in
ProductDetailView(product: product)
}
}
}
#Preview {
NavigationStack {
ScannerContainerView(viewModel: ScannerViewModel())
}
}
5. Product lookup service
The lookup service calls the Open Food Facts API — no API key required, completely free for non-commercial use. The @Observable view model drives loading and error state directly; no Combine publishers or manual objectWillChange calls needed in iOS 17+.
// Services/ProductLookupService.swift
import Foundation
struct ProductLookupService {
private let session = URLSession.shared
private let baseURL = "https://world.openfoodfacts.org/api/v2/product"
private let fields = "product_name,brands,image_front_url"
func fetchProduct(barcode: String) async throws -> OpenFoodFactsResponse {
guard let url = URL(string: "\(baseURL)/\(barcode).json?fields=\(fields)") else {
throw URLError(.badURL)
}
let (data, response) = try await session.data(from: url)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode(OpenFoodFactsResponse.self, from: data)
}
}
// ViewModels/ScannerViewModel.swift
import SwiftUI
@Observable
final class ScannerViewModel {
var pendingProduct: ScannedProduct?
var isLoading = false
var errorMessage: String?
private let service = ProductLookupService()
@MainActor
func handleScan(_ barcode: String) async {
guard !isLoading else { return }
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let response = try await service.fetchProduct(barcode: barcode)
guard response.status == 1, let raw = response.product else {
errorMessage = "No product found for barcode \(barcode)."
return
}
pendingProduct = ScannedProduct(
barcode: barcode,
name: raw.productName ?? "Unknown Product",
brand: raw.brands ?? "",
imageURL: raw.imageFrontURL ?? ""
)
} catch {
errorMessage = "Lookup failed: \(error.localizedDescription)"
}
}
}
6. Scan history with SwiftData
The product detail sheet lets the user review a scan before committing it to history — this saves or discards intentionally rather than auto-saving every scan. The history view uses @Query which automatically reflects insertions and deletions without any manual refresh logic.
// Views/ProductDetailView.swift
import SwiftUI
import SwiftData
struct ProductDetailView: View {
let product: ScannedProduct
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
if !product.imageURL.isEmpty,
let url = URL(string: product.imageURL) {
AsyncImage(url: url) { image in
image
.resizable()
.scaledToFit()
.clipShape(RoundedRectangle(cornerRadius: 8))
} placeholder: {
ProgressView()
.frame(height: 180)
}
.frame(maxHeight: 220)
.listRowInsets(.init())
}
Section("Product Info") {
LabeledContent("Barcode", value: product.barcode)
LabeledContent("Name", value: product.name)
if !product.brand.isEmpty {
LabeledContent("Brand", value: product.brand)
}
}
}
.navigationTitle("Product Found")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Save to History") {
context.insert(product)
dismiss()
}
.fontWeight(.semibold)
}
ToolbarItem(placement: .cancellationAction) {
Button("Discard") { dismiss() }
}
}
}
}
}
// Views/HistoryView.swift
import SwiftUI
import SwiftData
struct HistoryView: View {
@Query(sort: \ScannedProduct.scannedAt, order: .reverse)
private var products: [ScannedProduct]
@Environment(\.modelContext) private var context
@State private var searchText = ""
private var filtered: [ScannedProduct] {
guard !searchText.isEmpty else { return products }
return products.filter {
$0.name.localizedCaseInsensitiveContains(searchText) ||
$0.brand.localizedCaseInsensitiveContains(searchText) ||
$0.barcode.contains(searchText)
}
}
var body: some View {
NavigationStack {
List {
ForEach(filtered) { product in
VStack(alignment: .leading, spacing: 4) {
Text(product.name)
.font(.headline)
Text(product.brand.isEmpty ? product.barcode : product.brand)
.font(.subheadline)
.foregroundStyle(.secondary)
Text(
product.scannedAt
.formatted(date: .abbreviated, time: .shortened)
)
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding(.vertical, 2)
}
.onDelete { indexSet in
for index in indexSet {
context.delete(filtered[index])
}
}
}
.searchable(text: $searchText, prompt: "Search by name, brand, or barcode")
.navigationTitle("History")
.overlay {
if products.isEmpty {
ContentUnavailableView(
"No Scans Yet",
systemImage: "barcode",
description: Text("Saved products will appear here.")
)
}
}
}
}
}
#Preview {
HistoryView()
.modelContainer(for: ScannedProduct.self, inMemory: true)
}
7. Privacy Manifest (required for App Store)
A PrivacyInfo.xcprivacy file is mandatory for all new App Store submissions. SwiftData internally touches UserDefaults, which is a Required Reason API — declare it here. Add the file via Xcode → File → New File → App Privacy, then populate it. Missing entries have caused App Store rejections since Xcode 15.3.
<!-- PrivacyInfo.xcprivacy -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- No user data collected or sent off-device -->
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<!-- Required Reason API: UserDefaults (used by SwiftData internals) -->
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<!-- CA92.1: Read/write preferences exclusively owned by this app -->
<string>CA92.1</string>
</array>
</dict>
</array>
<!-- No third-party tracking domains -->
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyTracking</key>
<false/>
</dict>
</plist>
<!-- Verify coverage: Product → Privacy Report in Xcode before submitting -->
Common pitfalls
- Missing or vague NSCameraUsageDescription: The system kills the app immediately on the first camera permission request if this key is absent. App Review also rejects generic strings like "For scanning" — describe the actual purpose, as shown in step 1.
- Skipping the
isAvailableguard:DataScannerViewController.isSupportedcan returntrueon newer device models butisAvailablecan still befalse(e.g., when the camera is in use by another app). CallingstartScanning()without checking both properties throws a runtime error. - Duplicate scan callbacks:
DataScannerViewControllerfires its delegate repeatedly while a barcode stays in frame — without debouncing, a single can triggers dozens of API calls. The two-secondlastScannedguard in theCoordinator(step 4) is the minimal fix; for production, consider a more sophisticated lock. - App Store rejection for offline silence: Review tests network-dependent features on real devices, sometimes with throttled connections. If
URLSessiontimes out and you show nothing, the app will be rejected. TheerrorMessagestate in the view model and the tap-to-dismiss error banner handle this. - Privacy Manifest gaps from third-party SDKs: If you add a crash reporter (e.g., Firebase Crashlytics) or analytics library, that SDK must ship its own
PrivacyInfo.xcprivacy. Run Xcode's Privacy Report (Product → Privacy Report) before every submission to catch missing entries — Apple now performs automated checks during upload.
Adding monetization: One-time purchase
Use StoreKit 2's Product.purchase() API to gate premium features — unlimited scan history beyond a free tier of 20 items, CSV export, or per-product notes — behind a non-consumable in-app purchase. Define the product in App Store Connect with type "Non-Consumable," then call Product.products(for: ["com.yourapp.pro"]) at app launch to fetch its metadata. Start a long-lived Task { for await result in Transaction.updates { … } } inside your @main app struct so purchase completions are handled from any entry point, including promotional StoreKit overlays. Persist the entitlement state with @AppStorage (a Bool flag) and verify it by checking Transaction.currentEntitlement(for:) on each launch — never trust only the in-memory flag across cold starts.
Shipping this faster with Soarias
Soarias automates the parts of this build that have nothing to do with your scanner logic: it scaffolds the SwiftData model and UIViewControllerRepresentable wrapper from a description of your app, generates the PrivacyInfo.xcprivacy file based on APIs detected in your project, wires up fastlane lanes for TestFlight and App Store delivery, and pre-fills the App Store Connect metadata fields that first-time submitters almost always miss — privacy nutrition labels, export compliance declarations, and age ratings.
For an intermediate project at this complexity level, the scaffolding, fastlane setup, screenshot automation, and ASC metadata typically consume two to three full days of otherwise productive time. With Soarias, that collapses to under an hour, leaving your week free for the product lookup logic, UX polish, and the barcode database integrations that actually make your app worth downloading.
Related guides
FAQ
Does this work on iOS 16?
DataScannerViewController is available from iOS 16, so the scanner itself will work. However, SwiftData requires iOS 17+. If you need iOS 16 support, replace SwiftData with Core Data using the @FetchRequest property wrapper — the architecture is identical, just more boilerplate. For most new apps, targeting iOS 17+ is the right call given its market share.
Do I need a paid Apple Developer account to test?
A free Apple ID lets you sideload the app onto your personal device via Xcode, but the provisioning profile expires after seven days and you can only have three apps installed at a time. A paid Apple Developer Program membership ($99/year) is required for TestFlight beta testing, App Store distribution, and push notifications. It also gives you access to App Store Connect analytics after launch.
How do I submit this to the App Store?
Archive the app in Xcode (Product → Archive), upload it via Xcode Organizer or fastlane deliver, then complete the App Store Connect listing: privacy nutrition labels, screenshots for every required device size, a description, keywords, and pricing. App Review will test the camera on a physical device, so ensure your NSCameraUsageDescription matches what the app actually does and your offline error states are handled gracefully. Soarias automates the fastlane and ASC metadata steps end-to-end.
What if Open Food Facts doesn't have the product I scanned?
Open Food Facts is strong for consumer grocery products but thin on general retail, books, and electronics. A solid fallback strategy: try Open Food Facts first, then check response.status == 0 and call a secondary API such as UPCitemdb or Barcode Lookup (both have free tiers). Because you've already decoded the response into your own ScannedProduct model, swapping or chaining lookup sources requires changes only inside ProductLookupService — none of the views need to change.
Last reviewed: 2026-05-12 by the Soarias team.