본문 바로가기
iOS_Swift 앱개발👍

[iOS_Swift] AVFoundation, QRCodeReader기 만들기! _ 37

by 개발하는윤기사 2023. 10. 17.
728x90
반응형

 

안녕하세요!! 개발하는 윤기사입니다!!

 

이번 포스팅에서는 AVFoundation의 CaptureSession을 이용한 QR_Code 리더기를 만들어 볼 건데요!!

 

시작하기에 앞서 짧게 요약하자면! 제한된 영역만을 스캔하도록 만든 QR 리더기입니다. 

 

바로 시작해볼게요~~

 

 

 

그래서 AVFoundation 너 누군데...

다양한 Apple 플랫폼에서 사운드 및 영상 미디어의 처리, 제어, 가져오기 및 내보내기 등 광범위한 기능을 제공하는 프레임워크

Apple 공식문서를 확인해 보면 "카메라", "오디오"라는 단어를 포함하고 있는 걸 확인하실 수 있어요!

 

말 그대로, 영상 & 사운드 미디어 재생과 생성을 할 수 있게 해주는 프레임 워크입니다!

 

여기서 우리는 카메라를 이용해 QR코드를 읽을 거니까, AVCaptureSession을 이용할 거예요!

 

 

AVCaptureSession 너는 또 누군데...

캡처 동작을 구성하고 입력 장치의 데이터 흐름을 조정하여 출력을 캡처하는 개체입니다.

Apple 공식문서를 확인해 보면 "캡처 동작", "입력 장치", "출력 장치", "데이터 흐름"이라는 단어가 보이시나요?

 

그렇습니다! 저희가 필요한 'QRCode 캡처와 '카메라 입력', 'QRCode를 읽었을 때의 출력'

 

모두 해당되죠?

 

AVCaptureSession을 한 번 다뤄보겠습니다 ^_^

 

 

 

 

자세하게 설명하기에 앞서, 아래와 같은 순서를 갖고 있습니다!!

1. 실시간 캡처를 수행하기 위해서 AVCaptureSession 개체를 인스턴스화.
2. 제한할 영역 설정
3. 적절한 inputs 설정(카메라)
4. 적절한 outputs 설정(출력값)
5. 카메라 영상을 추가하기 위한 AVCaptureVideoPreviewLayer 추가
6. 제한 영역 선 추가
7. Capture Output에서 생성된 MetaData를 다룰 수 있는 AVCaptureMetadataOutputObjectsDelegate 선언
8. captureSession 시작

(+) 휴대폰 플래시 On/Off 기능도 추가

 

 

하나하나 코드와 함께 짚어보도록 할게요!

 

 

1️⃣ 실시간 캡처를 수행하기 위해서 AVCaptureSession 개체를 인스턴스화

import AVFoundation

private var captureSession = AVCaptureSession()

 

2️⃣ 제한할 영역 설정

 do {

    let rectOfInterest = CGRect(x: (UIScreen.main.bounds.width - 200) / 2 , y: (UIScreen.main.bounds.height - 220) / 2, width: 200, height: 200)

    ...
    // input 설정, output 설정, 제한 영역 선 추가 등
    ...
    
}
catch {
    print("error")
}

 

3️⃣ 적절한 inputs 설정(카메라)

- AVCaptureDevice.default()의 설정, DeviceType과 MediaType을 정해주어야 함!

- QR 코드를 인식하기 위해선 동영상처럼 움직이니까 MediaType을. video로 해주어야 함.

guard let captureDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { return }

let input = try AVCaptureDeviceInput(device: captureDevice)
captureSession.addInput(input)

 

- AVCaptureDevice.DeviceType

 

AVCaptureDevice.DeviceType | Apple Developer Documentation

A structure that defines the device types the framework supports.

developer.apple.com

- AVMediaType

 

AVMediaType | Apple Developer Documentation

An identifier for various media types.

developer.apple.com

 

 

4️⃣ 적절한 outputs 설정(QR타입)

- output.metadataObjectType을 .qr로 설정!

- setMetadataObjectDelegate의 queue는 main에서 실행되어야 한다!

let output = AVCaptureMetadataOutput()
captureSession.addOutput(output)

output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
output.metadataObjectTypes = [AVMetadataObject.ObjectType.qr]

 

 

 

5️⃣  카메라 영상을 추가하기 위한 AVCaptureVideoPreviewLayer 추가

