From 36be2b661327f21ab4b37f0328d182fe2e031f38 Mon Sep 17 00:00:00 2001 From: ZayanKhan-12 Date: Mon, 14 Sep 2026 23:17:25 -0400 Subject: [PATCH] Add an onTapToFocus event Issue #590 asks for a way to react to a tap on the camera area without losing the camera's own gestures. A sibling overlay with an onPress swallows the touch, which kills tap-to-focus and pinch-to-zoom, and the view does not accept children, so there was no way to have both. Report the tap instead. The gesture that already drives tap-to-focus now also emits onTapToFocus with the tap position, so an overlay can react to it while focus and zoom keep working. Nothing about the existing gesture handling changes, and the event costs nothing when the prop is not set. Coordinates are normalized to 0..1 of the preview rather than raw points, so they do not depend on device pixel dimensions. That matches the bounds onFaceDetected already reports. Not called onFocus, as the issue suggested, because CameraProps extends ViewProps and React Native already declares onFocus and onBlur there for accessibility focus; reusing the name would shadow it. The plumbing mirrors onZoom exactly: the codegen spec, the public prop, an RCTDirectEventBlock exported for the old architecture and forwarded to the generated event emitter for the new one, and on Android an Event subclass registered in both the newarch and oldarch view managers. FocusInterfaceView normalizes on iOS since it owns that coordinate space, which also keeps CameraView.swift from growing past the linter's file-length limit. Verified with `yarn build` (tsc), and by running codegen and checking the generated struct is `OnTapToFocus { double x; double y; }`, which is what the new-arch bridging code constructs. SwiftLint over the changed Swift files reports the same six pre-existing violations as master and no new ones. Fixes #590 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MjiwHkBNkrUKxsQUVeeaSH --- README.md | 1 + .../src/main/java/com/rncamerakit/CKCamera.kt | 22 ++++++++++++++ .../com/rncamerakit/events/TapToFocusEvent.kt | 24 +++++++++++++++ .../java/com/rncamerakit/CKCameraManager.kt | 1 + .../java/com/rncamerakit/CKCameraManager.kt | 1 + example/src/CameraExample.tsx | 5 ++++ ios/ReactNativeCameraKit/CKCameraManager.mm | 1 + .../CKCameraViewComponentView.mm | 11 +++++++ ios/ReactNativeCameraKit/CameraView.swift | 4 +++ .../FocusInterfaceView.swift | 9 ++++++ src/CameraProps.ts | 30 +++++++++++++++++++ src/specs/CameraNativeComponent.ts | 6 ++++ 12 files changed, 115 insertions(+) create mode 100644 android/src/main/java/com/rncamerakit/events/TapToFocusEvent.kt 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<{}>;