Building a QR Code Scanner: A Step-by-Step Guide

Calendar icon December 27, 2022 Clock icon 19 minutes
Swift
SwiftUI
Combine

Quick Response (QR) code scanners can be helpful in various industries and applications. Whether you want to allow users to access the menu in a restaurant, pair with a wifi network, or provide more information about a product you're selling, a QR code can give users a quick and convenient way to access information. In this article, we'll walk through the steps for setting up an iOS sample app with QR code scanning capabilities.

If you are targeting iOS 16 or higher and your app does not require special customization, consider using DataScannerViewController which is a configurable controller that handles machine-readable codes.
The article assumes that the reader has basic knowledge of SwiftUI/Swift and is familiar with iOS development.

Starter project

To get started, download and explore the starter project. It's a basic SwiftUI template with a button on the main view, which presents an empty view on tap.

Camera permissions

To access camera, the app needs to declare usage description in Info.plist. Camera usage description tells the user why application is requesting access to the camera; an attempt to access camera without declaring usage description will result in an application crash. Open Info.plist and add following key/value:

<key>NSCameraUsageDescription</key>
<string>Camera is used to scan QR code</string>

Request access to camera

Access to the camera can reveal a lot of personal information; therefore, before an app can access the camera user must explicitly grant permission. It's a good practice to request access only for resources app needs; if an app needs only camera access, it shouldn't request access to the photo library or microphone. Let's add an extension to AVCaptureDevice to request access to the camera.

import AVFoundation

extension AVCaptureDevice {
    public static func requestVideoPermissionIfNeeded() async throws {
        // Query system for video authorization status
        switch AVCaptureDevice.authorizationStatus(for: .video) {
        case .authorized:
            break
        case .notDetermined:
            let accessGranted = await AVCaptureDevice.requestAccess(for: .video)
            guard accessGranted else { throw ScannerError.cameraAccessDenied }
            try await requestVideoPermissionIfNeeded()
        default:
            throw ScannerError.cameraAccessDenied
        }
    }
}

requestVideoPermissionIfNeeded() checks authorization status for the video, if status .notDetermined system will present a permission request alert. If the user denied permission or status is restricted an error will be thrown; in a real app restricted status would need to be appropriately handled. We will use method later, next let's work on configuring camera session.

Configure camera session

Let's now configure the capture session, which will do the heavy lifting in processing camera output and extracting QR code. To keep the code modular, we will create a separate class to manage the capture session. Create CameraService.swift file, declare CameraService class and implement configureVideoSession() method:

import AVFoundation

final class CameraService: NSObject {
    // Variable to keep reference to created session
    private (set) var session: AVCaptureSession?
    // Custom queue for capture session
    private let queue = DispatchQueue(label: String(describing: CameraService.self))
}

extension CameraService {
    public func configureVideoSession() async throws {
        return try await withCheckedThrowingContinuation({ [weak self] continuation in
            // Configure session on custom queue
            queue.async { [weak self] in

                guard let weakSelf = self else {
                    continuation.resume(throwing: ScannerError.operationFailed)
                    return
                }
                
                // Create instance of capture session
                let session = AVCaptureSession()
                session.beginConfiguration()
                
                // Create and configure AVCaptureDeviceInput instance
                let captureDevice: AVCaptureDevice
                let videoInput: AVCaptureDeviceInput
                
                do {
                    captureDevice = try AVCaptureDevice.captureDevice(in: .back)
                    videoInput = try AVCaptureDeviceInput(device: captureDevice)
                } catch {
                    continuation.resume(throwing: ScannerError.operationFailed)
                    return
                }
                
                guard session.canAddInput(videoInput) else {
                    continuation.resume(throwing: ScannerError.operationFailed)
                    return
                }

                // Adding videoInput to session
                session.addInput(videoInput)
                
                // Create and configure AVCaptureMetadataOutput instance
                let output = AVCaptureMetadataOutput()
                guard session.canAddOutput(output) else {
                    continuation.resume(throwing: ScannerError.operationFailed)
                    return
                }
                
                // Adding output to session
                session.addOutput(output)

                // Subscribe to AVCaptureMetadataOutputObjectsDelegate.
                // Delegate method metadataOutput(_:didOutput:from:) will be triggered when code detected
                output.setMetadataObjectsDelegate(weakSelf, queue: weakSelf.queue)

                // Filter type of metadata objects forwarded to delegate
                output.metadataObjectTypes = [.qr]

                // Apply changes
                session.commitConfiguration()

                weakSelf.session = session
                continuation.resume(returning: ())
            }
        })
    }
}