- AVCaptureVideoPreviewLayer : CALayer의 서브클래스로 입력 장치로부터 들어오는 비디오 화면을 표시하기 위해서 사용

let rectConverted = setVideoLayer(rectOfInterest: rectOfInterest)
            
// rectOfInterest를 설정(=제한영역 설정 완료)
output.rectOfInterest = rectConverted

// 정사각형 가이드 라인 추가
setGuideCrossLineView(rectOfInterest: rectOfInterest)

- AVCaptureVideoPreviewLayer.metadataOutputRectConverted(_:)

  • preview layer의 metadata ouputs에 사용되는 좌표계의 사각형으로 변환.
private var previewLayer: AVCaptureVideoPreviewLayer?

private func setVideoLayer(rectOfInterest: CGRect) -> CGRect {
    //영상을 담을 공간.
    let videoLayer = AVCaptureVideoPreviewLayer(session: captureSession)
    //카메라의 크기 지정
    videoLayer.frame = view.layer.bounds
    //카메라의 비율지정
    videoLayer.videoGravity = AVLayerVideoGravity.resizeAspectFill
    view.layer.addSublayer(videoLayer)

    self.previewLayer = videoLayer
    return videoLayer.metadataOutputRectConverted(fromLayerRect: rectOfInterest)
}

 

 

6️⃣ 제한 영역 선 추가

  1. 모서리의 시작 지점을 설정
  2. 선을 그리기 위해 move(to:)addLine(to:) 메서드 사용
    • move(to:) : 시작 지점 설정
    • addLine(to:) : 선 그리기
  3. UIBezierPath()에 대한 설정, 선의 두께 및 색상을 적용
  4. stroke() 메서드 호출하여 선 그리기
  5. CAShapeLayer를 사용하여 UIBezierPath로 그린 모서리를 화면에 추가
 private func setGuideCrossLineView(rectOfInterest: CGRect) {
        
        let cornerLength: CGFloat = 20
        let cornerLineWidth: CGFloat = 5
        
        // 가이드라인의 각 모서리 point
        let upperLeftPoint = CGPoint(x: rectOfInterest.minX, y: rectOfInterest.minY)
        let upperRightPoint = CGPoint(x: rectOfInterest.maxX, y: rectOfInterest.minY)
        let lowerRightPoint = CGPoint(x: rectOfInterest.maxX, y: rectOfInterest.maxY)
        let lowerLeftPoint = CGPoint(x: rectOfInterest.minX, y: rectOfInterest.maxY)
        
        // 각 모서리를 중심으로 한 Edge 그리기
        let upperLeftCorner = UIBezierPath()
        upperLeftCorner.lineWidth = cornerLineWidth
        upperLeftCorner.move(to: CGPoint(x: upperLeftPoint.x + cornerLength, y: upperLeftPoint.y))
        upperLeftCorner.addLine(to: CGPoint(x: upperLeftPoint.x, y: upperLeftPoint.y))
        upperLeftCorner.addLine(to: CGPoint(x: upperLeftPoint.x, y: upperLeftPoint.y + cornerLength))
        
        let upperRightCorner = UIBezierPath()
        upperRightCorner.lineWidth = cornerLineWidth
        upperRightCorner.move(to: CGPoint(x: upperRightPoint.x - cornerLength, y: upperRightPoint.y))
        upperRightCorner.addLine(to: CGPoint(x: upperRightPoint.x, y: upperRightPoint.y))
        upperRightCorner.addLine(to: CGPoint(x: upperRightPoint.x, y: upperRightPoint.y + cornerLength))
        
        let lowerRightCorner = UIBezierPath()
        lowerRightCorner.lineWidth = cornerLineWidth
        lowerRightCorner.move(to: CGPoint(x: lowerRightPoint.x, y: lowerRightPoint.y - cornerLength))
        lowerRightCorner.addLine(to: CGPoint(x: lowerRightPoint.x, y: lowerRightPoint.y))
        lowerRightCorner.addLine(to: CGPoint(x: lowerRightPoint.x - cornerLength, y: lowerRightPoint.y))
        
        let lowerLeftCorner = UIBezierPath()
        lowerLeftCorner.lineWidth = cornerLineWidth
        lowerLeftCorner.move(to: CGPoint(x: lowerLeftPoint.x + cornerLength, y: lowerLeftPoint.y))
        lowerLeftCorner.addLine(to: CGPoint(x: lowerLeftPoint.x, y: lowerLeftPoint.y))
        lowerLeftCorner.addLine(to: CGPoint(x: lowerLeftPoint.x, y: lowerLeftPoint.y - cornerLength))
        
        upperLeftCorner.stroke()
        upperRightCorner.stroke()
        lowerRightCorner.stroke()
        lowerLeftCorner.stroke()
        
        // subLayer에 선 추가
        let upperLeftCornerLayer = createCornerLayer(path: upperLeftCorner, lineWidth: cornerLineWidth)
        let upperRightCornerLayer = createCornerLayer(path: upperRightCorner, lineWidth: cornerLineWidth)
        let lowerRightCornerLayer = createCornerLayer(path: lowerRightCorner, lineWidth: cornerLineWidth)
        let lowerLeftCornerLayer = createCornerLayer(path: lowerLeftCorner, lineWidth: cornerLineWidth)
        
        view.layer.addSublayer(upperLeftCornerLayer)
        view.layer.addSublayer(upperRightCornerLayer)
        view.layer.addSublayer(lowerRightCornerLayer)
        view.layer.addSublayer(lowerLeftCornerLayer)
        
    }
    
    func createCornerLayer(path: UIBezierPath, lineWidth: CGFloat) -> CAShapeLayer {
        let cornerLayer = CAShapeLayer()
        cornerLayer.path = path.cgPath
        cornerLayer.strokeColor = UIColor.green.withAlphaComponent(0.8).cgColor
        cornerLayer.fillColor = UIColor.clear.cgColor
        cornerLayer.lineWidth = lineWidth
        return cornerLayer
    }

 

 

 

