diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 432ee6f8b7..28b074bb52 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 46239cd414..0341aa97a0 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/.npmignore b/.npmignore index 7725d8967d..94d5d567b7 100644 --- a/.npmignore +++ b/.npmignore @@ -7,3 +7,5 @@ example-android/ android/build/ img/ ios/lib/DerivedData/ +images/ +docs/ diff --git a/README.md b/README.md index 726764e5b1..e8d0bf68c1 100644 --- a/README.md +++ b/README.md @@ -114,19 +114,22 @@ Additionally, the Camera can be used for barcode scanning | `style` | StyleProp\ | Style to apply on the camera view | | `flashMode` | `'on'`/`'off'`/`'auto'` | Camera flash mode. Default: `auto` | | `focusMode` | `'on'`/`'off'` | Camera focus mode. Default: `on` | -| `zoomMode` | `'on'`/`'off'` | Enable pinch to zoom camera. Default: `on` | +| `zoomMode` | `'on'`/`'off'` | Enable the pinch to zoom gesture. Default: `on` | +| `zoom` | `number` | Control the zoom. Default: `1.0` | +| `maxZoom` | `number` | Maximum zoom allowed (but not beyond what camera allows). Default: `undefined` (camera default max) | +| `onZoom` | Function | Callback when user makes a pinch gesture, regardless of what the `zoom` prop was set to. Returned event contains `zoom`. Ex: `onZoom={(e) => console.log(e.nativeEvent.zoom)}`. | | `torchMode` | `'on'`/`'off'` | Toggle flash light when camera is active. Default: `off` | | `cameraType` | CameraType.Back/CameraType.Front | Choose what camera to use. Default: `CameraType.Back` | | `onOrientationChange` | Function | Callback when physical device orientation changes. Returned event contains `orientation`. Ex: `onOrientationChange={(event) => console.log(event.nativeEvent.orientation)}`. Use `import { Orientation } from 'react-native-camera-kit'; if (event.nativeEvent.orientation === Orientation.PORTRAIT) { ... }` to understand the new value | | **iOS only** | | `ratioOverlay` | `'int:int'` | Show a guiding overlay in the camera preview for the selected ratio. Does not crop image as of v9.0. Example: `'16:9'` | | `ratioOverlayColor` | Color | Any color with alpha. Default: `'#ffffff77'` | -| `resetFocusTimeout` | Number | Dismiss tap to focus after this many milliseconds. Default `0` (disabled). Example: `5000` is 5 seconds. | +| `resetFocusTimeout` | `number` | Dismiss tap to focus after this many milliseconds. Default `0` (disabled). Example: `5000` is 5 seconds. | | `resetFocusWhenMotionDetected` | Boolean | Dismiss tap to focus when focus area content changes. Native iOS feature, see documentation: https://developer.apple.com/documentation/avfoundation/avcapturedevice/1624644-subjectareachangemonitoringenabl?language=objc). Default `true`. | -| `scanThrottleDelay` | Number | Duration between scan detection in milliseconds. Default 2000 (2s) | +| `scanThrottleDelay` | `number` | Duration between scan detection in milliseconds. Default 2000 (2s) | | **Barcode only** | -| `scanBarcode` | Boolean | Enable barcode scanner. Default: `false` | -| `showFrame` | Boolean | Show frame in barcode scanner. Default: `false` | +| `scanBarcode` | `boolean` | Enable barcode scanner. Default: `false` | +| `showFrame` | `boolean` | Show frame in barcode scanner. Default: `false` | | `laserColor` | Color | Color of barcode scanner laser visualization. Default: `red` | | `frameColor` | Color | Color of barcode scanner frame visualization. Default: `yellow` | | `onReadCode` | Function | Callback when scanner successfully reads barcode. Returned event contains `codeStringValue`. Default: `null`. Ex: `onReadCode={(event) => console.log(event.nativeEvent.codeStringValue)}` | diff --git a/android/src/main/java/com/rncamerakit/CKCamera.kt b/android/src/main/java/com/rncamerakit/CKCamera.kt index 4c11019ec7..b3a2c3c65a 100644 --- a/android/src/main/java/com/rncamerakit/CKCamera.kt +++ b/android/src/main/java/com/rncamerakit/CKCamera.kt @@ -91,6 +91,11 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs private var lensType = CameraSelector.LENS_FACING_BACK private var autoFocus = "on" private var zoomMode = "on" + private var lastOnZoom = 0.0 + private var zoom: Double? = null + private var maxZoom: Double? = null + private var zoomStartedAt = 1.0f + private var pinchGestureStartedAt = 0.0f // Barcode Props private var scanBarcode: Boolean = false @@ -180,13 +185,30 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs orientationListener!!.enable() val scaleDetector = ScaleGestureDetector(context, object: ScaleGestureDetector.SimpleOnScaleGestureListener() { - override fun onScale(detector: ScaleGestureDetector): Boolean { + override fun onScaleBegin(detector: ScaleGestureDetector?): Boolean { + val cameraZoom = camera?.cameraInfo?.zoomState?.value?.zoomRatio ?: return false + detector ?: return false + zoomStartedAt = cameraZoom + pinchGestureStartedAt = detector.currentSpan + return true + } + override fun onScale(detector: ScaleGestureDetector?): Boolean { if (zoomMode == "off") return true - val cameraControl = camera?.cameraControl ?: return true - val zoom = camera?.cameraInfo?.zoomState?.value?.zoomRatio ?: return true - val scaleFactor = detector.scaleFactor - val scale = zoom * scaleFactor - cameraControl.setZoomRatio(scale) + if (detector == null) return true + val videoDevice = camera ?: return true + val pinchScale = detector.currentSpan / pinchGestureStartedAt + + val desiredZoomFactor = zoomStartedAt * pinchScale + val zoomForDevice = getValidZoom(videoDevice, desiredZoomFactor.toDouble()) + + if (zoomForDevice != (videoDevice.cameraInfo.zoomState.value?.zoomRatio ?: -1)) { + // 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 (zoom == null) { + videoDevice.cameraControl.setZoomRatio(zoomForDevice.toFloat()) + } + onZoom(zoomForDevice) + } return true } }) @@ -204,6 +226,37 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs }, ContextCompat.getMainExecutor(getActivity())) } + private fun setZoomFor(videoDevice: Camera, zoom: Double) { + videoDevice.cameraControl.setZoomRatio(zoom.toFloat()) + } + + private fun resetZoom(videoDevice: Camera) { + var zoomForDevice = getValidZoom(videoDevice, 1.0) + val zoomPropValue = this.zoom + if (zoomPropValue != null) { + zoomForDevice = getValidZoom(videoDevice, zoomPropValue) + } + setZoomFor(videoDevice, zoomForDevice) + this.onZoom(zoomForDevice) + } + + private fun getValidZoom(videoDevice: Camera?, zoom: Double): Double { + var zoomOrDefault = zoom + val minZoomFactor = videoDevice?.cameraInfo?.zoomState?.value?.minZoomRatio?.toDouble() + var maxZoomFactor: Double? = videoDevice?.cameraInfo?.zoomState?.value?.maxZoomRatio?.toDouble() + val maxZoom = this.maxZoom + if (maxZoom != null) { + maxZoomFactor = min(maxZoomFactor ?: maxZoom, maxZoom) + } + if (maxZoomFactor != null) { + zoomOrDefault = min(zoomOrDefault, maxZoomFactor) + } + if (minZoomFactor != null) { + zoomOrDefault = max(zoomOrDefault, minZoomFactor) + } + return zoomOrDefault + } + private fun bindCameraUseCases() { if (viewFinder.display == null) return // Get screen metrics used to setup camera for full screen resolution @@ -264,7 +317,10 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs try { // A variable number of use-cases can be passed here - // camera provides access to CameraControl & CameraInfo - camera = cameraProvider.bindToLifecycle(getActivity() as AppCompatActivity, cameraSelector, *useCases.toTypedArray()) + val newCamera = cameraProvider.bindToLifecycle(getActivity() as AppCompatActivity, cameraSelector, *useCases.toTypedArray()) + camera = newCamera + + resetZoom(newCamera) // Attach the viewfinder's surface provider to preview use case preview?.setSurfaceProvider(viewFinder.surfaceProvider) @@ -469,8 +525,45 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs } } - fun setZoomMode(mode: String = "on") { - zoomMode = mode + fun setZoomMode(mode: String?) { + zoomMode = mode ?: "off" + } + + fun setZoom(factor: Double?) { + zoom = factor + var zoomOrDefault = zoom ?: return + val videoDevice = camera ?: return + + val zoomForDevice = this.getValidZoom(camera, zoomOrDefault) + this.setZoomFor(videoDevice, zoomForDevice) + } + + private fun onZoom(desiredZoom: Double?) { + val cameraZoom = camera?.cameraInfo?.zoomState?.value?.zoomRatio?.toDouble() ?: return + val desiredOrCameraZoom = desiredZoom ?: cameraZoom + // ignore duplicate events when zooming to min/max + // but always notify if a desiredZoom wasn't given, + // since that means they wanted to reset setZoom(1.0) + // so we should tell them what zoom it really is + if (desiredZoom != null && desiredOrCameraZoom == lastOnZoom) { + return + } + + lastOnZoom = desiredOrCameraZoom + val event: WritableMap = Arguments.createMap() + event.putDouble("zoom", desiredOrCameraZoom) + currentContext.getJSModule(RCTEventEmitter::class.java).receiveEvent( + id, + "onZoom", + event + ) + } + + fun setMaxZoom(factor: Double?) { + maxZoom = factor + + // Re-update zoom value in case the max was increased + setZoom(zoom) } fun setScanBarcode(enabled: Boolean) { diff --git a/android/src/main/java/com/rncamerakit/CKCameraManager.kt b/android/src/main/java/com/rncamerakit/CKCameraManager.kt index 29875cd854..7e7e685ab3 100644 --- a/android/src/main/java/com/rncamerakit/CKCameraManager.kt +++ b/android/src/main/java/com/rncamerakit/CKCameraManager.kt @@ -46,7 +46,8 @@ class CKCameraManager : SimpleViewManager() { return MapBuilder.of( "onOrientationChange", MapBuilder.of("registrationName", "onOrientationChange"), "onReadCode", MapBuilder.of("registrationName", "onReadCode"), - "onPictureTaken", MapBuilder.of("registrationName", "onPictureTaken") + "onPictureTaken", MapBuilder.of("registrationName", "onPictureTaken"), + "onZoom", MapBuilder.of("registrationName", "onZoom") ) } @@ -71,10 +72,20 @@ class CKCameraManager : SimpleViewManager() { } @ReactProp(name = "zoomMode") - fun setZoomMode(view: CKCamera, mode: String) { + fun setZoomMode(view: CKCamera, mode: String?) { view.setZoomMode(mode) } + @ReactProp(name = "zoom", defaultDouble = -1.0) + fun setZoom(view: CKCamera, factor: Double) { + view.setZoom(if (factor == -1.0) null else factor) + } + + @ReactProp(name = "maxZoom", defaultDouble = 420.0) + fun setMaxZoom(view: CKCamera, factor: Double) { + view.setMaxZoom(factor) + } + @ReactProp(name = "scanBarcode") fun setScanBarcode(view: CKCamera, enabled: Boolean) { view.setScanBarcode(enabled) diff --git a/example/images/cameraButton@2x.png b/example/images/cameraButton@2x.png deleted file mode 100644 index b9bb82cf4e..0000000000 Binary files a/example/images/cameraButton@2x.png and /dev/null differ diff --git a/example/images/cameraFlipIcon.png b/example/images/cameraFlipIcon.png new file mode 100644 index 0000000000..3529c8e338 Binary files /dev/null and b/example/images/cameraFlipIcon.png differ diff --git a/example/images/cameraFlipIcon@2x.png b/example/images/cameraFlipIcon@2x.png deleted file mode 100644 index 18f9f86e54..0000000000 Binary files a/example/images/cameraFlipIcon@2x.png and /dev/null differ diff --git a/example/images/hugging.png b/example/images/hugging.png deleted file mode 100644 index aa96842231..0000000000 Binary files a/example/images/hugging.png and /dev/null differ diff --git a/example/images/openCamera.png b/example/images/openCamera.png deleted file mode 100644 index 73b00fa2c2..0000000000 Binary files a/example/images/openCamera.png and /dev/null differ diff --git a/example/images/openCamera@1.5x.png b/example/images/openCamera@1.5x.png deleted file mode 100644 index a10a32c6bc..0000000000 Binary files a/example/images/openCamera@1.5x.png and /dev/null differ diff --git a/example/images/openCamera@2x.png b/example/images/openCamera@2x.png deleted file mode 100644 index d7bc99e169..0000000000 Binary files a/example/images/openCamera@2x.png and /dev/null differ diff --git a/example/images/openCamera@3x.png b/example/images/openCamera@3x.png deleted file mode 100644 index 502c8a2fd8..0000000000 Binary files a/example/images/openCamera@3x.png and /dev/null differ diff --git a/example/images/openCamera@4x.png b/example/images/openCamera@4x.png deleted file mode 100644 index 9c688634f1..0000000000 Binary files a/example/images/openCamera@4x.png and /dev/null differ diff --git a/example/images/selected.png b/example/images/selected.png deleted file mode 100644 index df23285c1b..0000000000 Binary files a/example/images/selected.png and /dev/null differ diff --git a/example/images/unsupportedImage.png b/example/images/unsupportedImage.png deleted file mode 100644 index a51b5aa69f..0000000000 Binary files a/example/images/unsupportedImage.png and /dev/null differ diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index caffb2a09c..2cdeda91c0 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 0b9de8c572..24fd4d9eb8 100644 --- a/example/src/BarcodeScreenExample.tsx +++ b/example/src/BarcodeScreenExample.tsx @@ -39,12 +39,12 @@ const flashArray = [ const BarcodeExample = ({ onBack }: { onBack: () => void }) => { const cameraRef = useRef(null); const [currentFlashArrayPosition, setCurrentFlashArrayPosition] = useState(0); - const [captureImages, setCaptureImages] = useState([]); + const [flashData, setFlashData] = useState(flashArray[currentFlashArrayPosition]); const [torchMode, setTorchMode] = useState(false); // const [ratios, setRatios] = useState([]); // const [ratioArrayPosition, setRatioArrayPosition] = useState(-1); - const [captured, setCaptured] = useState(false); + const [cameraType, setCameraType] = useState(CameraType.Back); const [barcode, setBarcode] = useState(''); @@ -81,16 +81,6 @@ const BarcodeExample = ({ onBack }: { onBack: () => void }) => { setTorchMode(!torchMode); }; - const onCaptureImagePressed = async () => { - if (!cameraRef.current) return; - const image = await cameraRef.current.capture(); - if (image) { - setCaptured(true); - setCaptureImages([...captureImages, image]); - console.log('image', image); - } - }; - // const onRatioButtonPressed = () => { // const newPosition = (ratioArrayPosition + 1) % ratios.length; // setRatioArrayPosition(newPosition); @@ -184,12 +174,6 @@ const BarcodeExample = ({ onBack }: { onBack: () => void }) => { - - - - - - {barcode} @@ -232,13 +216,9 @@ const styles = StyleSheet.create({ alignItems: 'center', }, backBtnContainer: { - flex: 1, alignItems: 'flex-start', }, captureButtonContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', }, textNumberContainer: { position: 'absolute', diff --git a/example/src/CameraExample.tsx b/example/src/CameraExample.tsx index 5745f565ce..19fd8e221e 100644 --- a/example/src/CameraExample.tsx +++ b/example/src/CameraExample.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef } from 'react'; -import { StyleSheet, Text, View, TouchableOpacity, Image, SafeAreaView } from 'react-native'; +import { StyleSheet, Text, View, TouchableOpacity, Image, SafeAreaView, Animated } from 'react-native'; import Camera from '../../src/Camera'; import { CameraApi, CameraType, CaptureData } from '../../src/types'; import { Orientation } from '../../src'; @@ -34,6 +34,8 @@ const CameraExample = ({ onBack }: { onBack: () => void }) => { const [captured, setCaptured] = useState(false); const [cameraType, setCameraType] = useState(CameraType.Back); const [showImageUri, setShowImageUri] = useState(''); + const [zoom, setZoom] = useState(); + const [orientationAnim] = useState(new Animated.Value(3)); // iOS will error out if capturing too fast, // so block capturing until the current capture is done @@ -54,6 +56,7 @@ const CameraExample = ({ onBack }: { onBack: () => void }) => { const onSwitchCameraPressed = () => { const direction = cameraType === CameraType.Back ? CameraType.Front : CameraType.Back; setCameraType(direction); + setZoom(1); // When changing camera type, reset to default zoom for that camera }; const onSetFlash = () => { @@ -88,23 +91,61 @@ const CameraExample = ({ onBack }: { onBack: () => void }) => { console.log('image', image); }; + function CaptureButton({ onPress, children }: { onPress: () => void, children?: React.ReactNode }) { + const w = 80, brdW = 4, spc = 6; + const cInner = 'white', cOuter = 'white'; + return ( + + + + {children} + + ); + } + + // Counter-rotate the icons to indicate the actual orientation of the captured photo. + // For this example, it'll behave incorrectly since UI orientation is allowed (and already-counter rotates the entire screen) + // For real phone apps, lock your UI orientation using a library like 'react-native-orientation-locker' + const rotateUi = true; + const uiRotation = orientationAnim.interpolate({ + inputRange: [1, 4], + outputRange: ['180deg', '-90deg'], + }); + const uiRotationStyle = rotateUi ? {transform: [{ rotate: uiRotation }]} : undefined; + + function rotateUiTo(rotationValue: number) { + Animated.timing(orientationAnim, { + toValue: rotationValue, + useNativeDriver: true, + duration: 200, + isInteraction: false, + }).start(); + } + return ( {flashData.image && ( - + )} - + + + + setZoom(1)}> + + {zoom ? Number(zoom).toFixed(1) : '??'}x + - @@ -120,28 +161,39 @@ const CameraExample = ({ onBack }: { onBack: () => void }) => { flashMode={flashData?.mode} zoomMode="on" focusMode="on" + resetFocusWhenMotionDetected + zoom={zoom} + maxZoom={10} + onZoom={(e) => { + console.log('zoom', e.nativeEvent.zoom); + setZoom(e.nativeEvent.zoom); + }} torchMode={torchMode ? 'on' : 'off'} 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.PORTRAIT_UPSIDE_DOWN: + console.log('orientationChange', 'PORTRAIT_UPSIDE_DOWN'); + rotateUiTo(1); + break; case Orientation.LANDSCAPE_LEFT: console.log('orientationChange', 'LANDSCAPE_LEFT'); - break; - case Orientation.LANDSCAPE_RIGHT: - console.log('orientationChange', 'LANDSCAPE_RIGHT'); + rotateUiTo(2); break; case Orientation.PORTRAIT: console.log('orientationChange', 'PORTRAIT'); + rotateUiTo(3); break; - case Orientation.PORTRAIT_UPSIDE_DOWN: - console.log('orientationChange', 'PORTRAIT_UPSIDE_DOWN'); + case Orientation.LANDSCAPE_RIGHT: + console.log('orientationChange', 'LANDSCAPE_RIGHT'); + rotateUiTo(4); break; default: console.log('orientationChange', e.nativeEvent); break; - } + } }} /> )} @@ -150,17 +202,16 @@ const CameraExample = ({ onBack }: { onBack: () => void }) => { - Back + Back - - + {numberOfImagesTaken()} - + @@ -197,7 +248,17 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', }, topButton: { - padding: 10, + backgroundColor: '#222', + width: 44, + height: 44, + borderRadius: 22, + justifyContent: 'center', + alignItems: 'center', + }, + topButtonImg: { + margin: 10, + width: 24, + height: 24, }, cameraContainer: { justifyContent: 'center', @@ -235,6 +296,9 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, + zoomFactor: { + color: '#ffffff', + }, thumbnailContainer: { flex: 1, alignItems: 'flex-end', diff --git a/ios/ReactNativeCameraKit/CKCameraManager.m b/ios/ReactNativeCameraKit/CKCameraManager.m index 0de8e2bdc3..b79fd65ce9 100644 --- a/ios/ReactNativeCameraKit/CKCameraManager.m +++ b/ios/ReactNativeCameraKit/CKCameraManager.m @@ -29,10 +29,13 @@ @interface RCT_EXTERN_MODULE(CKCameraManager, RCTViewManager) 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 b186964e36..fda57ef292 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 69e66454eb..7697682401 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 e76a9c076a..2dbe24696e 100644 --- a/ios/ReactNativeCameraKit/RealCamera.swift +++ b/ios/ReactNativeCameraKit/RealCamera.swift @@ -16,7 +16,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega private let cameraPreview = RealPreviewView(frame: .zero) private let session = AVCaptureSession() // Communicate with the session and other session objects on this queue. - private let sessionQueue = DispatchQueue(label: "session queue") + private let sessionQueue = DispatchQueue(label: "com.tesla.react-native-camera-kit") // utilities private var setupResult: SetupResult = .notStarted @@ -34,6 +34,10 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega private var onBarcodeRead: ((_ barcode: String) -> Void)? private var scannerFrameSize: CGRect? = nil private var onOrientationChange: RCTDirectEventBlock? + private var onZoomCallback: RCTDirectEventBlock? + private var lastOnZoom: Double? + private var zoom: Double? + private var maxZoom: Double? private var deviceOrientation = UIDeviceOrientation.unknown private var motionManager: CMMotionManager? @@ -56,7 +60,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega NotificationCenter.default.addObserver(forName: UIDevice.orientationDidChangeNotification, object: UIDevice.current, queue: nil, - using: { [weak self] notification in self?.uiOrientationChanged(notification: notification) }) + using: { _ in self.setVideoOrientationToInterfaceOrientation() }) } @available(*, unavailable) @@ -89,13 +93,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega DispatchQueue.main.async { self.cameraPreview.session = self.session self.cameraPreview.previewLayer.videoGravity = .resizeAspect - var interfaceOrientation: UIInterfaceOrientation - if #available(iOS 13.0, *) { - interfaceOrientation = self.previewView.window!.windowScene!.interfaceOrientation - } else { - interfaceOrientation = UIApplication.shared.statusBarOrientation - } - self.cameraPreview.previewLayer.connection?.videoOrientation = self.videoOrientation(from: interfaceOrientation) + self.setVideoOrientationToInterfaceOrientation() } self.initializeMotionManager() @@ -118,26 +116,77 @@ 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 { guard let videoDevice = self.videoDeviceInput?.device else { return } - - do { - try videoDevice.lockForConfiguration() - - let desiredZoomFactor = videoDevice.videoZoomFactor * pinchScale - let maxZoomFactor = min(20, videoDevice.maxAvailableVideoZoomFactor) - videoDevice.videoZoomFactor = max(1.0, min(desiredZoomFactor, maxZoomFactor)) - - videoDevice.unlockForConfiguration() - } catch { - print("Error setting zoom factor: \(error)") + + let desiredZoomFactor = (self.zoomStartedAt / self.defaultZoomFactor(for: videoDevice)) * pinchScale + let zoomForDevice = self.getValidZoom(forDevice: videoDevice, zoom: desiredZoomFactor) + + if zoomForDevice != self.normalizedZoom(for: videoDevice) { + // 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.setZoomFor(videoDevice, to: zoomForDevice) + } + self.onZoom(desiredZoom: zoomForDevice) } } } + + func update(maxZoom: Double?) { + self.maxZoom = maxZoom + + // Re-update zoom value in case the max was increased + self.update(zoom: self.zoom) + } + + func update(zoom: Double?) { + sessionQueue.async { + self.zoom = zoom + guard let videoDevice = self.videoDeviceInput?.device else { return } + guard let zoom_ = zoom else { return } + + let zoomForDevice = self.getValidZoom(forDevice: videoDevice, zoom: zoom_) + self.setZoomFor(videoDevice, to: zoomForDevice) + } + } + + /** + `desiredZoom` can be nil when we want to notify what the zoom factor really is + */ + func onZoom(desiredZoom: Double?) { + guard let videoDevice = self.videoDeviceInput?.device else { return } + let cameraZoom = normalizedZoom(for: videoDevice) + let desiredOrCameraZoom = desiredZoom ?? cameraZoom + guard desiredOrCameraZoom > -1.0 else { return } + + // ignore duplicate events when zooming to min/max + // but always notify if a desiredZoom wasn't given, + // since that means they wanted to reset setZoom(0.0) + // so we should tell them what zoom it really is + if desiredZoom != nil && desiredOrCameraZoom == lastOnZoom { + return + } + + lastOnZoom = desiredOrCameraZoom + self.onZoomCallback?(["zoom": desiredOrCameraZoom]) + } + + func update(onZoom: RCTDirectEventBlock?) { + self.onZoomCallback = onZoom + } func focus(at touchPoint: CGPoint, focusBehavior: FocusBehavior) { DispatchQueue.main.async { @@ -182,17 +231,14 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega } func update(torchMode: TorchMode) { - self.torchMode = torchMode - sessionQueue.async { + self.torchMode = torchMode guard let videoDevice = self.videoDeviceInput?.device, videoDevice.torchMode != torchMode.avTorchMode else { return } if videoDevice.isTorchModeSupported(torchMode.avTorchMode) && videoDevice.hasTorch { do { try videoDevice.lockForConfiguration() - videoDevice.torchMode = torchMode.avTorchMode - videoDevice.unlockForConfiguration() } catch { print("Error setting torch mode: \(error)") @@ -227,7 +273,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega if self.session.canAddInput(videoDeviceInput) { self.session.addInput(videoDeviceInput) - videoDevice.videoZoomFactor = self.wideAngleZoomFactor(for: videoDevice) + self.resetZoom(forDevice: videoDevice) self.videoDeviceInput = videoDeviceInput } else { // If it fails, put back current camera @@ -288,9 +334,8 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega func isBarcodeScannerEnabled(_ isEnabled: Bool, supportedBarcodeType: [AVMetadataObject.ObjectType], onBarcodeRead: ((_ barcode: String) -> Void)?) { - self.onBarcodeRead = onBarcodeRead - sessionQueue.async { + self.onBarcodeRead = onBarcodeRead let newTypes: [AVMetadataObject.ObjectType] if isEnabled && onBarcodeRead != nil { let availableTypes = self.metadataOutput.availableMetadataObjectTypes @@ -310,10 +355,8 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega func update(scannerFrameSize: CGRect?) { guard self.scannerFrameSize != scannerFrameSize else { return } - - self.scannerFrameSize = scannerFrameSize - self.sessionQueue.async { + self.scannerFrameSize = scannerFrameSize if !self.session.isRunning { return } @@ -408,8 +451,9 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega if session.canAddInput(videoDeviceInput) { session.addInput(videoDeviceInput) - videoDevice.videoZoomFactor = wideAngleZoomFactor(for: videoDevice) + self.videoDeviceInput = videoDeviceInput + self.resetZoom(forDevice: videoDevice) } else { return .sessionConfigurationFailed } @@ -441,7 +485,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega return .success } - private func wideAngleZoomFactor(for videoDevice: AVCaptureDevice) -> CGFloat { + private func defaultZoomFactor(for videoDevice: AVCaptureDevice) -> CGFloat { // Devices that have multiple physical cameras are binded behind one virtual camera input. The zoom factor defines what physical camera it actually uses // Find the 'normal' zoom factor, which on the physical camera defaults to the wide angle if #available(iOS 13.0, *) { @@ -452,7 +496,43 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega } } - return 1.0 + return videoDevice.minAvailableVideoZoomFactor + } + + private func setZoomFor(_ videoDevice: AVCaptureDevice, to zoom: Double) { + do { + try videoDevice.lockForConfiguration() + defer { videoDevice.unlockForConfiguration() } + let defaultZoom = defaultZoomFactor(for: videoDevice) + videoDevice.videoZoomFactor = zoom * defaultZoom + } catch { + print("CKCameraKit: setZoomFor error: \(error))") + } + } + + private func normalizedZoom(for videoDevice: AVCaptureDevice) -> Double { + let defaultZoom = defaultZoomFactor(for: videoDevice) + return videoDevice.videoZoomFactor / defaultZoom + } + + private func getValidZoom(forDevice videoDevice: AVCaptureDevice, zoom: Double) -> Double { + let defaultZoom = defaultZoomFactor(for: videoDevice) + let minZoomFactor = videoDevice.minAvailableVideoZoomFactor / defaultZoom + var maxZoomFactor = videoDevice.maxAvailableVideoZoomFactor / defaultZoom + if let maxZoom { + maxZoomFactor = min(maxZoom, maxZoomFactor) + } + let cappedZoom = max(minZoomFactor, min(zoom, maxZoomFactor)) + return cappedZoom + } + + private func resetZoom(forDevice videoDevice: AVCaptureDevice) { + var zoomForDevice = getValidZoom(forDevice: videoDevice, zoom: 1) + if let zoomPropValue = self.zoom { + zoomForDevice = getValidZoom(forDevice: videoDevice, zoom: zoomPropValue) + } + self.setZoomFor(videoDevice, to: zoomForDevice) + self.onZoom(desiredZoom: zoomForDevice) } // MARK: - Private device orientation from accelerometer @@ -543,13 +623,14 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega resetFocus?() } - private func uiOrientationChanged(notification: Notification) { - guard let device = notification.object as? UIDevice, - let videoOrientation = videoOrientation(from: device.orientation) else { - return + private func setVideoOrientationToInterfaceOrientation() { + var interfaceOrientation: UIInterfaceOrientation + if #available(iOS 13.0, *) { + interfaceOrientation = self.previewView.window?.windowScene?.interfaceOrientation ?? .portrait + } else { + interfaceOrientation = UIApplication.shared.statusBarOrientation } - - self.cameraPreview.previewLayer.connection?.videoOrientation = videoOrientation + self.cameraPreview.previewLayer.connection?.videoOrientation = self.videoOrientation(from: interfaceOrientation) } private func sessionRuntimeError(notification: Notification) { diff --git a/ios/ReactNativeCameraKit/SimulatorCamera.swift b/ios/ReactNativeCameraKit/SimulatorCamera.swift index 0f118ccf45..be15c83b8d 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.zoomStartedAt = self.videoDeviceZoomFactor + self.mockPreview.zoomLabel.text = "Zoom start" + } + } + + func zoomPinchChange(pinchScale: CGFloat) { + guard !pinchScale.isNaN else { return } + DispatchQueue.main.async { - self.mockPreview.zoomVelocityLabel.text = "Zoom Scale: \(pinchScale)" + 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 2da8d3a215..f6965f2390 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 07bb2b984a..40b2dcb043 100644 --- a/src/Camera.android.tsx +++ b/src/Camera.android.tsx @@ -29,14 +29,7 @@ const Camera = React.forwardRef((props: CameraProps, ref) => { transformedProps.frameColor = processColor(props.frameColor); transformedProps.laserColor = processColor(props.laserColor); - return ( - - ); + return ; }); export default Camera; diff --git a/src/Camera.d.ts b/src/Camera.d.ts index 8464b4002b..62b041009e 100644 --- a/src/Camera.d.ts +++ b/src/Camera.d.ts @@ -13,16 +13,75 @@ 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; + /** + * Controls zoom. Higher values zooms in. + * Default zoom is `1.0`, relative to 'wide angle' camera. + * Examples of minimum/widest zoom: + * - iPhone 6S Plus minimum is `1.0` + * - iPhone 14 Pro Max minimum `0.5` + * - Google Pixel 7 minimum is `0.7` + * ## Example + * ``` + * const [zoom, setZoom] = useState(1.0); + *