That was a lot of code; below are a few noteworthy highlights:

  • Camera operations are processor intensive, to avoid main thread blocking we're configuring the session on a custom queue
  • beginConfiguration() and commitConfiguration() mark the beginning / end of changes to capture session’s configuration, this allows to apply changes in a single atomic update.
  • output.metadataObjectTypes allows to filter type of metadata objects forwarded to delegate; for all available types see official doc.

If you try to build the project now, you'll get a few compiler errors. To resolve errors, we need to confirm to AVCaptureMetadataOutputObjectsDelegate and define captureDevice(in:) method, let's take care of that now.

Open CameraService, confirm to AVCaptureMetadataOutputObjectsDelegate.

extension CameraService: AVCaptureMetadataOutputObjectsDelegate {
    func metadataOutput(_ output: AVCaptureMetadataOutput, 
        didOutput metadataObjects: [AVMetadataObject], 
        from connection: AVCaptureConnection) {
            // to be implemented
    }
}

Add captureDevice(in:) method as AVCaptureDevice extension

extension AVCaptureDevice {
    static func captureDevice(in position: AVCaptureDevice.Position) throws -> AVCaptureDevice {
        let session = AVCaptureDevice.DiscoverySession(
            deviceTypes: [.builtInTrueDepthCamera, .builtInDualCamera, .builtInWideAngleCamera],
            mediaType: .video,
            position: .unspecified)
        guard let device = session.devices.first(where: { $0.position == position }) else { throw ScannerError.cameraUnavailable }
        return device
    }
}

captureDevice(position:) returns available capture device in the specified position, for more information see article Choosing a Capture Device.

Running Session

For the session to start/stop running, we need to call startRunning() and stopRunning() methods on a capture session respectively. One caveat to remember is that any changes to the session must be performed on the queue on which session was created. So lets add startRunning() and stopRunning() methods to the CameraService, which will take care of starting and stopping session on the correct queue.

extension CameraService {
    public func startRunning() async throws {
        return try await withCheckedThrowingContinuation({ [weak self] continuation in
            queue.async { [weak self] in
                
                guard let session = self?.session, !session.isRunning else {
                    continuation.resume(throwing: ScannerError.operationFailed)
                    return
                }
                
                session.startRunning()
                continuation.resume(returning: ())
            }
        })
    }

    public func stopRunning() async throws {
        return try await withCheckedThrowingContinuation({ [weak self] continuation in
            queue.async { [weak self] in
                
                guard let session = self?.session, session.isRunning else {
                    continuation.resume(throwing: ScannerError.operationFailed)
                    return
                }
                
                session.stopRunning()
                continuation.resume(returning: ())
            }
        })
    }
}

The capture session is ready for a spin; let's next configure camera preview to see it in action.

Configure camera preview

We're ready to wire up the capture session to the preview layer. As SwiftUI does not natively support AVCaptureSession preview, we'll create UIKit controller and bridge it back to SwiftUI, let's do that now:

  • Create new ScannerViewController as subclass of UIViewController and select Also create NIB file checkmark.
  • Next update ScannerView to confirm to UIViewControllerRepresentable protocol and add required methods.
  • In makeUIViewController(context:) create and return instance of ScannerViewController
struct ScannerView: UIViewControllerRepresentable {

    func makeUIViewController(context: Context) -> ScannerViewController {
        let controller = ScannerViewController(nibName: String(describing: ScannerViewController.self), bundle: Bundle.main)
        return controller
    }
    
    func updateUIViewController(_ uiViewController: ScannerViewController,  context: Context) {
        
    }
}

To display camera output on the screen we will use AVCaptureVideoPreviewLayer, a Core Animation layer that shows video from a camera device. Create new ScannerPreviewView class as a subclass of UIView

  • Override layerClass to return AVCaptureVideoPreviewLayer
  • Create previewLayer variable and set videoGravity to .resizeAspectFill
  • Create session variable which we will use later to set video session to render camera preview
// See https://developer.apple.com/documentation/avfoundation/avcapturevideopreviewlayer
final class ScannerPreviewView: UIView {
    
