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.
DataScannerViewController
which is a configurable controller that handles machine-readable codes.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.
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>
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.
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:
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.
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.
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:
ScannerViewController
as subclass of UIViewController
and select Also create NIB file checkmark.ScannerView
to confirm to UIViewControllerRepresentable
protocol and add required methods.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
layerClass
to return AVCaptureVideoPreviewLayer
previewLayer
variable and set videoGravity
to .resizeAspectFill
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
ScannerViewController.nib
file and set its root view class to ScannerPreviewView
ScannerViewController
class and create a private instance of CameraService
setupPreviewView()
and assign camera service session to ScannerPreviewView
sessionprivate 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.
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.
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
ScannerViewController
and add private var subscriptions = Set<AnyCancellable>()
variableconfigureSubscribers()
methodconfigureSubscribers()
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
setupView()
method which will set CAShapeLayer
initial stateupdatePositionIfNeeded(frame:)
method which will update line position on frame changehideOutlineView()
method which will hide linefinal 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:
ScannerController.xib
and add new UIView
Background Color
of the newly added view to Clear
OutlineView
OutlineView
to match frame of ScannerController.xib
ScannerController.swift
and create OutlineView
IBOutlethandleDetectedQRCode(code:)
method update and OutlineView
position when frame changesThe 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:
let hideOutlinePublisher = PassthroughSubject<Void, Never>()
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:
updatePositionIfNeeded(frame:)
to update frame on changehideOutlinePublisher.send()
to reset the publisher timer whenever code is detectedAfter 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.
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.