【Swift/SwiftUI】QRコード読み取り範囲指定

目次

概要

前回はQRコードの読み取りの実装を行った。
しかし、QRコードリーダーのアプリは範囲が指定されているものが多い。今回はその範囲指定を行う。
また、実験のため、今回は読み取ったQRコードを緑色の線で囲むこともしている。
Swiftでの実装は多かったが、SwiftUIでの実装は少なかったため記載。

ソースコード

TestApp.swift

import SwiftUI

@main
struct TestApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
    var body: some Scene {
        WindowGroup {
            QrScanView()
        }
    }
}

QrScanView.swift

import SwiftUI

struct QrScanView: View {
    @ObservedObject private(set) var viewModel = QrScanViewModel()
    
    var body: some View {
        ZStack {
            QrCapturePreview(qrScanViewModel: viewModel)
            qrFrame()
            qrScanableArea()
        }
        .frame(width: viewModel.cameraArea.width,
               height: viewModel.cameraArea.height)
    }
    
    private func qrFrame() -> some View {
        return Path { path in
            for readQrcodeArea in viewModel.readQrcodeArea {
                path.move(to: readQrcodeArea[0])
                for (index, _) in readQrcodeArea.enumerated() {
                    let readIndex = (index + 1) % readQrcodeArea.count
                    path.addLine(to: readQrcodeArea[readIndex])
                }
            }
        }
        .stroke(lineWidth: 4)
        .fill(Color.green)
    }
    
    private func qrScanableArea() -> some View {
        return ZStack {}
            .frame(width: viewModel.detectArea.width,
                   height: viewModel.detectArea.height)
            .border(.red, width: 2)
    }
}

struct QrCapturePreview: UIViewRepresentable {
    private var qrScanViewModel: QrScanViewModel
    
    init(qrScanViewModel: QrScanViewModel) {
        self.qrScanViewModel = qrScanViewModel
    }
    
    public func makeUIView(context: Context) -> some UIView {
        
        let cameraView = qrScanViewModel.previewView
        cameraView.frame = UIScreen.main.bounds
        
        self.qrScanViewModel.launchQrcodeReader()
        
        return cameraView
    }
    
    func updateUIView(_ uiView: UIViewType, context: Context) {
        
    }
}

QrScanViewModel.swift

import AVFoundation
import Foundation
import UIKit

class QrScanViewModel: NSObject, ObservableObject {
    @Published var isShownDialog = false
    @Published private(set) var qrString = ""
    @Published private(set) var readQrcodeArea: [[CGPoint]] = []
    
    var previewView: UIView = UIView()
    let cameraArea: CGSize
    
    private let metadataOutput = AVCaptureMetadataOutput()
    private let qrDetectAreaSize: CGFloat = 200.0
    private(set) var detectArea: CGRect
    
    private var session: AVCaptureSession?
    private var videoPreviewLayer: AVCaptureVideoPreviewLayer
    
    override init() {
        self.cameraArea = CGSize(width: UIScreen.main.bounds.width - 80,
                                height: UIScreen.main.bounds.height - 180)
        self.detectArea = CGRect(x: (cameraArea.width - qrDetectAreaSize) * 0.5,
                                 y: (cameraArea.height - qrDetectAreaSize) * 0.5,
                                 width: qrDetectAreaSize,
                                 height: qrDetectAreaSize)
        self.videoPreviewLayer = AVCaptureVideoPreviewLayer()
        
        super.init()
    }
    
    func launchQrcodeReader() {
        if let session = self.session {
            DispatchQueue.global(qos: .background).async {
                session.startRunning()
            }
            return
        }
        
        let session = AVCaptureSession()
        guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
              let deviceInput = try? AVCaptureDeviceInput(device: device) else {
            return
        }
        
        switch UIDevice.current.userInterfaceIdiom {
        case .phone:
            session.sessionPreset = .high
        default:
            session.sessionPreset = .photo
        }
        
        if session.canAddInput(deviceInput) {
            session.addInput(deviceInput)
        }
        if session.canAddOutput(metadataOutput) {
            session.addOutput(metadataOutput)
        }
        
        metadataOutput.metadataObjectTypes = [.qr]
        metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
        showCameraView(session: session)
        
        DispatchQueue.global(qos: .background).async {
            session.startRunning()
        }
        
        self.session = session
    }
    
