Fixed zoom issue Fixed Fabric (new arch) compile issues on both platforms Fixed included broken codegen lib code Fixed broken .h import paths to avoid setting custom build settings Fixed potential memory issue by disabling view recycling Added Fabric (new arch) support for camera view component Added RN 0.81 for example Added RN 0.79 for main lib Added helper for re-running codegen Added gitignore for Android codegen making a podspec Added support for scanThrottleDelay on Android Added includesGeneratedCode to package.json to indicate changes Rewrote optional int props to use -1 instead due to RN bug Moved to Obj-C with C++ support (.mm) to avoid C++ compile issues
454 lines
16 KiB
Swift
454 lines
16 KiB
Swift
//
|
|
// CameraView.swift
|
|
// ReactNativeCameraKit
|
|
//
|
|
|
|
import AVFoundation
|
|
import UIKit
|
|
import AVKit
|
|
import React
|
|
|
|
/*
|
|
* 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
|
|
private var supportedBarcodeType: [CodeFormat] = {
|
|
return CodeFormat.allCases
|
|
}()
|
|
|
|
// 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?
|
|
|
|
// 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 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 {
|
|
hasCameraBeenSetup = true
|
|
#if targetEnvironment(macCatalyst)
|
|
// Force front camera on Mac Catalyst during initial setup
|
|
camera.setup(cameraType: .front, supportedBarcodeType: scanBarcode && onReadCode != nil ? supportedBarcodeType : [])
|
|
#else
|
|
camera.setup(cameraType: cameraType, supportedBarcodeType: scanBarcode && onReadCode != nil ? supportedBarcodeType : [])
|
|
#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") {
|
|
camera.isBarcodeScannerEnabled(scanBarcode,
|
|
supportedBarcodeTypes: supportedBarcodeType,
|
|
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("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])
|
|
}
|
|
|
|
// MARK: - Gesture selectors
|
|
|
|
@objc func handlePinchToZoomRecognizer(_ pinchRecognizer: UIPinchGestureRecognizer) {
|
|
if pinchRecognizer.state == .began {
|
|
camera.zoomPinchStart()
|
|
}
|
|
if pinchRecognizer.state == .changed {
|
|
camera.zoomPinchChange(pinchScale: pinchRecognizer.scale)
|
|
}
|
|
}
|
|
}
|