7️⃣ Capture Output에서 생성된 MetaData를 다룰 수 있는 AVCaptureMetadataOutputObjectsDelegate

- 읽은 QR code의 metadata값이 http:// or https://로 시작하는지에 대한 분기처리를 해줌.

func metadataOutput(_ captureOutput: AVCaptureMetadataOutput,
                    didOutput metadataObjects: [AVMetadataObject],
                    from connection: AVCaptureConnection) {
                    
    //QR code 스캔 결과의 metadata
    if let metadataObject = metadataObjects.first {
		
        //읽은 metaData를 읽을 수 있는 형식으로 변환
        guard let readableObject = previewLayer?.transformedMetadataObject(for: metadataObject) as? AVMetadataMachineReadableCodeObject, metadataObject.type == .qr else { return }
        guard let stringValue = readableObject.stringValue else {
        	return }

        if stringValue.hasPrefix("http://") || stringValue.hasPrefix("https://")  {
            print(stringValue)
            let scanned = URL(string: stringValue)
            let safari = SFSafariViewController(url: scanned!)
            safari.delegate = self
            safari.modalPresentationStyle = .formSheet

            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: {
                self.present(safari, animated: true, completion: nil)
            })

        } else {
            showAlert(code: stringValue)
        }
    }
}

 

 

8️⃣ captureSession 시작

- CaptureSession의 startRunning()은 Background에서 실행이 되어야 함!!

DispatchQueue.global(qos: .background).async { [weak self] in
    guard let self else { return }
    self.captureSession.startRunning()
}

 

 

⭐️ 휴대폰 플래시 On/Off 기능도 추가

- AVCaptureDevice의 hasTorch 기능으로 플래시 On/Off를 구현할 수 있다!!

@objc func toggleFlash() {
    if let device = AVCaptureDevice.default(for: AVMediaType.video) {
        if device.hasTorch {
            do {
                try device.lockForConfiguration()
                if device.torchMode == .off {
                    try device.setTorchModeOn(level: 1.0)
                } else {
                    device.torchMode = .off
                }
                device.unlockForConfiguration()
            } catch {
                print("플래시를 설정하는 동안 오류가 발생했습니다: \(error.localizedDescription)")
            }
        } else {
            print("이 디바이스는 플래시를 지원하지 않습니다.")
        }
    } else {
        print("카메라 장치를 찾을 수 없습니다.")
    }
}

 

 

⭐️ 전체코드

import UIKit
import AVFoundation
import SafariServices

import SnapKit

final class QRNativeViewController: BaseViewController {
    
    private var captureSession = AVCaptureSession()
    
    private var previewLayer: AVCaptureVideoPreviewLayer?
    
    let cornerLength: CGFloat = 20
    let cornerLineWidth: CGFloat = 5
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "bolt.square"), style: .plain, target: self, action: #selector(toggleFlash))
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        setupQRReader()
    }
}

