Skip to content

Commit

Permalink
Adds QR Code Scanner UI component (#14)
Browse files Browse the repository at this point in the history
* Adds QR Code scanner

* Fix CI

* Lint
  • Loading branch information
Juliano1612 authored May 15, 2024
1 parent 33184aa commit 4a9cda4
Show file tree
Hide file tree
Showing 4 changed files with 293 additions and 5 deletions.
4 changes: 2 additions & 2 deletions Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/spruceid/wallet-sdk-rs.git",
"state" : {
"revision" : "56cd08960dd7ebabcaee435b5f15c8173e40fa1b",
"version" : "0.0.4"
"revision" : "d508f610c05a309a3254678d99c17804f04ec1fa",
"version" : "0.0.25"
}
}
],
Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import PackageDescription
let package = Package(
name: "SpruceIDWalletSdk",
platforms: [
.iOS(.v13)
.iOS(.v14)
],
products: [
.library(
Expand Down
4 changes: 2 additions & 2 deletions Sources/WalletSdk/MDoc.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import CryptoKit
import Foundation
import SpruceIDWalletSdkRs

public typealias Namespace = String
public typealias MDocNamespace = String
public typealias IssuerSignedItemBytes = Data
public typealias ItemsRequest = SpruceIDWalletSdkRs.ItemsRequest

Expand All @@ -15,7 +15,7 @@ public class MDoc: Credential {
/// namespaces is the full set of namespaces with data items and their value
/// IssuerSignedItemBytes will be bytes, but its composition is defined here
/// https://github.com/spruceid/isomdl/blob/f7b05dfa/src/definitions/issuer_signed.rs#L18
public init?(fromMDoc issuerAuth: Data, namespaces: [Namespace: [IssuerSignedItemBytes]], keyAlias: String) {
public init?(fromMDoc issuerAuth: Data, namespaces: [MDocNamespace: [IssuerSignedItemBytes]], keyAlias: String) {
self.keyAlias = keyAlias
do {
try self.inner = SpruceIDWalletSdkRs.MDoc.fromCbor(value: issuerAuth)
Expand Down
288 changes: 288 additions & 0 deletions Sources/WalletSdk/ui/QRCodeScanner.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
import SwiftUI
import AVKit
import os.log

public class QRScannerDelegate: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate {

@Published public var scannedCode: String?
public func metadataOutput(
_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection
) {
if let metaObject = metadataObjects.first {
guard let readableObject = metaObject as? AVMetadataMachineReadableCodeObject else {return}
guard let scannedCode = readableObject.stringValue else {return}
self.scannedCode = scannedCode
}
}
}

/// Camera View Using AVCaptureVideoPreviewLayer
public struct CameraView: UIViewRepresentable {

var frameSize: CGSize

/// Camera Session
@Binding var session: AVCaptureSession

public init(frameSize: CGSize, session: Binding<AVCaptureSession>) {
self.frameSize = frameSize
self._session = session
}

public func makeUIView(context: Context) -> UIView {
/// Defining camera frame size
let view = UIViewType(frame: CGRect(origin: .zero, size: frameSize))
view.backgroundColor = .clear

let cameraLayer = AVCaptureVideoPreviewLayer(session: session)
cameraLayer.frame = .init(origin: .zero, size: frameSize)
cameraLayer.videoGravity = .resizeAspectFill
cameraLayer.masksToBounds = true
view.layer.addSublayer(cameraLayer)

return view
}

public func updateUIView(_ uiView: UIViewType, context: Context) {

}

}

extension UIScreen {
static let screenWidth = UIScreen.main.bounds.size.width
static let screenHeight = UIScreen.main.bounds.size.height
static let screenSize = UIScreen.main.bounds.size
}

public struct QRCodeScanner: View {
/// QR Code Scanner properties
@State private var isScanning: Bool = false
@State private var session: AVCaptureSession = .init()

/// QR scanner AV Output
@State private var qrOutput: AVCaptureMetadataOutput = .init()

/// Camera QR Output delegate
@StateObject private var qrDelegate = QRScannerDelegate()

/// Scanned code
@State private var scannedCode: String = ""

var title: String
var subtitle: String
var cancelButtonLabel: String
var onCancel: () -> Void
var onRead: (String) -> Void
var titleFont: Font?
var subtitleFont: Font?
var cancelButtonFont: Font?
var guidesColor: Color
var readerColor: Color
var textColor: Color
var backgroundOpacity: Double

public init(
title: String = "Scan QR Code",
subtitle: String = "Please align within the guides",
cancelButtonLabel: String = "Cancel",
onRead: @escaping (String) -> Void,
onCancel: @escaping () -> Void,
titleFont: Font? = nil,
subtitleFont: Font? = nil,
cancelButtonFont: Font? = nil,
guidesColor: Color = .white,
readerColor: Color = .white,
textColor: Color = .white,
backgroundOpacity: Double = 0.75
) {
self.title = title
self.subtitle = subtitle
self.cancelButtonLabel = cancelButtonLabel
self.onCancel = onCancel
self.onRead = onRead
self.titleFont = titleFont
self.subtitleFont = subtitleFont
self.cancelButtonFont = cancelButtonFont
self.guidesColor = guidesColor
self.readerColor = readerColor
self.textColor = textColor
self.backgroundOpacity = backgroundOpacity
}

public var body: some View {

ZStack {
GeometryReader {
let viewSize = $0.size
let size = UIScreen.screenSize
ZStack {
CameraView(frameSize: CGSize(width: size.width, height: size.height), session: $session)
/// Blur layer with clear cut out
ZStack {
Rectangle()
.foregroundColor(Color.black.opacity(backgroundOpacity))
.frame(width: size.width, height: UIScreen.screenHeight)
Rectangle()
.frame(width: size.width * 0.6, height: size.width * 0.6)
.blendMode(.destinationOut)
}
.compositingGroup()

/// Scan area edges
ZStack {
ForEach(0...4, id: \.self) { index in
let rotation = Double(index) * 90

RoundedRectangle(cornerRadius: 2, style: .circular)
/// Triming to get Scanner lik Edges
.trim(from: 0.61, to: 0.64)
.stroke(
guidesColor,
style: StrokeStyle(
lineWidth: 5,
lineCap: .round,
lineJoin: .round
)
)
.rotationEffect(.init(degrees: rotation))
}
/// Scanner Animation
Rectangle()
.fill(readerColor)
.frame(height: 2.5)
.offset(y: isScanning ? (size.width * 0.59)/2 : -(size.width * 0.59)/2)
}
.frame(width: size.width * 0.6, height: size.width * 0.6)

}
/// Square Shape
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

VStack(alignment: .leading) {
Text(title)
.font(titleFont)
.foregroundColor(textColor)

Text(subtitle)
.font(subtitleFont)
.foregroundColor(textColor)

Spacer()

Button(cancelButtonLabel) {
onCancel()
}
.font(cancelButtonFont)
.foregroundColor(textColor)
}
.padding(.vertical, 80)
}

/// Checking camera permission, when the view is visible
.onAppear(perform: {
Task {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
if session.inputs.isEmpty {
/// New setup
setupCamera()
} else {
/// Already existing one
reactivateCamera()
}
default: break
}
}
})

.onDisappear {
session.stopRunning()
}

.onChange(of: qrDelegate.scannedCode) { newValue in
if let code = newValue {
scannedCode = code

/// When the first code scan is available, immediately stop the camera.
session.stopRunning()

/// Stopping scanner animation
deActivateScannerAnimation()
/// Clearing the data on delegate
qrDelegate.scannedCode = nil

onRead(code)
}

}

}

func reactivateCamera() {
DispatchQueue.global(qos: .background).async {
session.startRunning()
}
}

/// Activating Scanner Animation Method
func activateScannerAnimation() {
/// Adding Delay for each reversal
withAnimation(.easeInOut(duration: 0.85).delay(0.1).repeatForever(autoreverses: true)) {
isScanning = true
}
}

/// DeActivating scanner animation method
func deActivateScannerAnimation() {
/// Adding Delay for each reversal
withAnimation(.easeInOut(duration: 0.85)) {
isScanning = false
}
}

/// Setting up camera
func setupCamera() {
do {
/// Finding back camera
guard let device = AVCaptureDevice.DiscoverySession(
deviceTypes: [.builtInUltraWideCamera, .builtInWideAngleCamera],
mediaType: .video, position: .back)
.devices.first
else {
os_log("Error: %@", log: .default, type: .error, String("UNKNOWN DEVICE ERROR"))
return
}

/// Camera input
let input = try AVCaptureDeviceInput(device: device)
/// For Extra Safety
/// Checking whether input & output can be added to the session
guard session.canAddInput(input), session.canAddOutput(qrOutput) else {
os_log("Error: %@", log: .default, type: .error, String("UNKNOWN INPUT/OUTPUT ERROR"))
return
}

/// Adding input & output to camera session
session.beginConfiguration()
session.addInput(input)
session.addOutput(qrOutput)
/// Setting output config to read qr codes
qrOutput.metadataObjectTypes = [.qr]
/// Adding delegate to retreive the fetched qr code from camera
qrOutput.setMetadataObjectsDelegate(qrDelegate, queue: .main)
session.commitConfiguration()
/// Note session must be started on background thread

DispatchQueue.global(qos: .background).async {
session.startRunning()
}
activateScannerAnimation()
} catch {
os_log("Error: %@", log: .default, type: .error, error.localizedDescription)
}
}
}

0 comments on commit 4a9cda4

Please sign in to comment.