    // Use AVCaptureVideoPreviewLayer as the view's backing layer
    override class var layerClass: AnyClass {
        AVCaptureVideoPreviewLayer.self
    }
    
    // Configure preview layer
    var previewLayer: AVCaptureVideoPreviewLayer {
        guard let preview = layer as? AVCaptureVideoPreviewLayer else { fatalError() }
        preview.videoGravity = .resizeAspectFill
        return preview
    }

    // Connect the layer to a capture session
    var session: AVCaptureSession? {
        get { previewLayer.session }
        set { previewLayer.session = newValue }
    }
}

Next we will connect capture session with ScannerPreviewView

  • Open ScannerViewController.nib file and set its root view class to ScannerPreviewView
  • Open ScannerViewController class and create a private instance of CameraService
  • Declare new method setupPreviewView() and assign camera service session to ScannerPreviewView session
private func setupPreviewView() throws {
    // Cast view to ScannerPreviewView
    guard let previewView = view as? ScannerPreviewView else {
        throw ScannerError.operationFailed
    }
    
    // Set preview session to previously configured session
    previewView.session = cameraService.session
}

Create setupCameraPreview() method to connect the code we have written so far.

func setupCameraPreview() {
    Task {
        do {
            // Check camera permissions, system will present
            // permissions alert if permission state .notDetermined.
            try await AVCaptureDevice.requestVideoPermissionIfNeeded()

            // Configure capture session
            try await cameraService.configureVideoSession()

            // Setup preview view
            try setupPreviewView()

            // Start session
            try await cameraService.startRunning()
        } catch {
            os_log("setupCameraPreview failed")
        }
    }
}

We have defind setupCameraPreview() method but it's not being called yet. In ScannerViewController find viewDidLoad() and call setupCameraPreview(). If you build and run the app on a physical device you should see camera preview.

Extract code value

When we configured AVCaptureMetadataOutput we set CameraService as the delegate of AVCaptureMetadataOutput. Whenever camera detects QR code, it will call metadataOutput(_:didOutput:from:) delegate method passing down metadata object(s) of abstract AVMetadataObject type. To access QR code data, we need to cast AVMetadataObject to AVMetadataMachineReadableCodeObject, which is QR code concrete type; for the full list of available metadata types see documentation. AVMetadataMachineReadableCodeObject, has a stringValue property, which as you may have guessed, is string value encoded in QR code. Open CameraService and update metadataOutput(_:didOutput:from:) method:

func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
    guard let code = metadataObjects.first(where: { $0.type == .qr }) as? AVMetadataMachineReadableCodeObject else { return }
    print(code.stringValue)
}

Now go ahead and run the app and point the camera to QR code; you should see decoded code value printed in Xcode console.

Providing visual feedback

When you run the app, you may notice that the decoded value is printed in the console, but no visual indicators are displayed around the detected code. In this section, we will take care of the visual outline around the detected code.

First, let's create a publisher which will publish code to all interested subscribers. Open CameraService and declare publisher:

let quickResponseCodePublisher = PassthroughSubject<AVMetadataMachineReadableCodeObject, Never>()

Update metadataOutput(_:didOutput:from:) to start publishing decoded code, after change method should looks like this:

func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
    guard let code = metadataObjects.first(where: { $0.type == .qr }) as? AVMetadataMachineReadableCodeObject else { return }
    quickResponseCodePublisher.send(code)
}

Subscribe to quickResponseCodePublisher in ScannerViewController

  • Open ScannerViewController and add private var subscriptions = Set<AnyCancellable>() variable
  • Create and implement configureSubscribers() method
  • Call configureSubscribers() in viewDidLoad()
private func configureSubscribers() {
    cameraService
        .quickResponseCodePublisher
        .receive(on: DispatchQueue.main)
        .sink(receiveValue: { [weak self] code in
            self?.handleDetectedQRCode(code: code)
        }).store(in: &subscriptions)
}

Next, let's define handleDetectedQRCode(code:); this method will convert objects coordinates and call function to draw line around the code.

private func handleDetectedQRCode(code: AVMetadataMachineReadableCodeObject) {
    do {
        let metadata = try transformCodeToViewCoordinates(code: code)
        // draw line
    } catch {
        assertionFailure(error.localizedDescription)
    }
}

