From aa373cc63e4f83b400636f3ecbd2b37309f761ab Mon Sep 17 00:00:00 2001 From: Marc Rousavy Date: Mon, 24 Aug 2026 18:11:56 +0200 Subject: [PATCH] feat: add native tap-to-focus reset listener --- .../camera/hybrids/HybridCameraController.kt | 78 ++++++++++++++++++- .../HybridTapToFocusGestureController.kt | 22 +++++- .../HybridTapToFocusGestureController.swift | 30 ++++++- .../HybridCameraController.swift | 11 ++- .../ios/Utils/MeteringTask.swift | 14 +++- ...JHybridTapToFocusGestureControllerSpec.cpp | 5 ++ ...JHybridTapToFocusGestureControllerSpec.hpp | 1 + .../HybridTapToFocusGestureControllerSpec.kt | 9 +++ ...idTapToFocusGestureControllerSpecSwift.hpp | 8 ++ ...ybridTapToFocusGestureControllerSpec.swift | 1 + ...dTapToFocusGestureControllerSpec_cxx.swift | 20 +++++ .../HybridTapToFocusGestureControllerSpec.cpp | 1 + .../HybridTapToFocusGestureControllerSpec.hpp | 1 + .../TapToFocusGestureController.nitro.ts | 13 ++++ 14 files changed, 204 insertions(+), 10 deletions(-) diff --git a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/HybridCameraController.kt b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/HybridCameraController.kt index 337601a702..e218e21429 100644 --- a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/HybridCameraController.kt +++ b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/HybridCameraController.kt @@ -2,6 +2,9 @@ package com.margelo.nitro.camera.hybrids import android.animation.Animator import android.animation.ValueAnimator +import android.os.Handler +import android.os.Looper +import android.util.Log import androidx.camera.core.Camera import androidx.camera.core.CameraState import androidx.camera.core.FocusMeteringAction @@ -33,6 +36,7 @@ import com.margelo.nitro.core.Promise import com.margelo.nitro.core.resolve import com.margelo.nitro.core.resolved import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong class HybridCameraController( val camera: Camera, @@ -41,6 +45,10 @@ class HybridCameraController( private val cameraState: CameraState? get() = camera.cameraInfo.cameraState.value private var zoomAnimator: ValueAnimator? = null + private val focusResetHandler = Handler(Looper.getMainLooper()) + private val focusGeneration = AtomicLong() + private val focusResetLock = Any() + private var pendingFocusReset: Runnable? = null override val isConnected: Boolean get() = cameraState?.type == CameraState.Type.OPEN @@ -140,7 +148,14 @@ class HybridCameraController( override fun focusTo( point: HybridMeteringPointSpec, options: FocusOptions, + ): Promise = focusTo(point, options, null) + + fun focusTo( + point: HybridMeteringPointSpec, + options: FocusOptions, + onReset: (() -> Unit)?, ): Promise { + val generation = beginFocusOperation() return Promise.async { val point = point as? HybridMeteringPoint @@ -159,14 +174,68 @@ class HybridCameraController( SceneAdaptiveness.CONTINUOUS -> action.setLockingMode(0) } // Disable auto reset, or set it to a fixed duration (seconds) - autoResetAfter.match( - { _ -> action.disableAutoCancel() }, - { duration -> action.setAutoCancelDuration(duration.toLong(), TimeUnit.SECONDS) }, - ) + if (onReset != null) { + action.disableAutoCancel() + } else { + autoResetAfter.match( + { _ -> action.disableAutoCancel() }, + { duration -> action.setAutoCancelDuration(duration.toLong(), TimeUnit.SECONDS) }, + ) + } } camera.cameraControl .startFocusAndMetering(focusAction.build()) .await() + + if (onReset != null) { + autoResetAfter.match( + { _ -> }, + { duration -> scheduleFocusReset(generation, duration, onReset) }, + ) + } + } + } + + private fun beginFocusOperation(): Long { + val generation = focusGeneration.incrementAndGet() + synchronized(focusResetLock) { + pendingFocusReset?.let { focusResetHandler.removeCallbacks(it) } + pendingFocusReset = null + } + return generation + } + + private fun scheduleFocusReset( + generation: Long, + duration: Double, + onReset: () -> Unit, + ) { + val reset = + Runnable { + if (focusGeneration.get() != generation) return@Runnable + val future = camera.cameraControl.cancelFocusAndMetering() + future.addListener( + { + try { + future.get() + if (focusGeneration.get() == generation) { + synchronized(focusResetLock) { + pendingFocusReset = null + } + onReset() + } + } catch (error: Throwable) { + Log.e(TAG, "Failed to automatically reset focus!", error) + } + }, + { command -> focusResetHandler.post(command) }, + ) + } + synchronized(focusResetLock) { + if (focusGeneration.get() == generation) { + pendingFocusReset = reset + focusResetHandler.postDelayed(reset, (duration * 1_000).toLong()) + } } } @@ -183,6 +252,7 @@ class HybridCameraController( } override fun resetFocus(): Promise { + beginFocusOperation() return Promise.async { camera.cameraControl .cancelFocusAndMetering() diff --git a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/gestures/HybridTapToFocusGestureController.kt b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/gestures/HybridTapToFocusGestureController.kt index 2fc119c055..792a6f5c11 100644 --- a/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/gestures/HybridTapToFocusGestureController.kt +++ b/packages/react-native-vision-camera/android/src/main/java/com/margelo/nitro/camera/hybrids/gestures/HybridTapToFocusGestureController.kt @@ -28,6 +28,7 @@ class HybridTapToFocusGestureController : private var previewView: PreviewView? = null private val onTapListeners = mutableSetOf<(HybridMeteringPointSpec) -> Unit>() private val onFocusCompletedListeners = mutableSetOf<(HybridMeteringPointSpec) -> Unit>() + private val onFocusResetListeners = mutableSetOf<(HybridMeteringPointSpec) -> Unit>() private val context: Context get() = NitroModules.applicationContext ?: throw Error("Context not available!") private val mainHandler = Handler(Looper.getMainLooper()) @@ -50,8 +51,18 @@ class HybridTapToFocusGestureController : val density = context.resources.displayMetrics.density val meteringPoint = HybridMeteringPoint((e.x / density).toDouble(), (e.y / density).toDouble(), null, point) onTapListeners.toList().forEach { it(meteringPoint) } - controller - .focusTo(meteringPoint, FocusOptions(null, null, null, null)) + val focusOptions = FocusOptions(null, null, null, null) + val focusPromise = + if (controller is HybridCameraController) { + controller.focusTo(meteringPoint, focusOptions) { + mainHandler.post { + onFocusResetListeners.toList().forEach { it(meteringPoint) } + } + } + } else { + controller.focusTo(meteringPoint, focusOptions) + } + focusPromise .then { mainHandler.post { onFocusCompletedListeners.toList().forEach { it(meteringPoint) } @@ -122,4 +133,11 @@ class HybridTapToFocusGestureController : onFocusCompletedListeners.remove(onFocusCompleted) } } + + override fun addOnFocusResetListener(onFocusReset: (HybridMeteringPointSpec) -> Unit): ListenerSubscription { + onFocusResetListeners.add(onFocusReset) + return ListenerSubscription { + onFocusResetListeners.remove(onFocusReset) + } + } } diff --git a/packages/react-native-vision-camera/ios/Hybrid Objects/Gestures/HybridTapToFocusGestureController.swift b/packages/react-native-vision-camera/ios/Hybrid Objects/Gestures/HybridTapToFocusGestureController.swift index db80e63b02..d7088d7f84 100644 --- a/packages/react-native-vision-camera/ios/Hybrid Objects/Gestures/HybridTapToFocusGestureController.swift +++ b/packages/react-native-vision-camera/ios/Hybrid Objects/Gestures/HybridTapToFocusGestureController.swift @@ -18,6 +18,7 @@ final class HybridTapToFocusGestureController: HybridTapToFocusGestureController private weak var previewView: (any HybridPreviewViewSpec)? = nil private var onTapListeners: [UUID: (any HybridMeteringPointSpec) -> Void] = [:] private var onFocusCompletedListeners: [UUID: (any HybridMeteringPointSpec) -> Void] = [:] + private var onFocusResetListeners: [UUID: (any HybridMeteringPointSpec) -> Void] = [:] override init() { super.init() @@ -39,7 +40,24 @@ final class HybridTapToFocusGestureController: HybridTapToFocusGestureController let tapListeners = Array(onTapListeners.values) tapListeners.forEach { $0(meteringPoint) } - try controller.focusTo(point: meteringPoint, options: FocusOptions()) + let onReset = { [weak self] in + DispatchQueue.main.async { [weak self] in + guard let self else { return } + let focusResetListeners = Array(self.onFocusResetListeners.values) + focusResetListeners.forEach { $0(meteringPoint) } + } + } + let focusPromise: Promise + if let nativeController = controller as? HybridCameraController { + focusPromise = nativeController.focusTo( + point: meteringPoint, + options: FocusOptions(), + onReset: onReset) + } else { + focusPromise = try controller.focusTo(point: meteringPoint, options: FocusOptions()) + } + + focusPromise .then { [weak self] _ in DispatchQueue.main.async { [weak self] in guard let self else { return } @@ -75,6 +93,16 @@ final class HybridTapToFocusGestureController: HybridTapToFocusGestureController } } + func addOnFocusResetListener(onFocusReset: @escaping (any HybridMeteringPointSpec) -> Void) + -> ListenerSubscription + { + let id = UUID() + onFocusResetListeners[id] = onFocusReset + return ListenerSubscription { [weak self] in + self?.onFocusResetListeners.removeValue(forKey: id) + } + } + func onAttached(to preview: any HybridPreviewViewSpec) { self.previewView = preview } diff --git a/packages/react-native-vision-camera/ios/Hybrid Objects/HybridCameraController.swift b/packages/react-native-vision-camera/ios/Hybrid Objects/HybridCameraController.swift index ff48fd9f4f..30450d6b1b 100644 --- a/packages/react-native-vision-camera/ios/Hybrid Objects/HybridCameraController.swift +++ b/packages/react-native-vision-camera/ios/Hybrid Objects/HybridCameraController.swift @@ -171,6 +171,14 @@ final class HybridCameraController: HybridCameraControllerSpec, NativeCameraCont func focusTo( point: any HybridMeteringPointSpec, options: FocusOptions + ) -> Promise { + return focusTo(point: point, options: options, onReset: nil) + } + + func focusTo( + point: any HybridMeteringPointSpec, + options: FocusOptions, + onReset: (() -> Void)? ) -> Promise { return captureDevice.withLock(queue) { resolve, reject in guard let point = point as? HybridMeteringPoint else { @@ -214,7 +222,8 @@ final class HybridCameraController: HybridCameraControllerSpec, NativeCameraCont // Start listening to updates task.startListening( onComplete: resolve, - onError: reject + onError: reject, + onReset: onReset ) self.activeMeteringTask = task diff --git a/packages/react-native-vision-camera/ios/Utils/MeteringTask.swift b/packages/react-native-vision-camera/ios/Utils/MeteringTask.swift index ea13af310e..a0b7231469 100644 --- a/packages/react-native-vision-camera/ios/Utils/MeteringTask.swift +++ b/packages/react-native-vision-camera/ios/Utils/MeteringTask.swift @@ -42,6 +42,7 @@ final class MeteringTask { private var isFinished = false private var onComplete: (() -> Void)? = nil private var onError: ((Error) -> Void)? = nil + private var onReset: (() -> Void)? = nil private struct MeteringProgress { var settledAt: Date? = nil @@ -87,10 +88,12 @@ final class MeteringTask { */ func startListening( onComplete: @escaping () -> Void, - onError: @escaping (Error) -> Void + onError: @escaping (Error) -> Void, + onReset: (() -> Void)? ) { self.onComplete = onComplete self.onError = onError + self.onReset = onReset // the Timer periodically polls AE/AF/AWB state - this is how we can ensure the states have // been stable for 120ms+ and aren't just fluctuating. let pollTimer = DispatchSource.makeTimerSource(queue: queue) @@ -171,7 +174,14 @@ final class MeteringTask { if case .after(let seconds) = self.autoReset { self.queue.asyncAfter(deadline: .now() + seconds) { [weak self] in guard let self else { return } - try? self.resetMeteringValues() + do { + try self.resetMeteringValues() + let onReset = self.onReset + self.onReset = nil + onReset?() + } catch { + logger.error("Failed to automatically reset metering! \(error)") + } } } } diff --git a/packages/react-native-vision-camera/nitrogen/generated/android/c++/JHybridTapToFocusGestureControllerSpec.cpp b/packages/react-native-vision-camera/nitrogen/generated/android/c++/JHybridTapToFocusGestureControllerSpec.cpp index 9354021e57..0451bb57b8 100644 --- a/packages/react-native-vision-camera/nitrogen/generated/android/c++/JHybridTapToFocusGestureControllerSpec.cpp +++ b/packages/react-native-vision-camera/nitrogen/generated/android/c++/JHybridTapToFocusGestureControllerSpec.cpp @@ -78,5 +78,10 @@ namespace margelo::nitro::camera { auto __result = method(_javaPart, JFunc_void_std__shared_ptr_HybridMeteringPointSpec__cxx::fromCpp(onFocusCompleted)); return __result->toCpp(); } + ListenerSubscription JHybridTapToFocusGestureControllerSpec::addOnFocusResetListener(const std::function& /* point */)>& onFocusReset) { + static const auto method = _javaPart->javaClassStatic()->getMethod(jni::alias_ref /* onFocusReset */)>("addOnFocusResetListener_cxx"); + auto __result = method(_javaPart, JFunc_void_std__shared_ptr_HybridMeteringPointSpec__cxx::fromCpp(onFocusReset)); + return __result->toCpp(); + } } // namespace margelo::nitro::camera diff --git a/packages/react-native-vision-camera/nitrogen/generated/android/c++/JHybridTapToFocusGestureControllerSpec.hpp b/packages/react-native-vision-camera/nitrogen/generated/android/c++/JHybridTapToFocusGestureControllerSpec.hpp index 08d79ba045..341cd4456a 100644 --- a/packages/react-native-vision-camera/nitrogen/generated/android/c++/JHybridTapToFocusGestureControllerSpec.hpp +++ b/packages/react-native-vision-camera/nitrogen/generated/android/c++/JHybridTapToFocusGestureControllerSpec.hpp @@ -59,6 +59,7 @@ namespace margelo::nitro::camera { // Methods ListenerSubscription addOnTapListener(const std::function& /* point */)>& onTap) override; ListenerSubscription addOnFocusCompletedListener(const std::function& /* point */)>& onFocusCompleted) override; + ListenerSubscription addOnFocusResetListener(const std::function& /* point */)>& onFocusReset) override; private: jni::global_ref _javaPart; diff --git a/packages/react-native-vision-camera/nitrogen/generated/android/kotlin/com/margelo/nitro/camera/HybridTapToFocusGestureControllerSpec.kt b/packages/react-native-vision-camera/nitrogen/generated/android/kotlin/com/margelo/nitro/camera/HybridTapToFocusGestureControllerSpec.kt index 9e77873c44..9e9a240434 100644 --- a/packages/react-native-vision-camera/nitrogen/generated/android/kotlin/com/margelo/nitro/camera/HybridTapToFocusGestureControllerSpec.kt +++ b/packages/react-native-vision-camera/nitrogen/generated/android/kotlin/com/margelo/nitro/camera/HybridTapToFocusGestureControllerSpec.kt @@ -47,6 +47,15 @@ abstract class HybridTapToFocusGestureControllerSpec: HybridGestureControllerSpe return __result } + abstract fun addOnFocusResetListener(onFocusReset: (point: HybridMeteringPointSpec) -> Unit): ListenerSubscription + + @DoNotStrip + @Keep + private fun addOnFocusResetListener_cxx(onFocusReset: Func_void_std__shared_ptr_HybridMeteringPointSpec_): ListenerSubscription { + val __result = addOnFocusResetListener(onFocusReset) + return __result + } + // Default implementation of `HybridObject.toString()` override fun toString(): String { return "[HybridObject TapToFocusGestureController]" diff --git a/packages/react-native-vision-camera/nitrogen/generated/ios/c++/HybridTapToFocusGestureControllerSpecSwift.hpp b/packages/react-native-vision-camera/nitrogen/generated/ios/c++/HybridTapToFocusGestureControllerSpecSwift.hpp index 5a8876c483..18bee91d35 100644 --- a/packages/react-native-vision-camera/nitrogen/generated/ios/c++/HybridTapToFocusGestureControllerSpecSwift.hpp +++ b/packages/react-native-vision-camera/nitrogen/generated/ios/c++/HybridTapToFocusGestureControllerSpecSwift.hpp @@ -92,6 +92,14 @@ namespace margelo::nitro::camera { auto __value = std::move(__result.value()); return __value; } + inline ListenerSubscription addOnFocusResetListener(const std::function& /* point */)>& onFocusReset) override { + auto __result = _swiftPart.addOnFocusResetListener(onFocusReset); + if (__result.hasError()) [[unlikely]] { + std::rethrow_exception(__result.error()); + } + auto __value = std::move(__result.value()); + return __value; + } private: VisionCamera::HybridTapToFocusGestureControllerSpec_cxx _swiftPart; diff --git a/packages/react-native-vision-camera/nitrogen/generated/ios/swift/HybridTapToFocusGestureControllerSpec.swift b/packages/react-native-vision-camera/nitrogen/generated/ios/swift/HybridTapToFocusGestureControllerSpec.swift index 8718076a3d..f6be4d34a3 100644 --- a/packages/react-native-vision-camera/nitrogen/generated/ios/swift/HybridTapToFocusGestureControllerSpec.swift +++ b/packages/react-native-vision-camera/nitrogen/generated/ios/swift/HybridTapToFocusGestureControllerSpec.swift @@ -15,6 +15,7 @@ public protocol HybridTapToFocusGestureControllerSpec_protocol: HybridObject, Hy // Methods func addOnTapListener(onTap: @escaping (_ point: (any HybridMeteringPointSpec)) -> Void) throws -> ListenerSubscription func addOnFocusCompletedListener(onFocusCompleted: @escaping (_ point: (any HybridMeteringPointSpec)) -> Void) throws -> ListenerSubscription + func addOnFocusResetListener(onFocusReset: @escaping (_ point: (any HybridMeteringPointSpec)) -> Void) throws -> ListenerSubscription } public extension HybridTapToFocusGestureControllerSpec_protocol { diff --git a/packages/react-native-vision-camera/nitrogen/generated/ios/swift/HybridTapToFocusGestureControllerSpec_cxx.swift b/packages/react-native-vision-camera/nitrogen/generated/ios/swift/HybridTapToFocusGestureControllerSpec_cxx.swift index d1182de240..09143cfea3 100644 --- a/packages/react-native-vision-camera/nitrogen/generated/ios/swift/HybridTapToFocusGestureControllerSpec_cxx.swift +++ b/packages/react-native-vision-camera/nitrogen/generated/ios/swift/HybridTapToFocusGestureControllerSpec_cxx.swift @@ -166,4 +166,24 @@ open class HybridTapToFocusGestureControllerSpec_cxx : HybridGestureControllerSp return bridge.create_Result_ListenerSubscription_(__exceptionPtr) } } + + @inline(__always) + public final func addOnFocusResetListener(onFocusReset: bridge.Func_void_std__shared_ptr_HybridMeteringPointSpec_) -> bridge.Result_ListenerSubscription_ { + do { + let __result = try self.__implementation.addOnFocusResetListener(onFocusReset: { () -> ((any HybridMeteringPointSpec)) -> Void in + let __wrappedFunction = bridge.wrap_Func_void_std__shared_ptr_HybridMeteringPointSpec_(onFocusReset) + return { (__point: (any HybridMeteringPointSpec)) -> Void in + __wrappedFunction.call({ () -> bridge.std__shared_ptr_HybridMeteringPointSpec_ in + let __cxxWrapped = __point.getCxxWrapper() + return __cxxWrapped.getCxxPart() + }()) + } + }()) + let __resultCpp = __result + return bridge.create_Result_ListenerSubscription_(__resultCpp) + } catch (let __error) { + let __exceptionPtr = __error.toCpp() + return bridge.create_Result_ListenerSubscription_(__exceptionPtr) + } + } } diff --git a/packages/react-native-vision-camera/nitrogen/generated/shared/c++/HybridTapToFocusGestureControllerSpec.cpp b/packages/react-native-vision-camera/nitrogen/generated/shared/c++/HybridTapToFocusGestureControllerSpec.cpp index 133912f676..fcdc42ad25 100644 --- a/packages/react-native-vision-camera/nitrogen/generated/shared/c++/HybridTapToFocusGestureControllerSpec.cpp +++ b/packages/react-native-vision-camera/nitrogen/generated/shared/c++/HybridTapToFocusGestureControllerSpec.cpp @@ -17,6 +17,7 @@ namespace margelo::nitro::camera { registerHybrids(this, [](Prototype& prototype) { prototype.registerHybridMethod("addOnTapListener", &HybridTapToFocusGestureControllerSpec::addOnTapListener); prototype.registerHybridMethod("addOnFocusCompletedListener", &HybridTapToFocusGestureControllerSpec::addOnFocusCompletedListener); + prototype.registerHybridMethod("addOnFocusResetListener", &HybridTapToFocusGestureControllerSpec::addOnFocusResetListener); }); } diff --git a/packages/react-native-vision-camera/nitrogen/generated/shared/c++/HybridTapToFocusGestureControllerSpec.hpp b/packages/react-native-vision-camera/nitrogen/generated/shared/c++/HybridTapToFocusGestureControllerSpec.hpp index 06c0e259ca..e93c676e05 100644 --- a/packages/react-native-vision-camera/nitrogen/generated/shared/c++/HybridTapToFocusGestureControllerSpec.hpp +++ b/packages/react-native-vision-camera/nitrogen/generated/shared/c++/HybridTapToFocusGestureControllerSpec.hpp @@ -59,6 +59,7 @@ namespace margelo::nitro::camera { // Methods virtual ListenerSubscription addOnTapListener(const std::function& /* point */)>& onTap) = 0; virtual ListenerSubscription addOnFocusCompletedListener(const std::function& /* point */)>& onFocusCompleted) = 0; + virtual ListenerSubscription addOnFocusResetListener(const std::function& /* point */)>& onFocusReset) = 0; protected: // Hybrid Setup diff --git a/packages/react-native-vision-camera/src/specs/gestures/TapToFocusGestureController.nitro.ts b/packages/react-native-vision-camera/src/specs/gestures/TapToFocusGestureController.nitro.ts index 830cd78dca..62b6e4ea1d 100644 --- a/packages/react-native-vision-camera/src/specs/gestures/TapToFocusGestureController.nitro.ts +++ b/packages/react-native-vision-camera/src/specs/gestures/TapToFocusGestureController.nitro.ts @@ -30,4 +30,17 @@ export interface TapToFocusGestureController extends GestureController { addOnFocusCompletedListener( onFocusCompleted: (point: MeteringPoint) => void, ): ListenerSubscription + + /** + * Adds a listener that is called after focus triggered by a native tap + * automatically resets. + * + * This is not called if the reset fails or the focus is superseded by a + * newer focus operation. Call + * {@linkcode ListenerSubscription.remove | remove()} on the returned + * subscription to stop receiving updates. + */ + addOnFocusResetListener( + onFocusReset: (point: MeteringPoint) => void, + ): ListenerSubscription }