extension QRNativeViewController  {
    
    private func setupQRReader() {
        
        guard let captureDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else { return }
        
        do {
            
            let rectOfInterest = CGRect(x: (UIScreen.main.bounds.width - 200) / 2 , y: (UIScreen.main.bounds.height - 220) / 2, width: 200, height: 200)
            
            let input = try AVCaptureDeviceInput(device: captureDevice)
            captureSession.addInput(input)
            let output = AVCaptureMetadataOutput()
            captureSession.addOutput(output)
            
            output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
            output.metadataObjectTypes = [AVMetadataObject.ObjectType.qr]
            
            let rectConverted = setVideoLayer(rectOfInterest: rectOfInterest)
            
            output.rectOfInterest = rectConverted
 
            setGuideCrossLineView(rectOfInterest: rectOfInterest)
            
            DispatchQueue.global(qos: .background).async { [weak self] in
                guard let self else { return }
                self.captureSession.startRunning()
            }
        }
        catch {
            print("error")
        }
    }
    
    private func setVideoLayer(rectOfInterest: CGRect) -> CGRect {
     
        let videoLayer = AVCaptureVideoPreviewLayer(session: captureSession)
        videoLayer.frame = view.layer.bounds
        videoLayer.videoGravity = AVLayerVideoGravity.resizeAspectFill
        view.layer.addSublayer(videoLayer)
        
        self.previewLayer = videoLayer
        return videoLayer.metadataOutputRectConverted(fromLayerRect: rectOfInterest)
    }
    
    private func setGuideCrossLineView(rectOfInterest: CGRect) {
        
        let cornerLength: CGFloat = 20
        let cornerLineWidth: CGFloat = 5
        
        let upperLeftPoint = CGPoint(x: rectOfInterest.minX, y: rectOfInterest.minY)
        let upperRightPoint = CGPoint(x: rectOfInterest.maxX, y: rectOfInterest.minY)
        let lowerRightPoint = CGPoint(x: rectOfInterest.maxX, y: rectOfInterest.maxY)
        let lowerLeftPoint = CGPoint(x: rectOfInterest.minX, y: rectOfInterest.maxY)
        
        let upperLeftCorner = UIBezierPath()
        upperLeftCorner.lineWidth = cornerLineWidth
        upperLeftCorner.move(to: CGPoint(x: upperLeftPoint.x + cornerLength, y: upperLeftPoint.y))
        upperLeftCorner.addLine(to: CGPoint(x: upperLeftPoint.x, y: upperLeftPoint.y))
        upperLeftCorner.addLine(to: CGPoint(x: upperLeftPoint.x, y: upperLeftPoint.y + cornerLength))
        
        let upperRightCorner = UIBezierPath()
        upperRightCorner.lineWidth = cornerLineWidth
        upperRightCorner.move(to: CGPoint(x: upperRightPoint.x - cornerLength, y: upperRightPoint.y))
        upperRightCorner.addLine(to: CGPoint(x: upperRightPoint.x, y: upperRightPoint.y))
        upperRightCorner.addLine(to: CGPoint(x: upperRightPoint.x, y: upperRightPoint.y + cornerLength))
        
        let lowerRightCorner = UIBezierPath()
        lowerRightCorner.lineWidth = cornerLineWidth
        lowerRightCorner.move(to: CGPoint(x: lowerRightPoint.x, y: lowerRightPoint.y - cornerLength))
        lowerRightCorner.addLine(to: CGPoint(x: lowerRightPoint.x, y: lowerRightPoint.y))
        lowerRightCorner.addLine(to: CGPoint(x: lowerRightPoint.x - cornerLength, y: lowerRightPoint.y))
        
        let lowerLeftCorner = UIBezierPath()
        lowerLeftCorner.lineWidth = cornerLineWidth
        lowerLeftCorner.move(to: CGPoint(x: lowerLeftPoint.x + cornerLength, y: lowerLeftPoint.y))
        lowerLeftCorner.addLine(to: CGPoint(x: lowerLeftPoint.x, y: lowerLeftPoint.y))
        lowerLeftCorner.addLine(to: CGPoint(x: lowerLeftPoint.x, y: lowerLeftPoint.y - cornerLength))
        
        upperLeftCorner.stroke()
        upperRightCorner.stroke()
        lowerRightCorner.stroke()
        lowerLeftCorner.stroke()
        
        let upperLeftCornerLayer = createCornerLayer(path: upperLeftCorner, lineWidth: cornerLineWidth)
        let upperRightCornerLayer = createCornerLayer(path: upperRightCorner, lineWidth: cornerLineWidth)
        let lowerRightCornerLayer = createCornerLayer(path: lowerRightCorner, lineWidth: cornerLineWidth)
        let lowerLeftCornerLayer = createCornerLayer(path: lowerLeftCorner, lineWidth: cornerLineWidth)
        
        view.layer.addSublayer(upperLeftCornerLayer)
        view.layer.addSublayer(upperRightCornerLayer)
        view.layer.addSublayer(lowerRightCornerLayer)
        view.layer.addSublayer(lowerLeftCornerLayer)
        
    }
    
