```html How to Build a Barcode Scanner App in SwiftUI (2026)

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.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Intermediate Estimated time: 1 week Updated: May 12, 2026

Prerequisites

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

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.

```