Now define transformCodeToViewCoordinates(code:) method. This method will use transformedMetadataObject(for:) method on AVCaptureVideoPreviewLayer to convert object’s visual properties to layer coordinates.

private func transformCodeToViewCoordinates(code: AVMetadataMachineReadableCodeObject) throws -> AVMetadataMachineReadableCodeObject {
            
    guard let previewView = view as? ScannerPreviewView,
            let metadata = previewView.videoPreviewLayer?.transformedMetadataObject(for: code) as? AVMetadataMachineReadableCodeObject else {
        throw ScannerError.operationFailed
    }
    
    return metadata
}

At this point we have QR code coordinates converted to the layer coordinate space, next we need to build UI to draw the line around detected code. Create OutlineView class as a subclass of UIView

  • Define setupView() method which will set CAShapeLayer initial state
  • Define updatePositionIfNeeded(frame:) method which will update line position on frame change
  • Define hideOutlineView() method which will hide line
final class OutlineView: UIView {
    private var currentRect: CGRect = .zero
    private let outlineLayer = CAShapeLayer()

    override func awakeFromNib() {
        super.awakeFromNib()
        setupView()
    }
}

extension OutlineView {
    // Initial setup
    private func setupView() {
        outlineLayer.lineWidth = 4
        outlineLayer.strokeColor = UIColor(named: "outlineColor")?.cgColor
        outlineLayer.fillColor = UIColor.clear.cgColor
        layer.addSublayer(outlineLayer)
    }
}

extension OutlineView {
    // Update outline position when metadata frame changes
    func updatePositionIfNeeded(frame: CGRect) {
        guard currentRect != frame else { return }
        currentRect = frame
        let animation = CABasicAnimation(keyPath: "path")
        animation.duration = 0.1
        animation.isAdditive = true
        outlineLayer.path = UIBezierPath(roundedRect: frame, cornerRadius: 10).cgPath
        outlineLayer.add(animation, forKey: nil)
        if outlineLayer.opacity == 0 {
            outlineLayer.opacity = 1
        }
    }
    
    // Hide outline layer
    func hideOutlineView() {
        outlineLayer.opacity = 0
    }
}

Next let's integrate OutlineView into the app:

  • Open ScannerController.xib and add new UIView
  • Set Background Color of the newly added view to Clear
  • Update class of the newly added view to OutlineView
  • Set constraints of the OutlineView to match frame of ScannerController.xib
  • Open ScannerController.swift and create OutlineView IBOutlet
  • Find handleDetectedQRCode(code:) method update and OutlineView position when frame changes

The last thing that is left to do is to hide the line when QR code is no longer in frame. To hide the line, we will use another publisher with debounce operator, which will call hideOutlineView() after a specified time elapses.

Open ScannerController and:

  • Create new publisher let hideOutlinePublisher = PassthroughSubject<Void, Never>()
  • Subscribe to hideOutlinePublisher in configureSubscribers()

After changes configureSubscribers() method should look like this:

private func configureSubscribers() {
    cameraService
        .quickResponseCodePublisher
        .receive(on: DispatchQueue.main)
        .sink(receiveValue: { [weak self] code in
            self?.handleDetectedQRCode(code: code)
        }).store(in: &subscriptions)

    hideOutlinePublisher
        .debounce(for: .seconds(0.7), scheduler: DispatchQueue.main)
        .sink(receiveValue: { [weak self] code in
            self?.outlineView.hideOutlineView()
    }).store(in: &subscriptions)
}

Finally, find handleDetectedQRCode(code:) and make following updates:

  • Call updatePositionIfNeeded(frame:) to update frame on change
  • Call hideOutlinePublisher.send() to reset the publisher timer whenever code is detected

After changes handleDetectedQRCode(code:) method should look like this:

private func handleDetectedQRCode(code: AVMetadataMachineReadableCodeObject) {
    do {
        let metadata = try transformCodeToViewCoordinates(code: code)
        outlineView.updatePositionIfNeeded(frame: metadata.bounds)
        hideOutlinePublisher.send()
    } catch {
        assertionFailure(error.localizedDescription)
    }
}

Build and run the project; now line should be drawn around the detected code and disappear when QR code gets out of the frame.

Final thoughts

In the code above we omitted error handling, in real app errors would have to be properly handled. To download and explore final project click the button below.