From 17a7a999bc27bfe162702a32afa61f381cc9bd99 Mon Sep 17 00:00:00 2001 From: Thomas Han Date: Tue, 2 Jun 2026 10:03:49 +1200 Subject: [PATCH 1/7] update CameraChangeTracker so that it is more robust against multiple gestures being tracked that could lead to race conditions --- .../rnmbx/components/mapview/RNMBXMapView.kt | 7 +++-- .../mapview/helpers/CameraChangeTracker.kt | 26 +++++++++++++------ 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt index 9043e02562..8fc87e8324 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt @@ -335,11 +335,10 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie fun mapGestureBegin(type:MapGestureType, gesture: T) { isGestureActive = true - mCameraChangeTracker.setReason(CameraChangeReason.USER_GESTURE) + mCameraChangeTracker.setReason(type, CameraChangeReason.USER_GESTURE) handleMapChangedEvent(EventTypes.REGION_WILL_CHANGE) } fun mapGesture(type: MapGestureType, gesture: T): Boolean { - mCameraChangeTracker.setReason(CameraChangeReason.USER_GESTURE) handleMapChangedEvent(EventTypes.REGION_IS_CHANGING) return false } @@ -445,7 +444,7 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie val didChangeEvent = MapChangeEvent(this, EventTypes.REGION_DID_CHANGE, makeRegionPayload(isAnimated)) mManager.handleEvent(didChangeEvent) - mCameraChangeTracker.setReason(CameraChangeReason.NONE) + mCameraChangeTracker.clear() } private fun removeAllFeaturesFromMap(reason: RemovalReason) { @@ -807,7 +806,7 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie fun sendRegionDidChangeEvent() { handleMapChangedEvent(EventTypes.REGION_DID_CHANGE) - mCameraChangeTracker.setReason(CameraChangeReason.NONE) + mCameraChangeTracker.clear() } private fun handleMapChangedEvent(eventType: String) { diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/CameraChangeTracker.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/CameraChangeTracker.kt index 58a83bde9a..a6d9956e2c 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/CameraChangeTracker.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/CameraChangeTracker.kt @@ -1,23 +1,33 @@ package com.rnmapbox.rnmbx.components.mapview.helpers +import com.rnmapbox.rnmbx.components.mapview.MapGestureType + enum class CameraChangeReason { - NONE, USER_GESTURE, DEVELOPER_ANIMATION, SDK_ANIMATION } class CameraChangeTracker { - private var reason : CameraChangeReason = CameraChangeReason.NONE + private val reasonByGesture = mutableMapOf() var isAnimating = false - fun setReason(reason: CameraChangeReason) { - this.reason = reason + + fun setReason(type: MapGestureType, reason: CameraChangeReason) { + reasonByGesture[type] = reason + } + + fun clear() { + reasonByGesture.clear() + } + + fun clearReason(type: MapGestureType) { + reasonByGesture.remove(type) } val isUserInteraction: Boolean - get() = reason == CameraChangeReason.USER_GESTURE || reason == CameraChangeReason.DEVELOPER_ANIMATION + get() = reasonByGesture.values.any { it == CameraChangeReason.USER_GESTURE } val isAnimated: Boolean - get() = reason == CameraChangeReason.DEVELOPER_ANIMATION || reason == CameraChangeReason.SDK_ANIMATION + get() = reasonByGesture.values.any { it == CameraChangeReason.DEVELOPER_ANIMATION || it == CameraChangeReason.SDK_ANIMATION } val isEmpty: Boolean - get() = reason == CameraChangeReason.NONE -} \ No newline at end of file + get() = reasonByGesture.isEmpty() +} From 5630b1b817cb2ad75dc99823c76a0cd50d39d758 Mon Sep 17 00:00:00 2001 From: Thomas Han Date: Tue, 2 Jun 2026 10:05:47 +1200 Subject: [PATCH 2/7] refactor out MapSteadyDetector from RNMBXCameraGestureObserver so that map steady event detection is decoupled and can be minimally reused from RNMBXMapView --- .../rnmbx/components/mapview/RNMBXMapView.kt | 9 + .../mapview/helpers/MapSteadyDetector.kt | 177 +++++++++++ .../events/RNMBXCameraGestureObserver.kt | 281 +++--------------- 3 files changed, 224 insertions(+), 243 deletions(-) create mode 100644 android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/MapSteadyDetector.kt diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt index 8fc87e8324..5775fedb8b 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt @@ -47,6 +47,7 @@ import com.rnmapbox.rnmbx.components.location.LocationComponentManager import com.rnmapbox.rnmbx.components.location.RNMBXNativeUserLocation import com.rnmapbox.rnmbx.components.mapview.helpers.CameraChangeReason import com.rnmapbox.rnmbx.components.mapview.helpers.CameraChangeTracker +import com.rnmapbox.rnmbx.components.mapview.helpers.MapSteadyDetector import com.rnmapbox.rnmbx.components.styles.layers.RNMBXLayer import com.rnmapbox.rnmbx.components.styles.light.RNMBXLight import com.rnmapbox.rnmbx.components.styles.sources.RNMBXSource @@ -176,6 +177,7 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie private val mFeatures = mutableListOf() private var mQueuedFeatures: MutableList? = ArrayList() private val mCameraChangeTracker = CameraChangeTracker() + private var mMapSteadyDetector: MapSteadyDetector? = null private var mPreferredFrameRate: Int? = null private var mMaxPitch: Double? = null private lateinit var mMap: MapboxMap @@ -260,6 +262,13 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie handleMapChangedEvent(EventTypes.MAP_IDLE); }) + mMapSteadyDetector = MapSteadyDetector(map).apply { + onSteady = { idleDurationMs, lastGestureType -> + mCameraChangeTracker.clear() + } + attach() + } + val gesturesPlugin: GesturesPlugin = mapView.gestures gesturesPlugin.addOnMapLongClickListener(_this) gesturesPlugin.addOnMapClickListener(_this) diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/MapSteadyDetector.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/MapSteadyDetector.kt new file mode 100644 index 0000000000..5f78d8ca55 --- /dev/null +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/MapSteadyDetector.kt @@ -0,0 +1,177 @@ +package com.rnmapbox.rnmbx.components.mapview.helpers + +import android.animation.ValueAnimator +import android.os.Handler +import android.os.Looper +import com.mapbox.android.gestures.MoveGestureDetector +import com.mapbox.android.gestures.RotateGestureDetector +import com.mapbox.android.gestures.ShoveGestureDetector +import com.mapbox.android.gestures.StandardScaleGestureDetector +import com.mapbox.maps.MapboxMap +import com.mapbox.maps.plugin.animation.CameraAnimationsLifecycleListener +import com.mapbox.maps.plugin.animation.CameraAnimatorType +import com.mapbox.maps.plugin.gestures.OnMoveListener +import com.mapbox.maps.plugin.gestures.OnRotateListener +import com.mapbox.maps.plugin.gestures.OnScaleListener +import com.mapbox.maps.plugin.gestures.OnShoveListener + +class MapSteadyDetector( + private val mapboxMap: MapboxMap, + var quietPeriodMs: Double = 200.0, + var maxIntervalMs: Double? = null, +) { + var onSteady: ((idleDurationMs: Double, lastGestureType: String?) -> Unit)? = null + var onTimeout: ((lastGestureType: String?) -> Unit)? = null + + private val handler = Handler(Looper.getMainLooper()) + var activeAnimations: Int = 0 + private set + var isGestureActive: Boolean = false + private set + private var lastGestureType: String? = null + var lastTransitionEndedAtMs: Double? = null + private set + private var quietRunnable: Runnable? = null + private var timeoutRunnable: Runnable? = null + + private fun nowMs(): Double = System.currentTimeMillis().toDouble() + + private val canEmitSteady: Boolean + get() = activeAnimations == 0 && !isGestureActive && lastTransitionEndedAtMs != null + + fun attach() { + mapboxMap.cameraAnimationsPlugin { + addCameraAnimationsLifecycleListener(object : CameraAnimationsLifecycleListener { + override fun onAnimatorStarting(type: CameraAnimatorType, animator: ValueAnimator, owner: String?) { + if (owner != GESTURES_OWNER) return + activeAnimations++ + lastTransitionEndedAtMs = null + markActivity() + } + + override fun onAnimatorEnding(type: CameraAnimatorType, animator: ValueAnimator, owner: String?) { + if (owner != GESTURES_OWNER) return + handleAnimatorEnd() + } + + override fun onAnimatorCancelling(type: CameraAnimatorType, animator: ValueAnimator, owner: String?) { + if (owner != GESTURES_OWNER) return + handleAnimatorEnd() + } + + override fun onAnimatorInterrupting( + type: CameraAnimatorType, + runningAnimator: ValueAnimator, + runningAnimatorOwner: String?, + newAnimator: ValueAnimator, + newAnimatorOwner: String? + ) {} + }) + } + + mapboxMap.gesturesPlugin { + addOnMoveListener(object : OnMoveListener { + override fun onMoveBegin(detector: MoveGestureDetector) { handleGestureBegin("move") } + override fun onMove(detector: MoveGestureDetector): Boolean = false + override fun onMoveEnd(detector: MoveGestureDetector) { handleGestureEnd("move") } + }) + addOnScaleListener(object : OnScaleListener { + override fun onScaleBegin(detector: StandardScaleGestureDetector) { handleGestureBegin("scale") } + override fun onScale(detector: StandardScaleGestureDetector) {} + override fun onScaleEnd(detector: StandardScaleGestureDetector) { handleGestureEnd("scale") } + }) + addOnRotateListener(object : OnRotateListener { + override fun onRotateBegin(detector: RotateGestureDetector) { handleGestureBegin("rotate") } + override fun onRotate(detector: RotateGestureDetector) {} + override fun onRotateEnd(detector: RotateGestureDetector) { handleGestureEnd("rotate") } + }) + addOnShoveListener(object : OnShoveListener { + override fun onShoveBegin(detector: ShoveGestureDetector) { handleGestureBegin("shove") } + override fun onShove(detector: ShoveGestureDetector) {} + override fun onShoveEnd(detector: ShoveGestureDetector) { handleGestureEnd("shove") } + }) + } + } + + fun detach() { + cancelQuietTimer() + cancelTimeoutTimer() + } + + private fun cancelQuietTimer() { + quietRunnable?.let { handler.removeCallbacks(it) } + quietRunnable = null + } + + private fun cancelTimeoutTimer() { + timeoutRunnable?.let { handler.removeCallbacks(it) } + timeoutRunnable = null + } + + private fun scheduleQuietCheck() { + cancelQuietTimer() + if (quietPeriodMs <= 0) { + maybeEmitSteady() + return + } + val runnable = Runnable { maybeEmitSteady() } + handler.postDelayed(runnable, quietPeriodMs.toLong()) + quietRunnable = runnable + } + + private fun scheduleTimeoutTimer() { + if (timeoutRunnable != null) return + val delay = maxIntervalMs ?: return + if (delay <= 0) return + val runnable = Runnable { + timeoutRunnable = null + onTimeout?.invoke(lastGestureType) + scheduleTimeoutTimer() + } + handler.postDelayed(runnable, delay.toLong()) + timeoutRunnable = runnable + } + + private fun markActivity(gestureType: String? = null) { + if (gestureType != null) lastGestureType = gestureType + scheduleQuietCheck() + scheduleTimeoutTimer() + } + + private fun maybeEmitSteady() { + if (!canEmitSteady) return + val lastEnd = lastTransitionEndedAtMs ?: return + val sinceEnd = nowMs() - lastEnd + if (sinceEnd < quietPeriodMs) return + cancelQuietTimer() + cancelTimeoutTimer() + val gesture = lastGestureType + onSteady?.invoke(sinceEnd, gesture) + lastGestureType = null + } + + private fun handleAnimatorEnd() { + activeAnimations-- + if (activeAnimations < 0) activeAnimations = 0 + lastTransitionEndedAtMs = nowMs() + scheduleQuietCheck() + } + + private fun handleGestureBegin(type: String) { + isGestureActive = true + lastGestureType = type + lastTransitionEndedAtMs = null + markActivity(lastGestureType) + } + + private fun handleGestureEnd(type: String) { + lastGestureType = type + isGestureActive = false + lastTransitionEndedAtMs = nowMs() + markActivity(lastGestureType) + } + + companion object { + private const val GESTURES_OWNER = "Maps-Gestures" + } +} diff --git a/android/src/main/java/com/rnmapbox/rnmbx/events/RNMBXCameraGestureObserver.kt b/android/src/main/java/com/rnmapbox/rnmbx/events/RNMBXCameraGestureObserver.kt index dbeced9976..3d1384b1ee 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/events/RNMBXCameraGestureObserver.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/events/RNMBXCameraGestureObserver.kt @@ -1,23 +1,11 @@ package com.rnmapbox.rnmbx.events -import android.animation.ValueAnimator import android.content.Context -import android.os.Handler -import android.os.Looper import android.util.Log -import com.mapbox.android.gestures.MoveGestureDetector -import com.mapbox.android.gestures.RotateGestureDetector -import com.mapbox.android.gestures.ShoveGestureDetector -import com.mapbox.android.gestures.StandardScaleGestureDetector -import com.mapbox.maps.plugin.animation.CameraAnimationsLifecycleListener -import com.mapbox.maps.plugin.animation.CameraAnimatorType -import com.mapbox.maps.plugin.gestures.OnMoveListener -import com.mapbox.maps.plugin.gestures.OnRotateListener -import com.mapbox.maps.plugin.gestures.OnScaleListener -import com.mapbox.maps.plugin.gestures.OnShoveListener import com.rnmapbox.rnmbx.components.AbstractMapFeature import com.rnmapbox.rnmbx.components.RemovalReason import com.rnmapbox.rnmbx.components.mapview.RNMBXMapView +import com.rnmapbox.rnmbx.components.mapview.helpers.MapSteadyDetector class RNMBXCameraGestureObserver( private val mContext: Context, @@ -30,24 +18,16 @@ class RNMBXCameraGestureObserver( override var requiresStyleLoad: Boolean = false - private val handler = Handler(Looper.getMainLooper()) - private var activeAnimations: Int = 0 - private var isGestureActive: Boolean = false - private var lastGestureType: String? = null - private var lastTransitionEndedAtMs: Double? = null - private var quietRunnable: Runnable? = null - private var timeoutRunnable: Runnable? = null + private var detector: MapSteadyDetector? = null - private val quietMs: Double get() = quietPeriodMs ?: 200.0 - private val maxMs: Double? get() = maxIntervalMs - - private fun nowMs(): Double = System.currentTimeMillis().toDouble() - private fun timestamp(): Double = nowMs() - - private val canEmitSteady: Boolean - get() = activeAnimations == 0 && !isGestureActive && lastTransitionEndedAtMs != null + private fun debugLog(message: String) { + Log.d( + LOG_TAG, + "$message; activeAnimations=${detector?.activeAnimations} isGestureActive=${detector?.isGestureActive} lastTransitionEnd=${detector?.lastTransitionEndedAtMs ?: -1}" + ) + } - private fun normalizeGestureType(type: String): String { + private fun normalizeGestureType(type: String?): String? { return when (type) { "move" -> "pan" "scale" -> "pinch" @@ -57,237 +37,52 @@ class RNMBXCameraGestureObserver( } } - private fun debugLog(message: String) { - Log.d( - LOG_TAG, - "$message; activeAnimations=$activeAnimations isGestureActive=$isGestureActive lastTransitionEnd=${lastTransitionEndedAtMs ?: -1}" - ) - } - - private fun scheduleTimer(delay: Double, task: Runnable): Runnable { - handler.removeCallbacks(task) - if (delay > 0) { - handler.postDelayed(task, delay.toLong()) - } - return task - } - - private fun cancelQuietTimer() { - quietRunnable?.let { handler.removeCallbacks(it) } - quietRunnable = null - } - - private fun cancelTimeoutTimer() { - timeoutRunnable?.let { handler.removeCallbacks(it) } - timeoutRunnable = null - } - - private fun scheduleQuietCheck() { - cancelQuietTimer() - val delay = quietMs - if (delay <= 0) { - maybeEmitSteady() - return - } - debugLog("scheduleQuietCheck in ${delay.toInt()}ms") - val runnable = Runnable { - debugLog("quiet timer fired") - maybeEmitSteady() - } - quietRunnable = scheduleTimer(delay, runnable) - } - - private fun scheduleTimeout() { - if (timeoutRunnable != null) return - val delay = maxMs ?: return - val runnable = Runnable { - timeoutRunnable = null - emitTimeout() - } - timeoutRunnable = scheduleTimer(delay, runnable) - } - - private fun markActivity(gestureType: String? = null) { - if (gestureType != null) lastGestureType = gestureType - scheduleQuietCheck() - scheduleTimeout() - } - - private fun maybeEmitSteady() { - if (!canEmitSteady) return - val lastEnd = lastTransitionEndedAtMs ?: return - val sinceEnd = nowMs() - lastEnd - if (sinceEnd < quietMs) return - emitSteady(sinceEnd) - } - - private fun emitSteady(idleDurationMs: Double) { - cancelQuietTimer() - cancelTimeoutTimer() - val gesture = lastGestureType - debugLog("EMIT steady idleDurationMs=$idleDurationMs lastGestureType=$gesture") - mManager.handleEvent( - MapSteadyEvent.make(this, "steady", idleDurationMs, gesture) - ) - lastGestureType = null - } - - private fun emitTimeout() { - cancelQuietTimer() - debugLog("EMIT timeout lastGestureType=$lastGestureType") - mManager.handleEvent( - MapSteadyEvent.make(this, "timeout", null, lastGestureType) - ) - scheduleTimeout() - } - override fun addToMap(mapView: RNMBXMapView) { super.addToMap(mapView) if (!hasOnMapSteady) return mapView.getMapAsync { mapboxMap -> - // Camera animations lifecycle - mapboxMap.cameraAnimationsPlugin { - this.addCameraAnimationsLifecycleListener(object : CameraAnimationsLifecycleListener { - override fun onAnimatorStarting( - type: CameraAnimatorType, - animator: ValueAnimator, - owner: String? - ) { - if (owner != "Maps-Gestures") return - activeAnimations++ - lastTransitionEndedAtMs = null - markActivity() - debugLog("camera animator started") - } - - override fun onAnimatorEnding( - type: CameraAnimatorType, - animator: ValueAnimator, - owner: String? - ) { - if (owner != "Maps-Gestures") return - handleAnimatorEnd() - } - - override fun onAnimatorCancelling( - type: CameraAnimatorType, - animator: ValueAnimator, - owner: String? - ) { - if (owner != "Maps-Gestures") return - handleAnimatorEnd() - } - - override fun onAnimatorInterrupting( - type: CameraAnimatorType, - runningAnimator: ValueAnimator, - runningAnimatorOwner: String?, - newAnimator: ValueAnimator, - newAnimatorOwner: String? - ) { - // Interruptions are handled by the ending/starting callbacks - } - }) + val det = MapSteadyDetector( + mapboxMap = mapboxMap, + quietPeriodMs = quietPeriodMs ?: 200.0, + maxIntervalMs = maxIntervalMs, + ) + det.onSteady = { idleDurationMs, lastGestureType -> + debugLog("EMIT steady idleDurationMs=$idleDurationMs lastGestureType=$lastGestureType") + mManager.handleEvent( + MapSteadyEvent.make( + this, + "steady", + idleDurationMs, + normalizeGestureType(lastGestureType) + ) + ) } - - // Gesture listeners - mapboxMap.gesturesPlugin { - this.addOnMoveListener(object : OnMoveListener { - override fun onMoveBegin(detector: MoveGestureDetector) { - handleGestureBegin("move") - } - - override fun onMove(detector: MoveGestureDetector): Boolean { - return false - } - - override fun onMoveEnd(detector: MoveGestureDetector) { - handleGestureEnd("move") - } - }) - - this.addOnScaleListener(object : OnScaleListener { - override fun onScaleBegin(detector: StandardScaleGestureDetector) { - handleGestureBegin("scale") - } - - override fun onScale(detector: StandardScaleGestureDetector) { - } - - override fun onScaleEnd(detector: StandardScaleGestureDetector) { - handleGestureEnd("scale") - } - }) - - this.addOnRotateListener(object : OnRotateListener { - override fun onRotateBegin(detector: RotateGestureDetector) { - handleGestureBegin("rotate") - } - - override fun onRotate(detector: RotateGestureDetector) { - } - - override fun onRotateEnd(detector: RotateGestureDetector) { - handleGestureEnd("rotate") - } - }) - - this.addOnShoveListener(object : OnShoveListener { - override fun onShoveBegin(detector: ShoveGestureDetector) { - handleGestureBegin("shove") - } - - override fun onShove(detector: ShoveGestureDetector) { - } - - override fun onShoveEnd(detector: ShoveGestureDetector) { - handleGestureEnd("shove") - } - }) + det.onTimeout = { lastGestureType -> + debugLog("EMIT timeout lastGestureType=$lastGestureType") + mManager.handleEvent( + MapSteadyEvent.make( + this, + "timeout", + null, + normalizeGestureType(lastGestureType) + ) + ) } - + det.attach() + detector = det debugLog("addToMap and subscribed to gestures") } } override fun removeFromMap(mapView: RNMBXMapView, reason: RemovalReason): Boolean { debugLog("removeFromMap and unsubscribed from gestures") - cancelQuietTimer() - cancelTimeoutTimer() + detector?.detach() + detector = null return super.removeFromMap(mapView, reason) } - private fun handleAnimatorEnd() { - activeAnimations-- - if (activeAnimations < 0) { - Log.w(LOG_TAG, "WARNING: activeAnimations went negative, resetting to 0") - activeAnimations = 0 - } - lastTransitionEndedAtMs = nowMs() - scheduleQuietCheck() - debugLog("camera animator ended") - } - - private fun handleGestureBegin(type: String) { - isGestureActive = true - lastGestureType = normalizeGestureType(type) - lastTransitionEndedAtMs = null - markActivity(lastGestureType) - debugLog("gesture didBegin type=$lastGestureType") - } - - private fun handleGestureEnd(type: String) { - lastGestureType = normalizeGestureType(type) - // On Android, gesture end callbacks fire AFTER animations complete - // So we can mark the gesture as inactive and transition as ended - isGestureActive = false - lastTransitionEndedAtMs = nowMs() - markActivity(lastGestureType) - debugLog("gesture didEnd type=$lastGestureType -> isGestureActive=$isGestureActive") - } - companion object { const val LOG_TAG = "RNMBXCameraGestureObserver" } From 19a8de37951dd1a4ee34505c0148609e8bfe5722 Mon Sep 17 00:00:00 2001 From: Thomas Han Date: Tue, 2 Jun 2026 10:14:33 +1200 Subject: [PATCH 3/7] - refactor MapGestureType so that it is a reusable enum across different kotlin components - refactor MapSteadyDetector so that it uses enum instead of magic strings --- .../components/mapview/MapGestureType.kt | 5 ++++ .../rnmbx/components/mapview/RNMBXMapView.kt | 4 --- .../mapview/helpers/MapSteadyDetector.kt | 29 ++++++++++--------- .../events/RNMBXCameraGestureObserver.kt | 16 +++++----- 4 files changed, 28 insertions(+), 26 deletions(-) create mode 100644 android/src/main/java/com/rnmapbox/rnmbx/components/mapview/MapGestureType.kt diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/MapGestureType.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/MapGestureType.kt new file mode 100644 index 0000000000..b38f212443 --- /dev/null +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/MapGestureType.kt @@ -0,0 +1,5 @@ +package com.rnmapbox.rnmbx.components.mapview + +enum class MapGestureType { + Move, Scale, Rotate, Fling, Shove +} diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt index 5775fedb8b..8c7eae8905 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt @@ -93,10 +93,6 @@ data class OrnamentSettings( var position: Int = -1 ) -enum class MapGestureType { - Move,Scale,Rotate,Fling,Shove -} - fun interface Cancelable { fun cancel() } diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/MapSteadyDetector.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/MapSteadyDetector.kt index 5f78d8ca55..fc9744b7b3 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/MapSteadyDetector.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/MapSteadyDetector.kt @@ -14,21 +14,22 @@ import com.mapbox.maps.plugin.gestures.OnMoveListener import com.mapbox.maps.plugin.gestures.OnRotateListener import com.mapbox.maps.plugin.gestures.OnScaleListener import com.mapbox.maps.plugin.gestures.OnShoveListener +import com.rnmapbox.rnmbx.components.mapview.MapGestureType class MapSteadyDetector( private val mapboxMap: MapboxMap, var quietPeriodMs: Double = 200.0, var maxIntervalMs: Double? = null, ) { - var onSteady: ((idleDurationMs: Double, lastGestureType: String?) -> Unit)? = null - var onTimeout: ((lastGestureType: String?) -> Unit)? = null + var onSteady: ((idleDurationMs: Double, lastGestureType: MapGestureType?) -> Unit)? = null + var onTimeout: ((lastGestureType: MapGestureType?) -> Unit)? = null private val handler = Handler(Looper.getMainLooper()) var activeAnimations: Int = 0 private set var isGestureActive: Boolean = false private set - private var lastGestureType: String? = null + private var lastGestureType: MapGestureType? = null var lastTransitionEndedAtMs: Double? = null private set private var quietRunnable: Runnable? = null @@ -71,24 +72,24 @@ class MapSteadyDetector( mapboxMap.gesturesPlugin { addOnMoveListener(object : OnMoveListener { - override fun onMoveBegin(detector: MoveGestureDetector) { handleGestureBegin("move") } + override fun onMoveBegin(detector: MoveGestureDetector) { handleGestureBegin(MapGestureType.Move) } override fun onMove(detector: MoveGestureDetector): Boolean = false - override fun onMoveEnd(detector: MoveGestureDetector) { handleGestureEnd("move") } + override fun onMoveEnd(detector: MoveGestureDetector) { handleGestureEnd(MapGestureType.Move) } }) addOnScaleListener(object : OnScaleListener { - override fun onScaleBegin(detector: StandardScaleGestureDetector) { handleGestureBegin("scale") } + override fun onScaleBegin(detector: StandardScaleGestureDetector) { handleGestureBegin(MapGestureType.Scale) } override fun onScale(detector: StandardScaleGestureDetector) {} - override fun onScaleEnd(detector: StandardScaleGestureDetector) { handleGestureEnd("scale") } + override fun onScaleEnd(detector: StandardScaleGestureDetector) { handleGestureEnd(MapGestureType.Scale) } }) addOnRotateListener(object : OnRotateListener { - override fun onRotateBegin(detector: RotateGestureDetector) { handleGestureBegin("rotate") } + override fun onRotateBegin(detector: RotateGestureDetector) { handleGestureBegin(MapGestureType.Rotate) } override fun onRotate(detector: RotateGestureDetector) {} - override fun onRotateEnd(detector: RotateGestureDetector) { handleGestureEnd("rotate") } + override fun onRotateEnd(detector: RotateGestureDetector) { handleGestureEnd(MapGestureType.Rotate) } }) addOnShoveListener(object : OnShoveListener { - override fun onShoveBegin(detector: ShoveGestureDetector) { handleGestureBegin("shove") } + override fun onShoveBegin(detector: ShoveGestureDetector) { handleGestureBegin(MapGestureType.Shove) } override fun onShove(detector: ShoveGestureDetector) {} - override fun onShoveEnd(detector: ShoveGestureDetector) { handleGestureEnd("shove") } + override fun onShoveEnd(detector: ShoveGestureDetector) { handleGestureEnd(MapGestureType.Shove) } }) } } @@ -132,7 +133,7 @@ class MapSteadyDetector( timeoutRunnable = runnable } - private fun markActivity(gestureType: String? = null) { + private fun markActivity(gestureType: MapGestureType? = null) { if (gestureType != null) lastGestureType = gestureType scheduleQuietCheck() scheduleTimeoutTimer() @@ -157,14 +158,14 @@ class MapSteadyDetector( scheduleQuietCheck() } - private fun handleGestureBegin(type: String) { + private fun handleGestureBegin(type: MapGestureType) { isGestureActive = true lastGestureType = type lastTransitionEndedAtMs = null markActivity(lastGestureType) } - private fun handleGestureEnd(type: String) { + private fun handleGestureEnd(type: MapGestureType) { lastGestureType = type isGestureActive = false lastTransitionEndedAtMs = nowMs() diff --git a/android/src/main/java/com/rnmapbox/rnmbx/events/RNMBXCameraGestureObserver.kt b/android/src/main/java/com/rnmapbox/rnmbx/events/RNMBXCameraGestureObserver.kt index 3d1384b1ee..1e6462064e 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/events/RNMBXCameraGestureObserver.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/events/RNMBXCameraGestureObserver.kt @@ -4,6 +4,7 @@ import android.content.Context import android.util.Log import com.rnmapbox.rnmbx.components.AbstractMapFeature import com.rnmapbox.rnmbx.components.RemovalReason +import com.rnmapbox.rnmbx.components.mapview.MapGestureType import com.rnmapbox.rnmbx.components.mapview.RNMBXMapView import com.rnmapbox.rnmbx.components.mapview.helpers.MapSteadyDetector @@ -27,14 +28,13 @@ class RNMBXCameraGestureObserver( ) } - private fun normalizeGestureType(type: String?): String? { - return when (type) { - "move" -> "pan" - "scale" -> "pinch" - "rotate" -> "rotate" - "shove" -> "pitch" - else -> type - } + private fun normalizeGestureType(type: MapGestureType?): String? = when (type) { + MapGestureType.Move -> "pan" + MapGestureType.Scale -> "pinch" + MapGestureType.Rotate -> "rotate" + MapGestureType.Shove -> "pitch" + MapGestureType.Fling -> "fling" + null -> null } override fun addToMap(mapView: RNMBXMapView) { From 02fb3c47dd74b43d62f79982b0847acd8c5f7b35 Mon Sep 17 00:00:00 2001 From: Thomas Han Date: Sun, 19 Jul 2026 10:19:40 +1200 Subject: [PATCH 4/7] revert CameraChangeTracker changes --- .../rnmbx/components/mapview/RNMBXMapView.kt | 9 ++++--- .../mapview/helpers/CameraChangeTracker.kt | 26 ++++++------------- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt index 8c7eae8905..0ce964f938 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt @@ -260,7 +260,7 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie mMapSteadyDetector = MapSteadyDetector(map).apply { onSteady = { idleDurationMs, lastGestureType -> - mCameraChangeTracker.clear() + mCameraChangeTracker.setReason(CameraChangeReason.NONE) } attach() } @@ -340,10 +340,11 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie fun mapGestureBegin(type:MapGestureType, gesture: T) { isGestureActive = true - mCameraChangeTracker.setReason(type, CameraChangeReason.USER_GESTURE) + mCameraChangeTracker.setReason(CameraChangeReason.USER_GESTURE) handleMapChangedEvent(EventTypes.REGION_WILL_CHANGE) } fun mapGesture(type: MapGestureType, gesture: T): Boolean { + mCameraChangeTracker.setReason(CameraChangeReason.USER_GESTURE) handleMapChangedEvent(EventTypes.REGION_IS_CHANGING) return false } @@ -449,7 +450,7 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie val didChangeEvent = MapChangeEvent(this, EventTypes.REGION_DID_CHANGE, makeRegionPayload(isAnimated)) mManager.handleEvent(didChangeEvent) - mCameraChangeTracker.clear() + mCameraChangeTracker.setReason(CameraChangeReason.NONE) } private fun removeAllFeaturesFromMap(reason: RemovalReason) { @@ -811,7 +812,7 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie fun sendRegionDidChangeEvent() { handleMapChangedEvent(EventTypes.REGION_DID_CHANGE) - mCameraChangeTracker.clear() + mCameraChangeTracker.setReason(CameraChangeReason.NONE) } private fun handleMapChangedEvent(eventType: String) { diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/CameraChangeTracker.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/CameraChangeTracker.kt index a6d9956e2c..58a83bde9a 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/CameraChangeTracker.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/CameraChangeTracker.kt @@ -1,33 +1,23 @@ package com.rnmapbox.rnmbx.components.mapview.helpers -import com.rnmapbox.rnmbx.components.mapview.MapGestureType - enum class CameraChangeReason { + NONE, USER_GESTURE, DEVELOPER_ANIMATION, SDK_ANIMATION } class CameraChangeTracker { - private val reasonByGesture = mutableMapOf() + private var reason : CameraChangeReason = CameraChangeReason.NONE var isAnimating = false - - fun setReason(type: MapGestureType, reason: CameraChangeReason) { - reasonByGesture[type] = reason - } - - fun clear() { - reasonByGesture.clear() - } - - fun clearReason(type: MapGestureType) { - reasonByGesture.remove(type) + fun setReason(reason: CameraChangeReason) { + this.reason = reason } val isUserInteraction: Boolean - get() = reasonByGesture.values.any { it == CameraChangeReason.USER_GESTURE } + get() = reason == CameraChangeReason.USER_GESTURE || reason == CameraChangeReason.DEVELOPER_ANIMATION val isAnimated: Boolean - get() = reasonByGesture.values.any { it == CameraChangeReason.DEVELOPER_ANIMATION || it == CameraChangeReason.SDK_ANIMATION } + get() = reason == CameraChangeReason.DEVELOPER_ANIMATION || reason == CameraChangeReason.SDK_ANIMATION val isEmpty: Boolean - get() = reasonByGesture.isEmpty() -} + get() = reason == CameraChangeReason.NONE +} \ No newline at end of file From f0a865df7c6d8c516335eed24f5d7cba37a63e1d Mon Sep 17 00:00:00 2001 From: Thomas Han Date: Sun, 26 Jul 2026 00:14:32 +1200 Subject: [PATCH 5/7] remove unused MapSteadyDetector from RNMBXMapView The MapSteadyDetector instance in RNMBXMapView only reset the camera change reason to NONE and is no longer needed. Remove it and its import. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt | 9 --------- 1 file changed, 9 deletions(-) diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt index 0ce964f938..bf59747165 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapView.kt @@ -47,7 +47,6 @@ import com.rnmapbox.rnmbx.components.location.LocationComponentManager import com.rnmapbox.rnmbx.components.location.RNMBXNativeUserLocation import com.rnmapbox.rnmbx.components.mapview.helpers.CameraChangeReason import com.rnmapbox.rnmbx.components.mapview.helpers.CameraChangeTracker -import com.rnmapbox.rnmbx.components.mapview.helpers.MapSteadyDetector import com.rnmapbox.rnmbx.components.styles.layers.RNMBXLayer import com.rnmapbox.rnmbx.components.styles.light.RNMBXLight import com.rnmapbox.rnmbx.components.styles.sources.RNMBXSource @@ -173,7 +172,6 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie private val mFeatures = mutableListOf() private var mQueuedFeatures: MutableList? = ArrayList() private val mCameraChangeTracker = CameraChangeTracker() - private var mMapSteadyDetector: MapSteadyDetector? = null private var mPreferredFrameRate: Int? = null private var mMaxPitch: Double? = null private lateinit var mMap: MapboxMap @@ -258,13 +256,6 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie handleMapChangedEvent(EventTypes.MAP_IDLE); }) - mMapSteadyDetector = MapSteadyDetector(map).apply { - onSteady = { idleDurationMs, lastGestureType -> - mCameraChangeTracker.setReason(CameraChangeReason.NONE) - } - attach() - } - val gesturesPlugin: GesturesPlugin = mapView.gestures gesturesPlugin.addOnMapLongClickListener(_this) gesturesPlugin.addOnMapClickListener(_this) From 239456670a6089176e181a4e898b4c51f10837c3 Mon Sep 17 00:00:00 2001 From: Thomas Han Date: Sun, 26 Jul 2026 00:14:50 +1200 Subject: [PATCH 6/7] feat: add onMapCameraChange event to CameraGestureObserver Adds a new onMapCameraChange event that emits a camera snapshot (center, bounds, zoom, heading, pitch, isUserInteraction, timestamp) on every camera change. - add MapCameraChangeDetector to derive the change reason from live gesture/animation state and forward CameraChanged events - add MapCameraChangeEvent to serialize the snapshot into the JS shape - wire hasOnMapCameraChange prop and onMapCameraChange event through the observer, view manager, JS wrapper and codegen spec Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../helpers/MapCameraChangeDetector.kt | 116 ++++++++++++++++++ .../rnmbx/events/MapCameraChangeEvent.kt | 105 ++++++++++++++++ .../events/RNMBXCameraGestureObserver.kt | 87 ++++++++----- .../RNMBXCameraGestureObserverManager.kt | 16 ++- src/components/CameraGestureObserver.tsx | 7 ++ ...MBXCameraGestureObserverNativeComponent.ts | 22 ++++ 6 files changed, 319 insertions(+), 34 deletions(-) create mode 100644 android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/MapCameraChangeDetector.kt create mode 100644 android/src/main/java/com/rnmapbox/rnmbx/events/MapCameraChangeEvent.kt diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/MapCameraChangeDetector.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/MapCameraChangeDetector.kt new file mode 100644 index 0000000000..6053e8ccee --- /dev/null +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/MapCameraChangeDetector.kt @@ -0,0 +1,116 @@ +package com.rnmapbox.rnmbx.components.mapview.helpers + +import android.animation.ValueAnimator +import com.mapbox.android.gestures.MoveGestureDetector +import com.mapbox.android.gestures.RotateGestureDetector +import com.mapbox.android.gestures.ShoveGestureDetector +import com.mapbox.android.gestures.StandardScaleGestureDetector +import com.mapbox.common.Cancelable +import com.mapbox.maps.CameraChanged +import com.mapbox.maps.MapboxMap +import com.mapbox.maps.plugin.animation.CameraAnimationsLifecycleListener +import com.mapbox.maps.plugin.animation.CameraAnimatorType +import com.mapbox.maps.plugin.animation.MapAnimationOwnerRegistry +import com.mapbox.maps.plugin.gestures.OnMoveListener +import com.mapbox.maps.plugin.gestures.OnRotateListener +import com.mapbox.maps.plugin.gestures.OnScaleListener +import com.mapbox.maps.plugin.gestures.OnShoveListener +import com.rnmapbox.rnmbx.components.mapview.MapGestureType + +/** + * Detects camera changes and derives why the camera is moving (gesture, animation owner, or + * programmatic). Emits [onCameraChange] on every frame tick while the camera is in motion, + * with a [CameraChangeReason] derived from live animation/gesture state rather than stale flags, + * along with a [CameraChangePayload] snapshot of the camera. + */ +class MapCameraChangeDetector(private val mapboxMap: MapboxMap) { + var onMapCameraChange: ((cameraChanged: CameraChanged, derivedReason: CameraChangeReason) -> Unit)? = null + + private var isGestureActive: Boolean = false + private var activeAnimationOwner: String? = null + private var cameraChangedSubscription: Cancelable? = null + + private val derivedReason: CameraChangeReason + get() = when { + isGestureActive -> CameraChangeReason.USER_GESTURE +// activeAnimationOwner == MapAnimationOwnerRegistry.GESTURES -> CameraChangeReason.USER_GESTURE + activeAnimationOwner == MapAnimationOwnerRegistry.LOCATION -> CameraChangeReason.SDK_ANIMATION + activeAnimationOwner == MapAnimationOwnerRegistry.COMPASS -> CameraChangeReason.SDK_ANIMATION + activeAnimationOwner == MapAnimationOwnerRegistry.INTERNAL -> CameraChangeReason.SDK_ANIMATION + activeAnimationOwner != null -> CameraChangeReason.DEVELOPER_ANIMATION + else -> CameraChangeReason.NONE + } + + private val animationsLifecycleListener = object : CameraAnimationsLifecycleListener { + override fun onAnimatorStarting(type: CameraAnimatorType, animator: ValueAnimator, owner: String?) { + if (activeAnimationOwner == null) activeAnimationOwner = owner + } + + override fun onAnimatorEnding(type: CameraAnimatorType, animator: ValueAnimator, owner: String?) { + if (owner == activeAnimationOwner) activeAnimationOwner = null + } + + override fun onAnimatorCancelling(type: CameraAnimatorType, animator: ValueAnimator, owner: String?) { + if (owner == activeAnimationOwner) activeAnimationOwner = null + } + + override fun onAnimatorInterrupting( + type: CameraAnimatorType, + runningAnimator: ValueAnimator, + runningAnimatorOwner: String?, + newAnimator: ValueAnimator, + newAnimatorOwner: String? + ) { + activeAnimationOwner = newAnimatorOwner + } + } + + fun attach() { + cameraChangedSubscription = mapboxMap.subscribeCameraChanged { cameraChanged -> + onMapCameraChange?.invoke(cameraChanged, derivedReason) + } + + mapboxMap.cameraAnimationsPlugin { + addCameraAnimationsLifecycleListener(animationsLifecycleListener) + } + + mapboxMap.gesturesPlugin { + addOnMoveListener(object : OnMoveListener { + override fun onMoveBegin(detector: MoveGestureDetector) { handleGestureBegin(MapGestureType.Move) } + override fun onMove(detector: MoveGestureDetector): Boolean = false + override fun onMoveEnd(detector: MoveGestureDetector) { handleGestureEnd(MapGestureType.Move) } + }) + addOnScaleListener(object : OnScaleListener { + override fun onScaleBegin(detector: StandardScaleGestureDetector) { handleGestureBegin(MapGestureType.Scale) } + override fun onScale(detector: StandardScaleGestureDetector) {} + override fun onScaleEnd(detector: StandardScaleGestureDetector) { handleGestureEnd(MapGestureType.Scale) } + }) + addOnRotateListener(object : OnRotateListener { + override fun onRotateBegin(detector: RotateGestureDetector) { handleGestureBegin(MapGestureType.Rotate) } + override fun onRotate(detector: RotateGestureDetector) {} + override fun onRotateEnd(detector: RotateGestureDetector) { handleGestureEnd(MapGestureType.Rotate) } + }) + addOnShoveListener(object : OnShoveListener { + override fun onShoveBegin(detector: ShoveGestureDetector) { handleGestureBegin(MapGestureType.Shove) } + override fun onShove(detector: ShoveGestureDetector) { } + override fun onShoveEnd(detector: ShoveGestureDetector) { handleGestureEnd(MapGestureType.Shove) } + }) + } + } + + private fun handleGestureBegin(type: MapGestureType) { + isGestureActive = true + } + + private fun handleGestureEnd(type: MapGestureType) { + isGestureActive = false + } + + fun detach() { + cameraChangedSubscription?.cancel() + cameraChangedSubscription = null + mapboxMap.cameraAnimationsPlugin { + removeCameraAnimationsLifecycleListener(animationsLifecycleListener) + } + } +} \ No newline at end of file diff --git a/android/src/main/java/com/rnmapbox/rnmbx/events/MapCameraChangeEvent.kt b/android/src/main/java/com/rnmapbox/rnmbx/events/MapCameraChangeEvent.kt new file mode 100644 index 0000000000..5af8c48ff7 --- /dev/null +++ b/android/src/main/java/com/rnmapbox/rnmbx/events/MapCameraChangeEvent.kt @@ -0,0 +1,105 @@ +package com.rnmapbox.rnmbx.events + +import android.view.View +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableArray +import com.facebook.react.bridge.WritableMap +import com.mapbox.maps.CameraChanged +import com.mapbox.maps.CameraState +import com.mapbox.maps.MapboxMap +import com.mapbox.maps.toCameraOptions +import com.rnmapbox.rnmbx.components.mapview.helpers.CameraChangeReason + +/** + * Direct event for CameraGestureObserver -> onCameraChange + * JS registrationName: onCameraChange + * Native event name (key): onCameraChange + * + * Serializes the camera snapshot into the JS `OnCameraChangeEvent` shape: + * `{ properties: { center, bounds:{ne,sw}, zoom, heading, pitch }, gestures: { isGestureActive }, timestamp }`. + * + * [center], [ne] and [sw] are `[longitude, latitude]` pairs. [ne]/[sw] may be `null` if the + * bounds could not be computed for the current camera. + */ +class MapCameraChangeEvent( + view: View?, + private val center: List, + private val ne: List?, + private val sw: List?, + private val zoom: Double, + private val heading: Double, + private val pitch: Double, + private val isUserInteraction: Boolean, +) : AbstractEvent(view, "mapCameraChange") { + override val key: String + get() = "onMapCameraChange" + + override val payload: WritableMap + get() = Arguments.createMap().apply { + putMap("properties", Arguments.createMap().apply { + putArray("center", center.toWritableArray()) + putMap("bounds", Arguments.createMap().apply { + if (ne != null) putArray("ne", ne.toWritableArray()) else putNull("ne") + if (sw != null) putArray("sw", sw.toWritableArray()) else putNull("sw") + }) + putDouble("zoom", zoom) + putDouble("heading", heading) + putDouble("pitch", pitch) + }) + putBoolean("isUserInteraction", isUserInteraction) + putDouble("timestamp", timestamp.toDouble()) + } + + override fun toJSON(): WritableMap { + val map = Arguments.createMap() + map.merge(payload) + return map + } + + override fun canCoalesce(): Boolean { + // Coalesce rapid camera changes - only the latest snapshot is relevant + return true + } + + companion object { + fun make( + view: View, + center: List, + ne: List?, + sw: List?, + zoom: Double, + heading: Double, + pitch: Double, + isUserInteraction: Boolean + ): MapCameraChangeEvent = MapCameraChangeEvent( + view, center, ne, sw, zoom, heading, pitch, isUserInteraction + ) + + fun make( + view: View, + mapboxMap: MapboxMap, + cameraChanged: CameraChanged, + reason: CameraChangeReason + ): MapCameraChangeEvent { + val cameraState: CameraState = cameraChanged.cameraState + val center = cameraState.center + val bounds = runCatching { + mapboxMap.coordinateBoundsForCamera(cameraState.toCameraOptions()) + }.getOrNull() + + return make( + view, + center = listOf(center.longitude(), center.latitude()), + ne = bounds?.northeast?.let { listOf(it.longitude(), it.latitude()) }, + sw = bounds?.southwest?.let { listOf(it.longitude(), it.latitude()) }, + zoom = cameraState.zoom, + heading = cameraState.bearing, + pitch = cameraState.pitch, + isUserInteraction = reason == CameraChangeReason.USER_GESTURE + ) + } + + private fun List.toWritableArray(): WritableArray = + Arguments.createArray().also { array -> forEach { array.pushDouble(it) } } + } +} diff --git a/android/src/main/java/com/rnmapbox/rnmbx/events/RNMBXCameraGestureObserver.kt b/android/src/main/java/com/rnmapbox/rnmbx/events/RNMBXCameraGestureObserver.kt index 1e6462064e..758c239e93 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/events/RNMBXCameraGestureObserver.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/events/RNMBXCameraGestureObserver.kt @@ -6,6 +6,7 @@ import com.rnmapbox.rnmbx.components.AbstractMapFeature import com.rnmapbox.rnmbx.components.RemovalReason import com.rnmapbox.rnmbx.components.mapview.MapGestureType import com.rnmapbox.rnmbx.components.mapview.RNMBXMapView +import com.rnmapbox.rnmbx.components.mapview.helpers.MapCameraChangeDetector import com.rnmapbox.rnmbx.components.mapview.helpers.MapSteadyDetector class RNMBXCameraGestureObserver( @@ -14,17 +15,19 @@ class RNMBXCameraGestureObserver( ) : AbstractMapFeature(mContext) { var hasOnMapSteady: Boolean = false + var hasOnMapCameraChange: Boolean = false var quietPeriodMs: Double? = null var maxIntervalMs: Double? = null override var requiresStyleLoad: Boolean = false - private var detector: MapSteadyDetector? = null + private var mapSteadyDetector: MapSteadyDetector? = null + private var mapCameraChangeDetector: MapCameraChangeDetector? = null private fun debugLog(message: String) { Log.d( LOG_TAG, - "$message; activeAnimations=${detector?.activeAnimations} isGestureActive=${detector?.isGestureActive} lastTransitionEnd=${detector?.lastTransitionEndedAtMs ?: -1}" + "$message; activeAnimations=${mapSteadyDetector?.activeAnimations} isGestureActive=${mapSteadyDetector?.isGestureActive} lastTransitionEnd=${mapSteadyDetector?.lastTransitionEndedAtMs ?: -1}" ) } @@ -40,46 +43,64 @@ class RNMBXCameraGestureObserver( override fun addToMap(mapView: RNMBXMapView) { super.addToMap(mapView) - if (!hasOnMapSteady) return - - mapView.getMapAsync { mapboxMap -> - val det = MapSteadyDetector( - mapboxMap = mapboxMap, - quietPeriodMs = quietPeriodMs ?: 200.0, - maxIntervalMs = maxIntervalMs, - ) - det.onSteady = { idleDurationMs, lastGestureType -> - debugLog("EMIT steady idleDurationMs=$idleDurationMs lastGestureType=$lastGestureType") - mManager.handleEvent( - MapSteadyEvent.make( - this, - "steady", - idleDurationMs, - normalizeGestureType(lastGestureType) - ) + if (hasOnMapSteady) { + mapView.getMapAsync { mapboxMap -> + val det = MapSteadyDetector( + mapboxMap = mapboxMap, + quietPeriodMs = quietPeriodMs ?: 200.0, + maxIntervalMs = maxIntervalMs, ) - } - det.onTimeout = { lastGestureType -> - debugLog("EMIT timeout lastGestureType=$lastGestureType") - mManager.handleEvent( - MapSteadyEvent.make( - this, - "timeout", - null, - normalizeGestureType(lastGestureType) + det.onSteady = { idleDurationMs, lastGestureType -> + debugLog("EMIT steady idleDurationMs=$idleDurationMs lastGestureType=$lastGestureType") + mManager.handleEvent( + MapSteadyEvent.make( + this, + "steady", + idleDurationMs, + normalizeGestureType(lastGestureType) + ) + ) + } + det.onTimeout = { lastGestureType -> + debugLog("EMIT timeout lastGestureType=$lastGestureType") + mManager.handleEvent( + MapSteadyEvent.make( + this, + "timeout", + null, + normalizeGestureType(lastGestureType) + ) ) + } + det.attach() + mapSteadyDetector = det + debugLog("addToMap and subscribed to gestures") + } + } + + if (hasOnMapCameraChange) { + mapView.getMapAsync { mapboxMap -> + val det = MapCameraChangeDetector( + mapboxMap = mapboxMap ) + det.onMapCameraChange = { cameraChanged, derivedReason -> + mManager.handleEvent( + MapCameraChangeEvent.make(this, mapboxMap, cameraChanged, derivedReason) + ) + } + det.attach() + mapCameraChangeDetector = det + debugLog("addToMap and subscribed to gestures") } - det.attach() - detector = det - debugLog("addToMap and subscribed to gestures") } } override fun removeFromMap(mapView: RNMBXMapView, reason: RemovalReason): Boolean { debugLog("removeFromMap and unsubscribed from gestures") - detector?.detach() - detector = null + mapSteadyDetector?.detach() + mapSteadyDetector = null + mapCameraChangeDetector?.detach() + mapCameraChangeDetector = null return super.removeFromMap(mapView, reason) } diff --git a/android/src/main/java/com/rnmapbox/rnmbx/events/RNMBXCameraGestureObserverManager.kt b/android/src/main/java/com/rnmapbox/rnmbx/events/RNMBXCameraGestureObserverManager.kt index 7708ced26f..c707f90c08 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/events/RNMBXCameraGestureObserverManager.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/events/RNMBXCameraGestureObserverManager.kt @@ -61,11 +61,25 @@ class RNMBXCameraGestureObserverManager(private val mContext: ReactApplicationCo } } + @ReactProp(name = "hasOnMapCameraChange") + override fun setHasOnMapCameraChange(view: RNMBXCameraGestureObserver?, value: Dynamic?) { + if (value?.getType()?.name == "Boolean") { + view?.hasOnMapCameraChange = value.asBoolean() + } else { + if (value == null) { + Logger.e(REACT_CLASS, "Expected Boolean value for hasOnMapCameraChange") + } else { + Logger.e(REACT_CLASS, "Expected Boolean value for hasOnMapCameraChange, got ${value.getType().name}") + } + } + } + override fun getDelegate(): ViewManagerDelegate = delegate // Map the native event name to the JS registration name for direct events override fun customEvents(): Map = mapOf( - "onMapSteady" to "onMapSteady" + "onMapSteady" to "onMapSteady", + "onMapCameraChange" to "onMapCameraChange", ) companion object { diff --git a/src/components/CameraGestureObserver.tsx b/src/components/CameraGestureObserver.tsx index 46db426ea4..bd256ed16f 100644 --- a/src/components/CameraGestureObserver.tsx +++ b/src/components/CameraGestureObserver.tsx @@ -2,6 +2,7 @@ import { memo } from 'react'; import type { ViewProps } from 'react-native'; import RNMBXCameraGestureObserverNativeComponent, { type OnMapSteadyEvent, + type OnMapCameraChangeEvent, } from '../specs/RNMBXCameraGestureObserverNativeComponent'; type Props = ViewProps & { @@ -20,6 +21,11 @@ type Props = ViewProps & { * Callback when the map reaches a steady state (no active gestures or animations). */ onMapSteady?: (event: { nativeEvent: OnMapSteadyEvent }) => void; + + /** + * Callback when the camera changes (due to gestures or animations). + */ + onMapCameraChange?: (event: { nativeEvent: OnMapCameraChangeEvent }) => void; }; /** @@ -32,6 +38,7 @@ export default memo((props: Props) => { ); }); diff --git a/src/specs/RNMBXCameraGestureObserverNativeComponent.ts b/src/specs/RNMBXCameraGestureObserverNativeComponent.ts index 10fef01b20..0fabecb813 100644 --- a/src/specs/RNMBXCameraGestureObserverNativeComponent.ts +++ b/src/specs/RNMBXCameraGestureObserverNativeComponent.ts @@ -15,12 +15,34 @@ export type OnMapSteadyEvent = { timestamp: Double; }; +export type OnMapCameraChangeEvent = { + properties: { + // [longitude, latitude] + center: Double[]; + bounds: { + // northeast corner [longitude, latitude] + ne: Double[]; + // southwest corner [longitude, latitude] + sw: Double[]; + }; + zoom: Double; + heading: Double; + pitch: Double; + }; + // true if the camera change was initiated by user interaction (e.g., gesture) + isUserInteraction: boolean; + // Unix epoch in milliseconds + timestamp?: Double; +}; + export interface NativeProps extends ViewProps { quietPeriodMs?: UnsafeMixed; maxIntervalMs?: UnsafeMixed; hasOnMapSteady: UnsafeMixed; onMapSteady?: DirectEventHandler; + hasOnMapCameraChange: UnsafeMixed; + onMapCameraChange?: DirectEventHandler; } export default codegenNativeComponent( From 9699111f92c8474f07666da98e5cda1701193ff7 Mon Sep 17 00:00:00 2001 From: Thomas Han Date: Fri, 31 Jul 2026 13:48:57 +1200 Subject: [PATCH 7/7] - regenerate docs for onMapCameraChange - add onMapCameraChange example under CameraGestureObserver --- .../helpers/MapCameraChangeDetector.kt | 2 +- docs/CameraGestureObserver.md | 10 ++ docs/docs.json | 10 ++ docs/examples.json | 3 +- .../examples/Map/CameraGestureObserver.tsx | 106 +++++++++++++++--- src/Mapbox.native.ts | 1 + 6 files changed, 112 insertions(+), 20 deletions(-) diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/MapCameraChangeDetector.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/MapCameraChangeDetector.kt index 6053e8ccee..bbb4b3447b 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/MapCameraChangeDetector.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/MapCameraChangeDetector.kt @@ -33,7 +33,7 @@ class MapCameraChangeDetector(private val mapboxMap: MapboxMap) { private val derivedReason: CameraChangeReason get() = when { isGestureActive -> CameraChangeReason.USER_GESTURE -// activeAnimationOwner == MapAnimationOwnerRegistry.GESTURES -> CameraChangeReason.USER_GESTURE + activeAnimationOwner == MapAnimationOwnerRegistry.GESTURES -> CameraChangeReason.USER_GESTURE activeAnimationOwner == MapAnimationOwnerRegistry.LOCATION -> CameraChangeReason.SDK_ANIMATION activeAnimationOwner == MapAnimationOwnerRegistry.COMPASS -> CameraChangeReason.SDK_ANIMATION activeAnimationOwner == MapAnimationOwnerRegistry.INTERNAL -> CameraChangeReason.SDK_ANIMATION diff --git a/docs/CameraGestureObserver.md b/docs/CameraGestureObserver.md index 0051b64216..2ce2923b84 100644 --- a/docs/CameraGestureObserver.md +++ b/docs/CameraGestureObserver.md @@ -44,6 +44,16 @@ Callback when the map reaches a steady state (no active gestures or animations). [Camera Gesture Observer](../examples/Map/CameraGestureObserver) +### onMapCameraChange + +```tsx +func +``` +Callback when the camera changes (due to gestures or animations). +*signature:*`(event:{nativeEvent: OnMapCameraChangeEvent}) => void` + +[Camera Gesture Observer](../examples/Map/CameraGestureObserver) + diff --git a/docs/docs.json b/docs/docs.json index b9e079a84e..e63b148e3b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1054,6 +1054,16 @@ }, "default": "none", "description": "Callback when the map reaches a steady state (no active gestures or animations).\n*signature:*`(event:{nativeEvent: OnMapSteadyEvent}) => void`" + }, + { + "name": "onMapCameraChange", + "required": false, + "type": { + "name": "func", + "funcSignature": "(event:{nativeEvent: OnMapCameraChangeEvent}) => void" + }, + "default": "none", + "description": "Callback when the camera changes (due to gestures or animations).\n*signature:*`(event:{nativeEvent: OnMapCameraChangeEvent}) => void`" } ], "fileNameWithExt": "CameraGestureObserver.tsx", diff --git a/docs/examples.json b/docs/examples.json index 5ef861b0f3..e3cf98b393 100644 --- a/docs/examples.json +++ b/docs/examples.json @@ -267,10 +267,11 @@ "title": "Camera Gesture Observer", "tags": [ "CameraGestureObserver#onMapSteady", + "CameraGestureObserver#onMapCameraChange", "CameraGestureObserver#quietPeriodMs", "CameraGestureObserver#maxIntervalMs" ], - "docs": "\nDemonstrates how to detect when the map becomes steady after user gestures (pan, zoom, rotate). The CameraGestureObserver component fires the onMapSteady event after a configurable quiet period, providing information about the last gesture type and idle duration.\n" + "docs": "\nDemonstrates how to detect when the map becomes steady after user gestures (pan, zoom, rotate). The CameraGestureObserver component fires the onMapSteady event after a configurable quiet period, providing information about the last gesture type and idle duration. It also fires the onMapCameraChange event on every camera change, reporting the current center, zoom, heading, pitch, and whether the change came from user interaction.\n" }, "fullPath": "example/src/examples/Map/CameraGestureObserver.tsx", "relPath": "Map/CameraGestureObserver.tsx", diff --git a/example/src/examples/Map/CameraGestureObserver.tsx b/example/src/examples/Map/CameraGestureObserver.tsx index 6bdae85998..93a283d4b0 100644 --- a/example/src/examples/Map/CameraGestureObserver.tsx +++ b/example/src/examples/Map/CameraGestureObserver.tsx @@ -1,8 +1,15 @@ -import { useCallback, useState } from 'react'; -import { View, StyleSheet, Text } from 'react-native'; -import { MapView, Camera, CameraGestureObserver, type OnMapSteadyEvent } from '@rnmapbox/maps'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { View, StyleSheet, Text, Button } from 'react-native'; +import { + MapView, + Camera, + CameraGestureObserver, + type OnMapSteadyEvent, + type OnMapCameraChangeEvent, +} from '@rnmapbox/maps'; import { type ExampleWithMetadata } from '../common/ExampleMetadata'; // exclude-from-doc +import type { CameraRef } from '../../../../src/components/Camera'; const styles = StyleSheet.create({ container: { @@ -34,6 +41,9 @@ const styles = StyleSheet.create({ fontSize: 14, lineHeight: 20, }, + sectionSpacing: { + marginTop: 12, + }, hint: { fontSize: 12, color: '#666', @@ -42,34 +52,86 @@ const styles = StyleSheet.create({ }, }); +const defaultCameraCoordinate = [-74.006, 40.7128]; // New York City + const CameraGestureObserverExample = () => { + const mapCameraRef = useRef(null); const [status, setStatus] = useState('Waiting for interaction...'); + const [cameraStatus, setCameraStatus] = useState('No camera changes yet'); + const [autoRecenter, setAutoRecenter] = useState(true); + + const onMapCameraChange = useCallback( + ({ nativeEvent }: { nativeEvent: OnMapCameraChangeEvent }) => { + const { properties, isUserInteraction, timestamp } = nativeEvent; + const { center, zoom, heading, pitch } = properties; + + const [longitude = 0, latitude = 0] = center; + let message = `Center: ${latitude.toFixed(4)}, ${longitude.toFixed(4)}`; + message += `\nZoom: ${zoom.toFixed(2)}`; + message += `\nHeading: ${heading.toFixed(1)}° Pitch: ${pitch.toFixed( + 1, + )}°`; + message += `\nUser interaction: ${isUserInteraction ? 'yes' : 'no'}`; + + if (timestamp !== undefined) { + message += `\nTime: ${new Date(timestamp).toLocaleTimeString()}`; + } + + console.log('[CameraGestureObserver] cameraChange', nativeEvent); + setCameraStatus(message); + }, + [], + ); + + const onMapSteady = useCallback( + ({ nativeEvent }: { nativeEvent: OnMapSteadyEvent }) => { + const { reason, idleDurationMs, lastGestureType, timestamp } = + nativeEvent; + + let message = `✓ Map is steady!\n\nReason: ${reason}`; - const onMapSteady = useCallback(({ nativeEvent } : { nativeEvent: OnMapSteadyEvent }) => { - const { reason, idleDurationMs, lastGestureType, timestamp } = nativeEvent; + if (reason === 'steady' && idleDurationMs !== undefined) { + message += `\nIdle duration: ${Math.round(idleDurationMs)}ms`; + } - let message = `✓ Map is steady!\n\nReason: ${reason}`; + if (lastGestureType) { + message += `\nLast gesture: ${lastGestureType}`; + } - if (reason === 'steady' && idleDurationMs !== undefined) { - message += `\nIdle duration: ${Math.round(idleDurationMs)}ms`; - } + message += `\nTime: ${new Date(timestamp).toLocaleTimeString()}`; - if (lastGestureType) { - message += `\nLast gesture: ${lastGestureType}`; - } + console.log('[CameraGestureObserver]', nativeEvent); + setStatus(message); + }, + [], + ); + + useEffect(() => { + // Re-center the map every 5 seconds. When this is done, isUserInteraction should log as false (but it doesn't when locationPuck is true) + const interval = setInterval(() => { + if (!mapCameraRef.current || !autoRecenter) { + return; + } - message += `\nTime: ${new Date(timestamp).toLocaleTimeString()}`; + mapCameraRef.current?.setCamera({ + centerCoordinate: defaultCameraCoordinate, + animationDuration: 1000, + animationMode: 'linearTo', + }); + }, 5_000); - console.log('[CameraGestureObserver]', nativeEvent); - setStatus(message); - }, []); + return () => { + clearInterval(interval); + }; + }, [autoRecenter]); return ( @@ -77,14 +139,21 @@ const CameraGestureObserverExample = () => { quietPeriodMs={200} maxIntervalMs={5000} onMapSteady={onMapSteady} + onMapCameraChange={onMapCameraChange} /> Map Steady State {status} + Camera + {cameraStatus} Pan, zoom, or rotate the map to see the steady state detection +