497 lines
18 KiB
Swift
497 lines
18 KiB
Swift
//
|
|
// CameraView.swift
|
|
// ReactNativeCameraKit
|
|
//
|
|
|
|
import AVFoundation
|
|
import AVKit
|
|
import React
|
|
import UIKit
|
|
|
|
/*
|
|
* View abtracting the logic unrelated to the actual camera
|
|
* Like permission, ratio overlay, focus, zoom gesture, write image, etc
|
|
*/
|
|
@objc(CKCameraView)
|
|
public class CameraView: UIView {
|
|
private let camera: CameraProtocol
|
|
|
|
// Focus
|
|
private let focusInterfaceView: FocusInterfaceView
|
|
|
|
// scanner
|
|
private var lastBarcodeDetectedTime: TimeInterval = 0
|
|
private var scannerInterfaceView: ScannerInterfaceView
|
|
|
|
// camera
|
|
private var ratioOverlayView: RatioOverlayView?
|
|
|
|
// gestures
|
|
private var zoomGestureRecognizer: UIPinchGestureRecognizer?
|
|
|
|
// props
|
|
// camera settings
|
|
@objc public var cameraType: CameraType = .back
|
|
@objc public var resizeMode: ResizeMode = .contain
|
|
@objc public var flashMode: FlashMode = .auto
|
|
@objc public var torchMode: TorchMode = .off
|
|
@objc public var maxPhotoQualityPrioritization: MaxPhotoQualityPrioritization = .balanced
|
|
// ratio overlay
|
|
@objc public var ratioOverlay: String?
|
|
@objc public var ratioOverlayColor: UIColor?
|
|
// scanner
|
|
@objc public var scanBarcode = false
|
|
@objc public var showFrame = false
|
|
@objc public var onReadCode: RCTDirectEventBlock?
|
|
@objc public var scanThrottleDelay = 2000
|
|
@objc public var frameColor: UIColor?
|
|
@objc public var laserColor: UIColor?
|
|
@objc public var barcodeFrameSize: NSDictionary?
|
|
@objc public var allowedBarcodeTypes: NSArray?
|
|
|
|
// other
|
|
@objc public var onOrientationChange: RCTDirectEventBlock?
|
|
@objc public var onZoom: RCTDirectEventBlock?
|
|
@objc public var resetFocusTimeout = 0
|
|
@objc public var resetFocusWhenMotionDetected = false
|
|
@objc public var focusMode: FocusMode = .on
|
|
@objc public var zoomMode: ZoomMode = .on
|
|
@objc public var zoom: NSNumber?
|
|
@objc public var maxZoom: NSNumber?
|
|
@objc public var iOsDeferredStart: Bool = true
|
|
|
|
@objc public var onCaptureButtonPressIn: RCTDirectEventBlock?
|
|
@objc public var onCaptureButtonPressOut: RCTDirectEventBlock?
|
|
|
|
var eventInteraction: Any? = nil
|
|
|
|
// MARK: - Setup
|
|
|
|
// This is used to delay camera setup until we have both granted permission & received default props
|
|
var hasCameraBeenSetup = false
|
|
var hasPropBeenSetup = false {
|
|
didSet {
|
|
setupCamera()
|
|
}
|
|
}
|
|
var hasPermissionBeenGranted = false {
|
|
didSet {
|
|
setupCamera()
|
|
}
|
|
}
|
|
private func setupCamera() {
|
|
if hasPropBeenSetup && hasPermissionBeenGranted && !hasCameraBeenSetup {
|
|
let convertedAllowedTypes = convertAllowedBarcodeTypes()
|
|
|
|
camera.update(iOsDeferredStartEnabled: iOsDeferredStart)
|
|
|
|
hasCameraBeenSetup = true
|
|
#if targetEnvironment(macCatalyst)
|
|
// Force front camera on Mac Catalyst during initial setup
|
|
camera.setup(
|
|
cameraType: .front,
|
|
supportedBarcodeType: scanBarcode && onReadCode != nil
|
|
? convertedAllowedTypes : [])
|
|
#else
|
|
camera.setup(
|
|
cameraType: cameraType,
|
|
supportedBarcodeType: scanBarcode && onReadCode != nil
|
|
? convertedAllowedTypes : [])
|
|
#endif
|
|
}
|
|
}
|
|
|
|
// Use constraints for FABRIC 0.80.0
|
|
private func addFullSizeSubview(_ subview: UIView) {
|
|
subview.translatesAutoresizingMaskIntoConstraints = false
|
|
addSubview(subview)
|
|
NSLayoutConstraint.activate([
|
|
subview.topAnchor.constraint(equalTo: self.topAnchor),
|
|
subview.bottomAnchor.constraint(equalTo: self.bottomAnchor),
|
|
subview.leadingAnchor.constraint(equalTo: self.leadingAnchor),
|
|
subview.trailingAnchor.constraint(equalTo: self.trailingAnchor),
|
|
])
|
|
}
|
|
|
|
// MARK: Lifecycle
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder aDecoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override init(frame: CGRect) {
|
|
#if targetEnvironment(simulator)
|
|
camera = SimulatorCamera()
|
|
#else
|
|
camera = RealCamera()
|
|
#endif
|
|
|
|
scannerInterfaceView = ScannerInterfaceView(frameColor: .white, laserColor: .red)
|
|
focusInterfaceView = FocusInterfaceView()
|
|
|
|
super.init(frame: frame)
|
|
|
|
// Transfer the default values, otherwise the default wont take effect since it's a separate class
|
|
focusInterfaceView.update(focusMode: focusMode)
|
|
focusInterfaceView.update(resetFocusTimeout: resetFocusTimeout)
|
|
focusInterfaceView.update(resetFocusWhenMotionDetected: resetFocusWhenMotionDetected)
|
|
update(zoomMode: zoomMode)
|
|
|
|
addSubview(camera.previewView)
|
|
|
|
addSubview(scannerInterfaceView)
|
|
scannerInterfaceView.isHidden = true
|
|
|
|
addSubview(focusInterfaceView)
|
|
|
|
addFullSizeSubview(camera.previewView)
|
|
addFullSizeSubview(scannerInterfaceView)
|
|
addFullSizeSubview(focusInterfaceView)
|
|
|
|
focusInterfaceView.delegate = camera
|
|
|
|
handleCameraPermission()
|
|
|
|
configureHardwareInteraction()
|
|
}
|
|
|
|
private func configureHardwareInteraction() {
|
|
#if !targetEnvironment(macCatalyst)
|
|
// Create a new capture event interaction with a handler that captures a photo.
|
|
if #available(iOS 17.2, *) {
|
|
let interaction = AVCaptureEventInteraction { [weak self] event in
|
|
// Capture a photo on "press up" of a hardware button.
|
|
if event.phase == .began {
|
|
self?.onCaptureButtonPressIn?(nil)
|
|
} else if event.phase == .ended {
|
|
self?.onCaptureButtonPressOut?(nil)
|
|
}
|
|
}
|
|
// Add the interaction to the view controller's view.
|
|
self.addInteraction(interaction)
|
|
eventInteraction = interaction
|
|
}
|
|
#endif
|
|
}
|
|
|
|
override public func removeFromSuperview() {
|
|
camera.cameraRemovedFromSuperview()
|
|
|
|
super.removeFromSuperview()
|
|
}
|
|
|
|
// MARK: React lifecycle
|
|
|
|
override public func reactSetFrame(_ frame: CGRect) {
|
|
super.reactSetFrame(frame)
|
|
self.updateSubviewsBounds(frame)
|
|
}
|
|
|
|
@objc public func updateSubviewsBounds(_ frame: CGRect) {
|
|
camera.previewView.frame = bounds
|
|
|
|
scannerInterfaceView.frame = bounds
|
|
// If frame size changes, we have to update the scanner
|
|
camera.update(scannerFrameSize: showFrame ? scannerInterfaceView.frameSize : nil)
|
|
|
|
focusInterfaceView.frame = bounds
|
|
|
|
ratioOverlayView?.frame = bounds
|
|
}
|
|
|
|
override public func removeReactSubview(_ subview: UIView) {
|
|
subview.removeFromSuperview()
|
|
super.removeReactSubview(subview)
|
|
}
|
|
|
|
// Called once when all props have been set, then every time one is updated
|
|
// swiftlint:disable:next cyclomatic_complexity function_body_length
|
|
override public func didSetProps(_ changedProps: [String]) {
|
|
hasPropBeenSetup = true
|
|
|
|
// Camera settings
|
|
if changedProps.contains("cameraType") {
|
|
#if targetEnvironment(macCatalyst)
|
|
// Force front camera on Mac Catalyst regardless of what's passed
|
|
camera.update(cameraType: .front)
|
|
#else
|
|
camera.update(cameraType: cameraType)
|
|
#endif
|
|
}
|
|
if changedProps.contains("flashMode") {
|
|
camera.update(flashMode: flashMode)
|
|
}
|
|
if changedProps.contains("cameraType") || changedProps.contains("torchMode") {
|
|
camera.update(torchMode: torchMode)
|
|
}
|
|
if changedProps.contains("maxPhotoQualityPrioritization") {
|
|
camera.update(maxPhotoQualityPrioritization: maxPhotoQualityPrioritization)
|
|
}
|
|
|
|
if changedProps.contains("onOrientationChange") {
|
|
camera.update(onOrientationChange: onOrientationChange)
|
|
}
|
|
|
|
if changedProps.contains("onZoom") {
|
|
camera.update(onZoom: onZoom)
|
|
}
|
|
|
|
if changedProps.contains("resizeMode") {
|
|
camera.update(resizeMode: resizeMode)
|
|
}
|
|
|
|
// Ratio overlay
|
|
if changedProps.contains("ratioOverlay") {
|
|
if let ratioOverlay {
|
|
if let ratioOverlayView {
|
|
ratioOverlayView.setRatio(ratioOverlay)
|
|
} else {
|
|
ratioOverlayView = RatioOverlayView(
|
|
frame: bounds, ratioString: ratioOverlay, overlayColor: ratioOverlayColor)
|
|
addSubview(ratioOverlayView!)
|
|
}
|
|
} else {
|
|
ratioOverlayView?.removeFromSuperview()
|
|
ratioOverlayView = nil
|
|
}
|
|
}
|
|
|
|
if changedProps.contains("ratioOverlayColor"), let ratioOverlayColor {
|
|
ratioOverlayView?.setColor(ratioOverlayColor)
|
|
}
|
|
|
|
// Scanner
|
|
if changedProps.contains("scanBarcode") || changedProps.contains("onReadCode")
|
|
|| changedProps.contains("allowedBarcodeTypes")
|
|
{
|
|
let convertedAllowedTypes: [CodeFormat] = convertAllowedBarcodeTypes()
|
|
|
|
camera.isBarcodeScannerEnabled(
|
|
scanBarcode,
|
|
supportedBarcodeTypes: convertedAllowedTypes,
|
|
onBarcodeRead: { [weak self] (barcode, codeFormat) in
|
|
self?.onBarcodeRead(barcode: barcode, codeFormat: codeFormat)
|
|
})
|
|
}
|
|
|
|
if changedProps.contains("showFrame") || changedProps.contains("scanBarcode") {
|
|
DispatchQueue.main.async {
|
|
self.scannerInterfaceView.isHidden = !self.showFrame
|
|
|
|
self.camera.update(
|
|
scannerFrameSize: self.showFrame ? self.scannerInterfaceView.frameSize : nil)
|
|
}
|
|
}
|
|
|
|
if changedProps.contains("barcodeFrameSize"), let barcodeFrameSize, showFrame, scanBarcode {
|
|
if let width = barcodeFrameSize["width"] as? CGFloat,
|
|
let height = barcodeFrameSize["height"] as? CGFloat
|
|
{
|
|
scannerInterfaceView.update(frameSize: CGSize(width: width, height: height))
|
|
camera.update(scannerFrameSize: showFrame ? scannerInterfaceView.frameSize : nil)
|
|
}
|
|
}
|
|
|
|
if changedProps.contains("laserColor"), let laserColor {
|
|
scannerInterfaceView.update(laserColor: laserColor)
|
|
}
|
|
|
|
if changedProps.contains("frameColor"), let frameColor {
|
|
scannerInterfaceView.update(frameColor: frameColor)
|
|
}
|
|
|
|
// Others
|
|
if changedProps.contains("iOsDeferredStart") {
|
|
camera.update(iOsDeferredStartEnabled: iOsDeferredStart)
|
|
}
|
|
if changedProps.contains("focusMode") {
|
|
focusInterfaceView.update(focusMode: focusMode)
|
|
}
|
|
if changedProps.contains("resetFocusTimeout") {
|
|
focusInterfaceView.update(resetFocusTimeout: resetFocusTimeout)
|
|
}
|
|
if changedProps.contains("resetFocusWhenMotionDetected") {
|
|
focusInterfaceView.update(resetFocusWhenMotionDetected: resetFocusWhenMotionDetected)
|
|
}
|
|
|
|
if changedProps.contains("zoomMode") {
|
|
self.update(zoomMode: zoomMode)
|
|
}
|
|
|
|
if changedProps.contains("zoom") {
|
|
camera.update(zoom: zoom?.doubleValue)
|
|
}
|
|
|
|
if changedProps.contains("maxZoom") {
|
|
camera.update(maxZoom: maxZoom?.doubleValue)
|
|
}
|
|
}
|
|
|
|
// MARK: Public
|
|
|
|
@objc public func capture(
|
|
onSuccess: @escaping (_ imageObject: [String: Any]) -> Void,
|
|
onError: @escaping (_ error: String) -> Void
|
|
) {
|
|
camera.capturePicture(
|
|
onWillCapture: { [weak self] in
|
|
// Flash/dim preview to indicate shutter action
|
|
DispatchQueue.main.async {
|
|
self?.camera.previewView.alpha = 0
|
|
UIView.animate(
|
|
withDuration: 0.35,
|
|
animations: {
|
|
self?.camera.previewView.alpha = 1
|
|
})
|
|
}
|
|
},
|
|
onSuccess: { [weak self] imageData, thumbnailData, dimensions in
|
|
DispatchQueue.global(qos: .default).async {
|
|
self?.writeCaptured(
|
|
imageData: imageData,
|
|
thumbnailData: thumbnailData,
|
|
dimensions: dimensions,
|
|
onSuccess: onSuccess,
|
|
onError: onError)
|
|
|
|
self?.focusInterfaceView.resetFocus()
|
|
}
|
|
}, onError: onError)
|
|
}
|
|
|
|
// MARK: - Private Helper
|
|
|
|
private func update(zoomMode: ZoomMode) {
|
|
if zoomMode == .on {
|
|
if zoomGestureRecognizer == nil {
|
|
let pinchGesture = UIPinchGestureRecognizer(
|
|
target: self, action: #selector(handlePinchToZoomRecognizer(_:)))
|
|
addGestureRecognizer(pinchGesture)
|
|
zoomGestureRecognizer = pinchGesture
|
|
}
|
|
} else {
|
|
if let zoomGestureRecognizer {
|
|
removeGestureRecognizer(zoomGestureRecognizer)
|
|
self.zoomGestureRecognizer = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handleCameraPermission() {
|
|
#if targetEnvironment(macCatalyst)
|
|
// On macOS, camera permissions are handled differently
|
|
if #available(macCatalyst 14.0, *) {
|
|
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
|
case .authorized:
|
|
hasPermissionBeenGranted = true
|
|
case .notDetermined:
|
|
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
|
if granted {
|
|
DispatchQueue.main.async {
|
|
self?.hasPermissionBeenGranted = true
|
|
}
|
|
}
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
#else
|
|
// iOS permission handling
|
|
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
|
case .authorized:
|
|
// The user has previously granted access to the camera.
|
|
hasPermissionBeenGranted = true
|
|
case .notDetermined:
|
|
// The user has not yet been presented with the option to grant video access.
|
|
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
|
if granted {
|
|
self?.hasPermissionBeenGranted = true
|
|
}
|
|
}
|
|
default:
|
|
// The user has previously denied access.
|
|
break
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private func writeCaptured(
|
|
imageData: Data,
|
|
thumbnailData: Data?,
|
|
dimensions: CMVideoDimensions,
|
|
onSuccess: @escaping (_ imageObject: [String: Any]) -> Void,
|
|
onError: @escaping (_ error: String) -> Void
|
|
) {
|
|
do {
|
|
let temporaryImageFileURL = try saveToTmpFolder(imageData)
|
|
|
|
onSuccess([
|
|
"size": imageData.count,
|
|
"uri": temporaryImageFileURL.description,
|
|
"name": temporaryImageFileURL.lastPathComponent,
|
|
"thumb": "",
|
|
"height": dimensions.height,
|
|
"width": dimensions.width,
|
|
])
|
|
} catch {
|
|
let errorMessage =
|
|
"Error occurred while writing image data to a temporary file: \(error)"
|
|
print(errorMessage)
|
|
onError(errorMessage)
|
|
}
|
|
}
|
|
|
|
private func saveToTmpFolder(_ data: Data) throws -> URL {
|
|
let temporaryFileName = ProcessInfo.processInfo.globallyUniqueString
|
|
// Store temporary photos in the 'caches' directory to support expo-file-system
|
|
let cachesUrl = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
|
var temporaryFolderURL = cachesUrl
|
|
if let bundleId = Bundle.main.bundleIdentifier {
|
|
temporaryFolderURL = temporaryFolderURL.appendingPathComponent(
|
|
bundleId, isDirectory: true)
|
|
}
|
|
temporaryFolderURL = temporaryFolderURL.appendingPathComponent(
|
|
"com.tesla.react-native-camera-kit", isDirectory: true)
|
|
try FileManager.default.createDirectory(
|
|
at: temporaryFolderURL, withIntermediateDirectories: true)
|
|
let temporaryFileURL = temporaryFolderURL.appendingPathComponent("\(temporaryFileName).jpg")
|
|
|
|
try data.write(to: temporaryFileURL, options: .atomic)
|
|
|
|
return temporaryFileURL
|
|
}
|
|
|
|
private func onBarcodeRead(barcode: String, codeFormat: CodeFormat) {
|
|
// Throttle barcode detection
|
|
let now = Date.timeIntervalSinceReferenceDate
|
|
guard lastBarcodeDetectedTime + Double(scanThrottleDelay) / 1000 < now else {
|
|
return
|
|
}
|
|
|
|
lastBarcodeDetectedTime = now
|
|
|
|
onReadCode?(["codeStringValue": barcode, "codeFormat": codeFormat.rawValue])
|
|
}
|
|
|
|
private func convertAllowedBarcodeTypes() -> [CodeFormat] {
|
|
guard let allowedTypes = allowedBarcodeTypes as? [String], !allowedTypes.isEmpty else {
|
|
return CodeFormat.allCases
|
|
}
|
|
|
|
return allowedTypes.compactMap { CodeFormat(rawValue: $0) }
|
|
}
|
|
|
|
// MARK: - Gesture selectors
|
|
|
|
@objc func handlePinchToZoomRecognizer(_ pinchRecognizer: UIPinchGestureRecognizer) {
|
|
if pinchRecognizer.state == .began {
|
|
camera.zoomPinchStart()
|
|
}
|
|
if pinchRecognizer.state == .changed {
|
|
camera.zoomPinchChange(pinchScale: pinchRecognizer.scale)
|
|
}
|
|
}
|
|
}
|