【iOS/SwiftUI】読み取ったQRコードにエフェクトをつける

目次

概要

QRコードを読み取る実装は前回のページに記載した。
ただ、どのQRコードを読み取ったか現在ではわからない。
そのため、読み取ったQRコードにエフェクトをつけて、どのQRコードを読み取ったか視覚的にわかるようにしよう。

ちなみに、このコードで読み取り対象となる範囲に入っている複数のQRコードに対してエフェクトをつけられる。
今回のエフェクトは緑色の線だ。

ソースコード

アプリ実行部分

import SwiftUI

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

View表示部分

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) {
        
    }
}

処理部分

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
        }
    }
}

デモ動画

詳細

基本的にコードは前回と同じ。
ただ、今回はQRコードを読み取った時に読み取ったQRコードを四角で囲う。
今回は四角で囲んだが、半透明の四角形で塗りつぶし、URLのポップアップなど、そのエフェクトは自由。

読み取ったバーコードを四角で囲う

var body: some View {
    ZStack {
        QrCapturePreview(qrScanViewModel: viewModel)
        qrFrame()
        qrScanableArea()
        DialogView()
    }
    .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)
}

まずはViewの表示部分から。
こちらは線を引いて描画している。処理部分の方で取得したQRコードの四隅の座標(後述)を元に線を描画している。

中身は簡単。
まず、以下のようにPathで囲む。このPathの中に線を描画処理を描いていく。

Path { path in
    // ここに描画する
}

そして、まずは以下のようにpath.moveメソッドで始点となる座標を設定する。

path.move(to: readQrcodeArea[0])

そして、path.addLineメソッドを使って、線を描画していく。

path.addLine(to: readQrcodeArea[readIndex])

こうすることでQRコードを四角く囲っている。

そして、色や線の太さは以下のように設定している。

.stroke(lineWidth: 4)
.fill(Color.green)

では、今度はQRコードの四隅の座標を取得しよう。
まず、QRコードを認識したタイミングで描画処理を行うので、処理を書く場所はmetadataOutputメソッドの中だ。

@Published private(set) var readQrcodeArea: [[CGPoint]] = []

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
    }
}

この中で大事なのは以下の部分だ。

guard let transformedObject = self.videoPreviewLayer.transformedMetadataObject(for: metadataObject) as? AVMetadataMachineReadableCodeObject else {
    return
}
self.readQrcodeArea.append(transformedObject.corners)

まず、カメラを取得している映像のオブジェクトのtransformedMetadataObjectメソッドを使用してmetadataObjectの座標を取得する。
そして、取得した情報をPublishedで宣言した読み取ったQRコードの座標を格納する。
Publishedにしておけば、値が変更された時にView側でその更新された値でViewの描画を更新してくれる。

参考ページ