diff --git a/README.md b/README.md index ce44e5236e..2dafa2f0da 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,7 @@ To have Play Services [pre-download the model in the background after the app is | `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)}`. | +| `onTapToFocus` | Function | Callback when the user taps the preview to focus, fired alongside the built-in tap-to-focus so an overlay can be shown without suppressing focus and zoom. Returned event contains `x` and `y`, normalized 0-1 in preview space. Requires `focusMode` `'on'`. Ex: `onTapToFocus={(e) => console.log(e.nativeEvent.x, e.nativeEvent.y)}`. | | `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 | diff --git a/android/src/main/java/com/rncamerakit/CKCamera.kt b/android/src/main/java/com/rncamerakit/CKCamera.kt index bf8d9e7f44..c577c53c98 100644 --- a/android/src/main/java/com/rncamerakit/CKCamera.kt +++ b/android/src/main/java/com/rncamerakit/CKCamera.kt @@ -274,6 +274,7 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs return@setOnTouchListener scaleDetector.onTouchEvent(event) } focusOnPoint(event.x, event.y) + onTapToFocus(event.x, event.y) return@setOnTouchListener true } @@ -722,6 +723,27 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs ?.dispatchEvent(ZoomEvent(surfaceId, id, desiredOrCameraZoom)) } + // Reported alongside the tap-to-focus above so JS can overlay its own UI + // without having to intercept the touch and suppress focus and zoom. + // Normalized to 0..1 of the preview, matching the bounds onFaceDetected reports. + private fun onTapToFocus(x: Float, y: Float) { + val width = viewFinder.width + val height = viewFinder.height + if (width <= 0 || height <= 0) return + + val surfaceId = UIManagerHelper.getSurfaceId(currentContext) + UIManagerHelper + .getEventDispatcherForReactTag(currentContext, id) + ?.dispatchEvent( + TapToFocusEvent( + surfaceId, + id, + (x / width).toDouble().coerceIn(0.0, 1.0), + (y / height).toDouble().coerceIn(0.0, 1.0), + ) + ) + } + fun setMaxZoom(factor: Double?) { maxZoom = factor diff --git a/android/src/main/java/com/rncamerakit/events/TapToFocusEvent.kt b/android/src/main/java/com/rncamerakit/events/TapToFocusEvent.kt new file mode 100644 index 0000000000..8f7973345f --- /dev/null +++ b/android/src/main/java/com/rncamerakit/events/TapToFocusEvent.kt @@ -0,0 +1,24 @@ +package com.rncamerakit.events + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.events.Event + +class TapToFocusEvent( + surfaceId: Int, + viewId: Int, + private val x: Double, + private val y: Double, +) : Event(surfaceId, viewId) { + override fun getEventName(): String = EVENT_NAME + + override fun getEventData(): WritableMap = + Arguments.createMap().apply { + putDouble("x", x) + putDouble("y", y) + } + + companion object { + const val EVENT_NAME = "topTapToFocus" + } +} diff --git a/android/src/newarch/java/com/rncamerakit/CKCameraManager.kt b/android/src/newarch/java/com/rncamerakit/CKCameraManager.kt index d97c3f6063..ae2147c119 100644 --- a/android/src/newarch/java/com/rncamerakit/CKCameraManager.kt +++ b/android/src/newarch/java/com/rncamerakit/CKCameraManager.kt @@ -57,6 +57,7 @@ class CKCameraManager(context: ReactApplicationContext) : SimpleViewManager void; stress?: boolea console.log('zoom', e.nativeEvent.zoom); setZoom(e.nativeEvent.zoom); }} + onTapToFocus={(e) => { + // Fires alongside the camera's own tap-to-focus, so an overlay can + // react to the tap without suppressing focus and zoom + console.log('tapToFocus', e.nativeEvent.x, e.nativeEvent.y); + }} torchMode={torchMode ? 'on' : 'off'} shutterPhotoSound maxPhotoQualityPrioritization="speed" diff --git a/ios/ReactNativeCameraKit/CKCameraManager.mm b/ios/ReactNativeCameraKit/CKCameraManager.mm index ef8a78249d..520b9cde1e 100644 --- a/ios/ReactNativeCameraKit/CKCameraManager.mm +++ b/ios/ReactNativeCameraKit/CKCameraManager.mm @@ -43,6 +43,7 @@ @interface RCT_EXTERN_MODULE (CKCameraManager, RCTViewManager) RCT_EXPORT_VIEW_PROPERTY(onCaptureButtonPressIn, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(onCaptureButtonPressOut, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(onZoom, RCTDirectEventBlock) +RCT_EXPORT_VIEW_PROPERTY(onTapToFocus, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(resetFocusTimeout, NSInteger) RCT_EXPORT_VIEW_PROPERTY(resetFocusWhenMotionDetected, BOOL) RCT_EXPORT_VIEW_PROPERTY(focusMode, CKFocusMode) diff --git a/ios/ReactNativeCameraKit/CKCameraViewComponentView.mm b/ios/ReactNativeCameraKit/CKCameraViewComponentView.mm index b7fc511335..676c4c172e 100644 --- a/ios/ReactNativeCameraKit/CKCameraViewComponentView.mm +++ b/ios/ReactNativeCameraKit/CKCameraViewComponentView.mm @@ -133,6 +133,17 @@ - (void)prepareView { ->onZoom({.zoom = [zoom doubleValue]}); } }]; + [_view setOnTapToFocus:^(NSDictionary *event) { + __typeof__(self) strongSelf = weakSelf; + + if (strongSelf != nullptr && strongSelf->_eventEmitter != nullptr) { + id x = [event valueForKey:@"x"] == nil ? 0 : [event valueForKey:@"x"]; + id y = [event valueForKey:@"y"] == nil ? 0 : [event valueForKey:@"y"]; + std::dynamic_pointer_cast( + strongSelf->_eventEmitter) + ->onTapToFocus({.x = [x doubleValue], .y = [y doubleValue]}); + } + }]; [_view setOnCaptureButtonPressIn:^(NSDictionary *event) { __typeof__(self) strongSelf = weakSelf; diff --git a/ios/ReactNativeCameraKit/CameraView.swift b/ios/ReactNativeCameraKit/CameraView.swift index 28720e7348..c3275606e0 100644 --- a/ios/ReactNativeCameraKit/CameraView.swift +++ b/ios/ReactNativeCameraKit/CameraView.swift @@ -59,6 +59,7 @@ public class CameraView: UIView { // other @objc public var onOrientationChange: RCTDirectEventBlock? @objc public var onZoom: RCTDirectEventBlock? + @objc public var onTapToFocus: RCTDirectEventBlock? @objc public var resetFocusTimeout = 0 @objc public var resetFocusWhenMotionDetected = false @objc public var focusMode: FocusMode = .on @@ -157,6 +158,9 @@ public class CameraView: UIView { addFullSizeSubview(focusInterfaceView) focusInterfaceView.delegate = camera + focusInterfaceView.onTap = { [weak self] x, y in + self?.onTapToFocus?(["x": x, "y": y]) + } handleCameraPermission() diff --git a/ios/ReactNativeCameraKit/FocusInterfaceView.swift b/ios/ReactNativeCameraKit/FocusInterfaceView.swift index 0f9a02cc43..6d96e915fd 100644 --- a/ios/ReactNativeCameraKit/FocusInterfaceView.swift +++ b/ios/ReactNativeCameraKit/FocusInterfaceView.swift @@ -48,6 +48,11 @@ protocol FocusInterfaceViewDelegate: AnyObject { class FocusInterfaceView: UIView { weak var delegate: FocusInterfaceViewDelegate? + // Reported alongside the focus itself so JS can overlay its own UI without + // intercepting the touch, which would suppress focus and zoom. + // Coordinates are normalized to 0...1 of this view, which is full-size. + var onTap: ((_ x: CGFloat, _ y: CGFloat) -> Void)? + private var resetFocusTimeout = 0 private var resetFocusWhenMotionDetected = false @@ -175,6 +180,10 @@ class FocusInterfaceView: UIView { @objc func focusAndExposeTap(_ gestureRecognizer: UIGestureRecognizer) { let touchPoint = gestureRecognizer.location(in: self) + if let onTap, bounds.width > 0, bounds.height > 0 { + onTap(min(max(touchPoint.x / bounds.width, 0), 1), + min(max(touchPoint.y / bounds.height, 0), 1)) + } delegate?.focus(at: touchPoint, focusBehavior: .customFocus(resetFocusWhenMotionDetected: resetFocusWhenMotionDetected, resetFocus: resetFocus, diff --git a/src/CameraProps.ts b/src/CameraProps.ts index aa33dc2548..539d96bc02 100644 --- a/src/CameraProps.ts +++ b/src/CameraProps.ts @@ -29,6 +29,15 @@ export type OnZoom = { }; }; +export type OnTapToFocusData = { + nativeEvent: { + /** Horizontal position of the tap, normalized 0-1 across the preview's width */ + x: number; + /** Vertical position of the tap, normalized 0-1 across the preview's height */ + y: number; + }; +}; + export type FaceData = { id: number; yaw: number; @@ -125,6 +134,27 @@ export interface CameraProps extends ViewProps { * ``` */ onZoom?: (event: OnZoom) => void; + /** + * Callback triggered when the user taps the preview to focus. + * Fires alongside the built-in tap-to-focus, so an overlay can be shown + * without replacing the camera's own gesture handling. + * + * Coordinates are normalized 0-1 in preview space, matching `onFaceDetected`. + * Requires `focusMode="on"` (the default), since that is when the + * tap-to-focus gesture is active. + * + * Note this is deliberately not called `onFocus`: `ViewProps` already + * declares that prop for accessibility focus. + * Example: + * ``` + * { + * console.log('tapped at', e.nativeEvent.x, e.nativeEvent.y); + * }} + * /> + * ``` + */ + onTapToFocus?: (event: OnTapToFocusData) => void; /** **Android only**. Triggered when camera fails to initialize */ onError?: (event: { nativeEvent: { errorMessage: string } }) => void; // Barcode only diff --git a/src/specs/CameraNativeComponent.ts b/src/specs/CameraNativeComponent.ts index ab5a715227..6c0c849ecb 100644 --- a/src/specs/CameraNativeComponent.ts +++ b/src/specs/CameraNativeComponent.ts @@ -28,6 +28,11 @@ type OnZoom = { zoom: Double; } +type OnTapToFocus = { + x: Double; + y: Double; +}; + type OnFaceDetectedData = { faces: { id: Int32; @@ -67,6 +72,7 @@ export interface NativeProps extends ViewProps { shutterPhotoSound?: boolean; onOrientationChange?: DirectEventHandler; onZoom?: DirectEventHandler; + onTapToFocus?: DirectEventHandler; onError?: DirectEventHandler<{errorMessage: string }>; onReadCode?: DirectEventHandler; onCaptureButtonPressIn?: DirectEventHandler<{}>;