    func createCornerLayer(path: UIBezierPath, lineWidth: CGFloat) -> CAShapeLayer {
        let cornerLayer = CAShapeLayer()
        cornerLayer.path = path.cgPath
        cornerLayer.strokeColor = UIColor.green.withAlphaComponent(0.8).cgColor
        cornerLayer.fillColor = UIColor.clear.cgColor
        cornerLayer.lineWidth = lineWidth
        return cornerLayer
    }

}

extension QRNativeViewController: AVCaptureMetadataOutputObjectsDelegate, SFSafariViewControllerDelegate {
    
    func metadataOutput(_ captureOutput: AVCaptureMetadataOutput,
                        didOutput metadataObjects: [AVMetadataObject],
                        from connection: AVCaptureConnection) {
        
        if let metadataObject = metadataObjects.first {
            
            guard let readableObject = previewLayer?.transformedMetadataObject(for: metadataObject) as? AVMetadataMachineReadableCodeObject, metadataObject.type == .qr else { return }
            
            guard let stringValue = readableObject.stringValue else { return }
            if stringValue.hasPrefix("http://") || stringValue.hasPrefix("https://")  {
                print(stringValue)
                let scanned = URL(string: stringValue)
                let safari = SFSafariViewController(url: scanned!)
                safari.delegate = self
                safari.modalPresentationStyle = .formSheet
                DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: {
                    self.present(safari, animated: true, completion: nil)
                })
                
                delegate?.readerComplete(status: .success("200"))
                
            } else {
                showAlert(code: stringValue)
            }
        }
    }
}

extension QRNativeViewController {
    
    private func showAlert(code: String) {
        let alertController = UIAlertController(title: code, message: nil, preferredStyle: .actionSheet)
        let copyAction = UIAlertAction(title: "복사하기", style: .default) { [weak self] _ in
            guard let self else { return }
            UIPasteboard.general.string = code
            self.dismiss(animated: true, completion: nil)
        }
        let searchWebAction = UIAlertAction(title: "검색하기", style: .default) { [weak self] _ in
            guard let self else { return }
            let encoded = "https://www.google.com/search?q=\(code)".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)
            let scanned = URL(string: encoded!)
            let safari = SFSafariViewController(url: scanned!)
            safari.delegate = self
            safari.modalPresentationStyle = .overFullScreen
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: {
                self.present(safari, animated: true, completion: nil)
            })
        }
        let cancelAction = UIAlertAction(title: "취소", style: .cancel) { [weak self] _ in
            DispatchQueue.global(qos: .background).async { [weak self] in
                guard let self else { return }
                self.captureSession.startRunning()
            }
        }
        alertController.addAction(copyAction)
        alertController.addAction(searchWebAction)
        alertController.addAction(cancelAction)
        present(alertController, animated: true, completion: nil)
    }
}

///Objc Func Methods
extension QRNativeViewController {
    
    @objc func toggleFlash() {
        if let device = AVCaptureDevice.default(for: AVMediaType.video) {
            if device.hasTorch {
                do {
                    try device.lockForConfiguration()
                    if device.torchMode == .off {
                        try device.setTorchModeOn(level: 1.0)
                    } else {
                        device.torchMode = .off
                    }
                    device.unlockForConfiguration()
                } catch {
                    print("플래시를 설정하는 동안 오류가 발생했습니다: \(error.localizedDescription)")
                }
            } else {
                print("이 디바이스는 플래시를 지원하지 않습니다.")
            }
        } else {
            print("카메라 장치를 찾을 수 없습니다.")
        }
    }
}
728x90
반응형