diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 432ee6f..28b074b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,8 +9,6 @@ jobs: uses: actions/checkout@v2 - name: Install modules run: yarn - - name: Bootstrap - run: yarn bootstrap - name: Build run: cd example/ios && xcodebuild -workspace CameraKitExample.xcworkspace -configuration Debug -scheme CameraKitExample -sdk iphoneos build CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO build-example-android: @@ -23,7 +21,5 @@ jobs: uses: gradle/wrapper-validation-action@v1 - name: Install modules run: yarn - - name: Bootstrap - run: yarn bootstrap - name: Build run: cd example/android && ./gradlew assembleDebug diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 46239cd..0341aa9 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -8,6 +8,6 @@ jobs: - name: Checkout uses: actions/checkout@v2 - name: Install modules - run: yarn + run: yarn --ignore-scripts - name: Lint run: yarn lint diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index caffb2a..2cdeda9 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -575,7 +575,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 57d2868c099736d80fcd648bf211b4431e51a558 CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 - DoubleConversion: 5189b271737e1565bdce30deb4a08d647e3f5f54 + DoubleConversion: cde416483dac037923206447da6e1454df403714 FBLazyVector: f637f31eacba90d4fdeff3fa41608b8f361c173b FBReactNativeSpec: 0d9a4f4de7ab614c49e98c00aedfd3bfbda33d59 Flipper: 26fc4b7382499f1281eb8cb921e5c3ad6de91fe0 @@ -588,7 +588,7 @@ SPEC CHECKSUMS: Flipper-RSocket: d9d9ade67cbecf6ac10730304bf5607266dd2541 FlipperKit: cbdee19bdd4e7f05472a66ce290f1b729ba3cb86 fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 - glog: 04b94705f318337d7ead9e6d17c019bd9b1f6b1b + glog: 40a13f7840415b9a77023fbcae0f1e6f43192af3 hermes-engine: 47986d26692ae75ee7a17ab049caee8864f855de libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913 OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c diff --git a/example/src/BarcodeScreenExample.tsx b/example/src/BarcodeScreenExample.tsx index 0b9de8c..7e6c882 100644 --- a/example/src/BarcodeScreenExample.tsx +++ b/example/src/BarcodeScreenExample.tsx @@ -128,6 +128,7 @@ const BarcodeExample = ({ onBack }: { onBack: () => void }) => { flashMode={flashData?.mode} zoomMode="on" focusMode="on" + maxZoom={5} torchMode={torchMode ? 'on' : 'off'} onOrientationChange={(e) => { // We recommend locking the camera UI to portrait (using a different library) diff --git a/example/src/CameraExample.tsx b/example/src/CameraExample.tsx index 5745f56..6e22494 100644 --- a/example/src/CameraExample.tsx +++ b/example/src/CameraExample.tsx @@ -34,6 +34,7 @@ const CameraExample = ({ onBack }: { onBack: () => void }) => { const [captured, setCaptured] = useState(false); const [cameraType, setCameraType] = useState(CameraType.Back); const [showImageUri, setShowImageUri] = useState(''); + const [zoom, setZoom] = useState(0); // iOS will error out if capturing too fast, // so block capturing until the current capture is done @@ -54,6 +55,7 @@ const CameraExample = ({ onBack }: { onBack: () => void }) => { const onSwitchCameraPressed = () => { const direction = cameraType === CameraType.Back ? CameraType.Front : CameraType.Back; setCameraType(direction); + setZoom(0); // When changing camera type, reset to default zoom for that camera }; const onSetFlash = () => { @@ -101,6 +103,10 @@ const CameraExample = ({ onBack }: { onBack: () => void }) => { + setZoom(0)}> + {Number(zoom).toFixed(1)}x + + void }) => { flashMode={flashData?.mode} zoomMode="on" focusMode="on" + zoom={zoom} + maxZoom={15} torchMode={torchMode ? 'on' : 'off'} + onZoom={(e) => { + console.log('zoom', e.nativeEvent.zoom); + setZoom(e.nativeEvent.zoom); + }} onOrientationChange={(e) => { // We recommend locking the camera UI to portrait (using a different library) // and rotating the UI elements counter to the orientation // However, we include onOrientationChange so you can match your UI to what the camera does - switch(e.nativeEvent.orientation) { + switch (e.nativeEvent.orientation) { case Orientation.LANDSCAPE_LEFT: console.log('orientationChange', 'LANDSCAPE_LEFT'); break; @@ -141,7 +153,7 @@ const CameraExample = ({ onBack }: { onBack: () => void }) => { default: console.log('orientationChange', e.nativeEvent); break; - } + } }} /> )} @@ -235,6 +247,13 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, + zoom: { + alignItems: 'center', + justifyContent: 'center', + }, + zoomFactor: { + color: '#ffffff', + }, thumbnailContainer: { flex: 1, alignItems: 'flex-end', diff --git a/ios/ReactNativeCameraKit/CKCameraManager.m b/ios/ReactNativeCameraKit/CKCameraManager.m index 0de8e2b..b79fd65 100644 --- a/ios/ReactNativeCameraKit/CKCameraManager.m +++ b/ios/ReactNativeCameraKit/CKCameraManager.m @@ -29,10 +29,13 @@ RCT_EXPORT_VIEW_PROPERTY(laserColor, UIColor) RCT_EXPORT_VIEW_PROPERTY(frameColor, UIColor) RCT_EXPORT_VIEW_PROPERTY(onOrientationChange, RCTDirectEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onZoom, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(resetFocusTimeout, NSInteger) RCT_EXPORT_VIEW_PROPERTY(resetFocusWhenMotionDetected, BOOL) RCT_EXPORT_VIEW_PROPERTY(focusMode, CKFocusMode) RCT_EXPORT_VIEW_PROPERTY(zoomMode, CKZoomMode) +RCT_EXPORT_VIEW_PROPERTY(zoom, NSNumber) +RCT_EXPORT_VIEW_PROPERTY(maxZoom, NSNumber) RCT_EXTERN_METHOD(capture:(NSDictionary*)options resolve:(RCTPromiseResolveBlock)resolve diff --git a/ios/ReactNativeCameraKit/CameraProtocol.swift b/ios/ReactNativeCameraKit/CameraProtocol.swift index b186964..fda57ef 100644 --- a/ios/ReactNativeCameraKit/CameraProtocol.swift +++ b/ios/ReactNativeCameraKit/CameraProtocol.swift @@ -11,11 +11,16 @@ protocol CameraProtocol: AnyObject, FocusInterfaceViewDelegate { func setup(cameraType: CameraType, supportedBarcodeType: [AVMetadataObject.ObjectType]) func cameraRemovedFromSuperview() - func update(pinchScale: CGFloat) func update(torchMode: TorchMode) func update(flashMode: FlashMode) func update(cameraType: CameraType) func update(onOrientationChange: RCTDirectEventBlock?) + func update(onZoom: RCTDirectEventBlock?) + func update(zoom: Double?) + func update(maxZoom: Double?) + + func zoomPinchStart() + func zoomPinchChange(pinchScale: CGFloat) func isBarcodeScannerEnabled(_ isEnabled: Bool, supportedBarcodeType: [AVMetadataObject.ObjectType], diff --git a/ios/ReactNativeCameraKit/CameraView.swift b/ios/ReactNativeCameraKit/CameraView.swift index 69e6645..7697682 100644 --- a/ios/ReactNativeCameraKit/CameraView.swift +++ b/ios/ReactNativeCameraKit/CameraView.swift @@ -47,10 +47,13 @@ class CameraView: UIView { @objc var laserColor: UIColor? // other @objc var onOrientationChange: RCTDirectEventBlock? + @objc var onZoom: RCTDirectEventBlock? @objc var resetFocusTimeout = 0 @objc var resetFocusWhenMotionDetected = false @objc var focusMode: FocusMode = .on @objc var zoomMode: ZoomMode = .on + @objc var zoom: NSNumber? + @objc var maxZoom: NSNumber? // MARK: - Setup @@ -149,6 +152,10 @@ class CameraView: UIView { if changedProps.contains("onOrientationChange") { camera.update(onOrientationChange: onOrientationChange) } + + if changedProps.contains("onZoom") { + camera.update(onZoom: onZoom) + } // Ratio overlay if changedProps.contains("ratioOverlay") { @@ -217,6 +224,14 @@ class CameraView: UIView { } } } + + if changedProps.contains("zoom") { + camera.update(zoom: zoom?.doubleValue) + } + + if changedProps.contains("maxZoom") { + camera.update(maxZoom: maxZoom?.doubleValue) + } } // MARK: Public @@ -240,7 +255,7 @@ class CameraView: UIView { } }, onError: onError) } - + // MARK: - Private Helper private func handleCameraPermission() { @@ -310,10 +325,11 @@ class CameraView: UIView { // MARK: - Gesture selectors @objc func handlePinchToZoomRecognizer(_ pinchRecognizer: UIPinchGestureRecognizer) { + if pinchRecognizer.state == .began { + camera.zoomPinchStart() + } if pinchRecognizer.state == .changed { - camera.update(pinchScale: pinchRecognizer.scale) - // Reset scale after every reading to get a one timeframe scale value. Otherwise pinchRecognizer.scale is relative to the start of the gesture - pinchRecognizer.scale = 1.0 + camera.zoomPinchChange(pinchScale: pinchRecognizer.scale) } } } diff --git a/ios/ReactNativeCameraKit/RealCamera.swift b/ios/ReactNativeCameraKit/RealCamera.swift index e76a9c0..3a3f41b 100644 --- a/ios/ReactNativeCameraKit/RealCamera.swift +++ b/ios/ReactNativeCameraKit/RealCamera.swift @@ -34,6 +34,9 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega private var onBarcodeRead: ((_ barcode: String) -> Void)? private var scannerFrameSize: CGRect? = nil private var onOrientationChange: RCTDirectEventBlock? + private var onZoom: RCTDirectEventBlock? + private var zoom: Double? + private var maxZoom: Double? private var deviceOrientation = UIDeviceOrientation.unknown private var motionManager: CMMotionManager? @@ -118,8 +121,16 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega } } } + + private var zoomStartedAt: Double = 1.0 + func zoomPinchStart() { + sessionQueue.async { + guard let videoDevice = self.videoDeviceInput?.device else { return } + self.zoomStartedAt = videoDevice.videoZoomFactor + } + } - func update(pinchScale: CGFloat) { + func zoomPinchChange(pinchScale: CGFloat) { guard !pinchScale.isNaN else { return } sessionQueue.async { @@ -127,17 +138,70 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega do { try videoDevice.lockForConfiguration() + defer { videoDevice.unlockForConfiguration() } - let desiredZoomFactor = videoDevice.videoZoomFactor * pinchScale - let maxZoomFactor = min(20, videoDevice.maxAvailableVideoZoomFactor) - videoDevice.videoZoomFactor = max(1.0, min(desiredZoomFactor, maxZoomFactor)) - - videoDevice.unlockForConfiguration() + let desiredZoomFactor = self.zoomStartedAt * pinchScale + var maxZoomFactor = videoDevice.maxAvailableVideoZoomFactor + if let maxZoom = self.maxZoom { + maxZoomFactor = min(maxZoom, maxZoomFactor) + } + let zoomForDevice = max(1.0, min(desiredZoomFactor, maxZoomFactor)) + + if zoomForDevice != videoDevice.videoZoomFactor { + // Only trigger zoom changes if it's an uncontrolled component (zoom isn't manually set) + // otherwise it's likely to cause issues inf. loops + if self.zoom == nil { + videoDevice.videoZoomFactor = zoomForDevice + } + self.onZoom?(["zoom": zoomForDevice]) + } } catch { print("Error setting zoom factor: \(error)") } } } + + func update(maxZoom: Double?) { + self.maxZoom = maxZoom + } + + func update(zoom: Double?) { + self.zoom = zoom + + sessionQueue.async { + guard let videoDevice = self.videoDeviceInput?.device else { return } + + var zoomOrDefault = zoom ?? 0 + // -1 will reset to zoom default (which is not 1 on modern cameras) + if zoomOrDefault == 0 { + zoomOrDefault = self.wideAngleZoomFactor(for: videoDevice) + } + + do { + try videoDevice.lockForConfiguration() + defer { videoDevice.unlockForConfiguration() } + + var maxZoomFactor = videoDevice.maxAvailableVideoZoomFactor + if let maxZoom = self.maxZoom { + maxZoomFactor = min(maxZoom, maxZoomFactor) + } + let zoomForDevice = max(1.0, min(zoomOrDefault, maxZoomFactor)) + videoDevice.videoZoomFactor = zoomForDevice + + // If they wanted to reset, tell them what the default zoom turned out to be + // regardless if it's controlled + if self.zoom == nil || zoom == 0 { + self.onZoom?(["zoom": zoomForDevice]) + } + } catch { + print("Error setting zoom factor: \(error)") + } + } + } + + func update(onZoom: RCTDirectEventBlock?) { + self.onZoom = onZoom + } func focus(at touchPoint: CGPoint, focusBehavior: FocusBehavior) { DispatchQueue.main.async { @@ -227,7 +291,11 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega if self.session.canAddInput(videoDeviceInput) { self.session.addInput(videoDeviceInput) - videoDevice.videoZoomFactor = self.wideAngleZoomFactor(for: videoDevice) + let zoomForDevice = self.wideAngleZoomFactor(for: videoDevice) + if self.zoom == nil { + videoDevice.videoZoomFactor = zoomForDevice + } + self.onZoom?(["zoom": zoomForDevice]) self.videoDeviceInput = videoDeviceInput } else { // If it fails, put back current camera @@ -408,7 +476,11 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega if session.canAddInput(videoDeviceInput) { session.addInput(videoDeviceInput) - videoDevice.videoZoomFactor = wideAngleZoomFactor(for: videoDevice) + let zoomForDevice = wideAngleZoomFactor(for: videoDevice) + if self.zoom == nil { + videoDevice.videoZoomFactor = zoomForDevice + } + self.onZoom?(["zoom": zoomForDevice]) self.videoDeviceInput = videoDeviceInput } else { return .sessionConfigurationFailed diff --git a/ios/ReactNativeCameraKit/SimulatorCamera.swift b/ios/ReactNativeCameraKit/SimulatorCamera.swift index 0f118cc..be15c83 100644 --- a/ios/ReactNativeCameraKit/SimulatorCamera.swift +++ b/ios/ReactNativeCameraKit/SimulatorCamera.swift @@ -11,6 +11,12 @@ import UIKit */ class SimulatorCamera: CameraProtocol { private var onOrientationChange: RCTDirectEventBlock? + private var onZoom: RCTDirectEventBlock? + private var videoDeviceZoomFactor: Double = 1.0 + private var videoDeviceMaxAvailableVideoZoomFactor: Double = 150.0 + private var wideAngleZoomFactor: Double = 2.0 + private var zoom: Double? + private var maxZoom: Double? var previewView: UIView { mockPreview } @@ -54,9 +60,42 @@ class SimulatorCamera: CameraProtocol { self.onOrientationChange = onOrientationChange } - func update(pinchScale: CGFloat) { + func update(onZoom: RCTDirectEventBlock?) { + self.onZoom = onZoom + } + + func setVideoDevice(zoomFactor: Double) { + self.videoDeviceZoomFactor = zoomFactor + self.mockPreview.zoomLabel.text = "Zoom: \(zoomFactor)" + } + + private var zoomStartedAt: Double = 1.0 + func zoomPinchStart() { DispatchQueue.main.async { - self.mockPreview.zoomVelocityLabel.text = "Zoom Scale: \(pinchScale)" + self.zoomStartedAt = self.videoDeviceZoomFactor + self.mockPreview.zoomLabel.text = "Zoom start" + } + } + + func zoomPinchChange(pinchScale: CGFloat) { + guard !pinchScale.isNaN else { return } + + DispatchQueue.main.async { + let desiredZoomFactor = self.zoomStartedAt * pinchScale + var maxZoomFactor = self.videoDeviceMaxAvailableVideoZoomFactor + if let maxZoom = self.maxZoom { + maxZoomFactor = min(maxZoom, maxZoomFactor) + } + let zoomForDevice = max(1.0, min(desiredZoomFactor, maxZoomFactor)) + + if zoomForDevice != self.videoDeviceZoomFactor { + // Only trigger zoom changes if it's an uncontrolled component (zoom isn't manually set) + // otherwise it's likely to cause issues inf. loops + if self.zoom == nil { + self.setVideoDevice(zoomFactor: zoomForDevice) + } + self.onZoom?(["zoom": zoomForDevice]) + } } } @@ -93,6 +132,36 @@ class SimulatorCamera: CameraProtocol { self.mockPreview.randomize() } } + + func update(maxZoom: Double?) { + self.maxZoom = maxZoom + } + + func update(zoom: Double?) { + self.zoom = zoom + + DispatchQueue.main.async { + var zoomOrDefault = zoom ?? 0 + // -1 will reset to zoom default (which is not 1 on modern cameras) + if zoomOrDefault == 0 { + zoomOrDefault = self.wideAngleZoomFactor + } + + var maxZoomFactor = self.videoDeviceMaxAvailableVideoZoomFactor + if let maxZoom = self.maxZoom { + maxZoomFactor = min(maxZoom, maxZoomFactor) + } + let zoomForDevice = max(1.0, min(zoomOrDefault, maxZoomFactor)) + self.setVideoDevice(zoomFactor: zoomForDevice) + + // If they wanted to reset, tell them what the default zoom turned out to be + // regardless if it's controlled + if self.zoom == nil || zoom == 0 { + self.onZoom?(["zoom": zoomForDevice]) + } + } + } + func isBarcodeScannerEnabled(_ isEnabled: Bool, supportedBarcodeType: [AVMetadataObject.ObjectType], diff --git a/ios/ReactNativeCameraKit/SimulatorPreviewView.swift b/ios/ReactNativeCameraKit/SimulatorPreviewView.swift index 2da8d3a..f6965f2 100644 --- a/ios/ReactNativeCameraKit/SimulatorPreviewView.swift +++ b/ios/ReactNativeCameraKit/SimulatorPreviewView.swift @@ -6,7 +6,7 @@ import UIKit class SimulatorPreviewView: UIView { - let zoomVelocityLabel = UILabel() + let zoomLabel = UILabel() let focusAtLabel = UILabel() let torchModeLabel = UILabel() let flashModeLabel = UILabel() @@ -30,7 +30,7 @@ class SimulatorPreviewView: UIView { stackView.translatesAutoresizingMaskIntoConstraints = false stackView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor).isActive = true stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10).isActive = true - [zoomVelocityLabel, focusAtLabel, torchModeLabel, flashModeLabel, cameraTypeLabel].forEach { + [zoomLabel, focusAtLabel, torchModeLabel, flashModeLabel, cameraTypeLabel].forEach { $0.numberOfLines = 0 stackView.addArrangedSubview($0) } diff --git a/src/Camera.android.tsx b/src/Camera.android.tsx index 07bb2b9..ecb5b36 100644 --- a/src/Camera.android.tsx +++ b/src/Camera.android.tsx @@ -32,8 +32,8 @@ const Camera = React.forwardRef((props: CameraProps, ref) => { return ( ); diff --git a/src/Camera.d.ts b/src/Camera.d.ts index 8464b40..8f01eec 100644 --- a/src/Camera.d.ts +++ b/src/Camera.d.ts @@ -13,16 +13,72 @@ export type OnOrientationChangeData = { }; }; +export type OnZoom = { + nativeEvent: { + zoom: number; + }; +} + export interface CameraProps { ref?: LegacyRef>; style?: StyleProp; // Behavior flashMode?: FlashMode; focusMode?: FocusMode; + /** + * Enable or disable the pinch gesture handler + * Example: + * ``` + * + * ``` + */ zoomMode?: ZoomMode; + /** + * **iOS only.** + * Control zoom. `0` is resets zoom. `1` is the widest zoom possible. Higher values zooms in. + * Example: + * ``` + * const [zoom, setZoom] = useState(0); + *