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 9043e02562..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 @@ -92,10 +92,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/MapCameraChangeDetector.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/MapCameraChangeDetector.kt new file mode 100644 index 0000000000..bbb4b3447b --- /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/components/mapview/helpers/MapSteadyDetector.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/MapSteadyDetector.kt new file mode 100644 index 0000000000..fc9744b7b3 --- /dev/null +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/helpers/MapSteadyDetector.kt @@ -0,0 +1,178 @@ +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 +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: 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: MapGestureType? = 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(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) } + }) + } + } + + 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: MapGestureType? = 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: MapGestureType) { + isGestureActive = true + lastGestureType = type + lastTransitionEndedAtMs = null + markActivity(lastGestureType) + } + + private fun handleGestureEnd(type: MapGestureType) { + 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/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 dbeced9976..758c239e93 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,13 @@ 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.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( private val mContext: Context, @@ -25,269 +15,95 @@ 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 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 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 normalizeGestureType(type: String): String { - return when (type) { - "move" -> "pan" - "scale" -> "pinch" - "rotate" -> "rotate" - "shove" -> "pitch" - else -> type - } - } + private var mapSteadyDetector: MapSteadyDetector? = null + private var mapCameraChangeDetector: MapCameraChangeDetector? = null 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) + "$message; activeAnimations=${mapSteadyDetector?.activeAnimations} isGestureActive=${mapSteadyDetector?.isGestureActive} lastTransitionEnd=${mapSteadyDetector?.lastTransitionEndedAtMs ?: -1}" ) - lastGestureType = null } - private fun emitTimeout() { - cancelQuietTimer() - debugLog("EMIT timeout lastGestureType=$lastGestureType") - mManager.handleEvent( - MapSteadyEvent.make(this, "timeout", null, lastGestureType) - ) - scheduleTimeout() + 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) { 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 - } - }) + if (hasOnMapSteady) { + 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) + ) + ) + } + 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") } + } - // 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") - } - }) + 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") } - - debugLog("addToMap and subscribed to gestures") } } override fun removeFromMap(mapView: RNMBXMapView, reason: RemovalReason): Boolean { debugLog("removeFromMap and unsubscribed from gestures") - cancelQuietTimer() - cancelTimeoutTimer() + mapSteadyDetector?.detach() + mapSteadyDetector = null + mapCameraChangeDetector?.detach() + mapCameraChangeDetector = 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" } 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/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 +