    private func showCameraView(session: AVCaptureSession) {
        DispatchQueue.main.async {
            self.videoPreviewLayer = AVCaptureVideoPreviewLayer(session: session)
            self.videoPreviewLayer.videoGravity = .resizeAspectFill
            self.videoPreviewLayer.connection?.videoOrientation = .portrait
            self.videoPreviewLayer.frame = self.previewView.bounds
            let metadataOutputRectOfInterest = CGRect(x: self.detectArea.minY / self.cameraArea.height ,
                                                      y: self.detectArea.minX / self.cameraArea.width,
                                                      width: self.detectArea.height / self.cameraArea.height,
                                                      height: self.detectArea.width / self.cameraArea.width)
            self.metadataOutput.rectOfInterest = metadataOutputRectOfInterest
            self.previewView.layer.insertSublayer(self.videoPreviewLayer, at: 0)
        }
    }
}

extension QrScanViewModel: AVCaptureMetadataOutputObjectsDelegate {
    func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
        
        self.readQrcodeArea.removeAll()
        
        for metadataObject in metadataObjects {
            guard let machineReadableCode = metadataObject as? AVMetadataMachineReadableCodeObject,
                  machineReadableCode.type == .qr,
            let stringValue = machineReadableCode.stringValue else {
                return
            }
            guard let transformedObject = self.videoPreviewLayer.transformedMetadataObject(for: metadataObject) as? AVMetadataMachineReadableCodeObject else {
                return
            }
            self.readQrcodeArea.append(transformedObject.corners)
            self.qrString = stringValue
        }
    }
}

デモ動画

詳細

これはSwiftUIでかつ画面表示側と処理側に分けるとなかなか調べても出てこない。
今回重要になっているのは以下だ。

View表示側

var body: some View {
    ZStack {
        QrCapturePreview(qrScanViewModel: viewModel)
        qrScanableArea()
    }
    .frame(width: viewModel.cameraArea.width,
           height: viewModel.cameraArea.height)
}


private func qrScanableArea() -> some View {
    return ZStack {}
        .frame(width: viewModel.detectArea.width,
               height: viewModel.detectArea.height)
        .border(.red, width: 2)
}

まずはView表示側から。
これは単純で、空白のViewをサイズと境界線を指定して描画することだ。
qrScanableAreaメソッド内でサイズを設定するframe、境界線を設定するborderを設定することでViewを描画。

処理部分

以下は全て同じファイル内で行われている

読み取り範囲のサイズを指定

private let qrDetectAreaSize: CGFloat = 200.0

カメラから取得した映像を表示する領域(cameraArea)と、QRコードの読み取り範囲(detectArea)を指定。これらは画面に表示に関係あるもの。

override init() {
    self.cameraArea = CGSize(width: UIScreen.main.bounds.width - 80,
                             height: UIScreen.main.bounds.height - 180)
    self.detectArea = CGRect(x: (cameraArea.width - qrDetectAreaSize) * 0.5,
                             y: (cameraArea.height - qrDetectAreaSize) * 0.5,
                             width: qrDetectAreaSize,
                             height: qrDetectAreaSize)
    self.videoPreviewLayer = AVCaptureVideoPreviewLayer()
    
    super.init()
}

カメラ側にQRコードの読み取り範囲(rectOfInterest)を指定

let metadataOutputRectOfInterest = CGRect(x: self.detectArea.minY / self.cameraArea.height ,
                                          y: self.detectArea.minX / self.cameraArea.width,
                                          width: self.detectArea.height / self.cameraArea.height,
                                          height: self.detectArea.width / self.cameraArea.width)
self.metadataOutput.rectOfInterest = metadataOutputRectOfInterest

以上でできる。

detectAreaはいわゆるデモ動画の赤枠の部分だ。
つまり、この枠の中に表示されているQRコードが読み取りの対象となるQRコードだ。
こうすることで、読み取るつもりのなかったQRコードの読み取り処理を防ぐのに使える。
しかし、これだけでは表示だけのお飾りにすぎない。

そこで必要になるのが、rectOfInterestに読み取り範囲を設定すること。
こうすることでQRコードの読み取り領域を設定するのだが、
注意点として、これは水平方向、垂直方向の値の範囲が共に「0.0~1.0」になっていること。
つまり、上記の処理のように位置、大きさをカメラの映像を表示しているサイズで割ってあげる必要がある。

もう一つの注意点として、x, yの指定が逆になっていること。
コードを見ればわかるが、
水平方向(x, width)に関する部分に垂直方向の情報(y, height)が、
垂直方向(y, height)に関する部分に本来水平方向の情報(x, width)が格納されている。
これはこの領域の設定が画面を横向きにしていることを前提にしているからだ。

上記の点させ注意すればQRコード読み取り部分のしてもできる。
読み取ったQRコードを緑色の線で囲む点はまた次回記載しよう。

参考ページ