diff --git a/__tests__/components/Viewport.test.tsx b/__tests__/components/Viewport.test.tsx
new file mode 100644
index 000000000..4ef350ea6
--- /dev/null
+++ b/__tests__/components/Viewport.test.tsx
@@ -0,0 +1,74 @@
+import { render } from '@testing-library/react-native';
+
+import {
+ Viewport,
+ normalizeTransitionsToIdleUponUserInteraction,
+} from '../../src/components/Viewport';
+import RNMBXViewport from '../../src/specs/RNMBXViewportNativeComponent';
+
+function nativeTransitionsToIdle(element: React.ReactElement) {
+ const { UNSAFE_getByType } = render(element);
+ return UNSAFE_getByType(RNMBXViewport).props
+ .transitionsToIdleUponUserInteraction;
+}
+
+describe('Viewport transitionsToIdleUponUserInteraction', () => {
+ it('passes booleans through unchanged', () => {
+ expect(
+ nativeTransitionsToIdle(
+ ,
+ ),
+ ).toBe(true);
+ expect(
+ nativeTransitionsToIdle(
+ ,
+ ),
+ ).toBe(false);
+ });
+
+ it('passes nothing when unset', () => {
+ expect(nativeTransitionsToIdle()).toBeUndefined();
+ });
+
+ it('defaults omitted gestures to true', () => {
+ expect(
+ nativeTransitionsToIdle(
+ ,
+ ),
+ ).toEqual({
+ pan: true,
+ pinch: false,
+ doubleTap: true,
+ rotate: false,
+ pitch: true,
+ });
+ });
+
+ it('treats an empty object like true for every gesture', () => {
+ expect(normalizeTransitionsToIdleUponUserInteraction({})).toEqual({
+ pan: true,
+ pinch: true,
+ doubleTap: true,
+ rotate: true,
+ pitch: true,
+ });
+ });
+
+ it('drops unknown keys', () => {
+ expect(
+ normalizeTransitionsToIdleUponUserInteraction({
+ pan: false,
+ // @ts-expect-error unknown gesture
+ swipe: false,
+ }),
+ ).toEqual({
+ pan: false,
+ pinch: true,
+ doubleTap: true,
+ rotate: true,
+ pitch: true,
+ });
+ });
+});
diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXVIewportManager.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXVIewportManager.kt
index 96ac60e94..b48fd8c23 100644
--- a/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXVIewportManager.kt
+++ b/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXVIewportManager.kt
@@ -3,6 +3,7 @@ package com.rnmapbox.rnmbx.components.camera
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Dynamic
import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.bridge.ReadableType
import com.facebook.react.bridge.WritableArray
import com.facebook.react.bridge.WritableMap
import com.facebook.react.common.MapBuilder
@@ -34,7 +35,11 @@ class RNMBXViewportManager(private val mContext: ReactApplicationContext, val vi
@ReactProp(name = "transitionsToIdleUponUserInteraction")
override fun setTransitionsToIdleUponUserInteraction(view: RNMBXViewport?, value: Dynamic?) {
- view?.transitionsToIdleUponUserInteraction = value?.asBoolean()
+ when (value?.type) {
+ ReadableType.Boolean -> view?.setTransitionsToIdleUponUserInteraction(value.asBoolean(), null)
+ ReadableType.Map -> view?.setTransitionsToIdleUponUserInteraction(null, RNMBXViewportGestureHandler.IdleGestures.from(value.asMap()))
+ else -> view?.setTransitionsToIdleUponUserInteraction(null, null)
+ }
}
@ReactProp(name = "hasStatusChanged")
diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXViewport.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXViewport.kt
index 5ed4d3852..14ab6d431 100644
--- a/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXViewport.kt
+++ b/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXViewport.kt
@@ -65,25 +65,53 @@ AbstractMapFeature(
mContext
) {
// region properties
- var transitionsToIdleUponUserInteraction: Boolean? = null
- set(value: Boolean?) {
- field = value
- if (value != null) {
- mMapView?.let { applyTransitionsToIdleUponUserIntraction(it.mapView) }
+ private var transitionsToIdleUponUserInteraction: Boolean? = null
+ private var idleGestures: RNMBXViewportGestureHandler.IdleGestures? = null
+
+ /** Sets the boolean form, or the per-gesture form when `idleGestures` is non-null. */
+ fun setTransitionsToIdleUponUserInteraction(value: Boolean?, idleGestures: RNMBXViewportGestureHandler.IdleGestures?) {
+ this.transitionsToIdleUponUserInteraction = if (idleGestures != null) null else value
+ this.idleGestures = idleGestures
+ mMapView?.let { applyTransitionsToIdleUponUserIntraction(it) }
+ }
+
+ private var gestureHandler: RNMBXViewportGestureHandler? = null
+ private var gestureIdlePending = false
+
+ fun applyTransitionsToIdleUponUserIntraction(map: RNMBXMapView) {
+ val mapView = map.mapView
+ val idleGestures = this.idleGestures
+ val transitionsToIdle = this.transitionsToIdleUponUserInteraction
+ if (idleGestures != null) {
+ val gestureHandler = this.gestureHandler
+ if (gestureHandler != null) {
+ gestureHandler.idleGestures = idleGestures
+ } else {
+ this.gestureHandler = RNMBXViewportGestureHandler(map, idleGestures) {
+ gestureIdlePending = hasStatusChanged
+ }
+ }
+ setSdkTransitionsToIdle(mapView, false)
+ } else {
+ val hadGestureHandler = gestureHandler != null
+ gestureHandler?.detach()
+ gestureHandler = null
+ when {
+ transitionsToIdle != null -> setSdkTransitionsToIdle(mapView, transitionsToIdle)
+ hadGestureHandler -> restoreTransitionsToIdleUponUserInteraction(mapView)
}
}
+ }
// The map's value before this Viewport changed it, restored on removal so the option doesn't outlive the component.
private var savedTransitionsToIdleUponUserInteraction: Boolean? = null
- fun applyTransitionsToIdleUponUserIntraction(mapView: MapView) {
- this.transitionsToIdleUponUserInteraction?.let {
- val options = mapView.viewport.options
- if (savedTransitionsToIdleUponUserInteraction == null) {
- savedTransitionsToIdleUponUserInteraction = options.transitionsToIdleUponUserInteraction
- }
- mapView.viewport.options = options.toBuilder().transitionsToIdleUponUserInteraction(it).build()
+ private fun setSdkTransitionsToIdle(mapView: MapView, value: Boolean) {
+ val options = mapView.viewport.options
+ if (savedTransitionsToIdleUponUserInteraction == null) {
+ savedTransitionsToIdleUponUserInteraction = options.transitionsToIdleUponUserInteraction
}
+ mapView.viewport.options = options.toBuilder().transitionsToIdleUponUserInteraction(value).build()
}
private fun restoreTransitionsToIdleUponUserInteraction(mapView: MapView) {
@@ -105,7 +133,12 @@ mContext
if (hasStatusChanged) {
if (statusObserver == null) {
val statusObserver = ViewportStatusObserver { from, to, reason ->
- val payload = writableMapOf("from" to statusToMap(from), "to" to statusToMap(to), "reason" to reasonToSrting(reason))
+ val userInteraction = gestureIdlePending && to is ViewportStatus.Idle && reason == ViewportStatusChangeReason.IDLE_REQUESTED
+ if (userInteraction) {
+ gestureIdlePending = false
+ }
+ val reasonString = if (userInteraction) reasonToSrting(ViewportStatusChangeReason.USER_INTERACTION) else reasonToSrting(reason)
+ val payload = writableMapOf("from" to statusToMap(from), "to" to statusToMap(to), "reason" to reasonString)
mManager.dispatchEvent(
BaseEvent(
UIManagerHelper.getSurfaceId(mContext),
@@ -131,11 +164,13 @@ mContext
override fun addToMap(mapView: RNMBXMapView) {
super.addToMap(mapView)
- applyTransitionsToIdleUponUserIntraction(mapView.mapView)
+ applyTransitionsToIdleUponUserIntraction(mapView)
applyHasStatusChanged(mapView.mapView)
}
override fun removeFromMap(mapView: RNMBXMapView, reason: RemovalReason): Boolean {
+ gestureHandler?.detach()
+ gestureHandler = null
restoreTransitionsToIdleUponUserInteraction(mapView.mapView)
return super.removeFromMap(mapView, reason)
}
diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXViewportGestureHandler.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXViewportGestureHandler.kt
new file mode 100644
index 000000000..74a29173a
--- /dev/null
+++ b/android/src/main/java/com/rnmapbox/rnmbx/components/camera/RNMBXViewportGestureHandler.kt
@@ -0,0 +1,237 @@
+package com.rnmapbox.rnmbx.components.camera
+
+import android.os.Handler
+import android.os.Looper
+import com.facebook.react.bridge.ReadableMap
+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.ScreenCoordinate
+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.mapbox.maps.plugin.gestures.gestures
+import com.mapbox.maps.plugin.viewport.ViewportStatus
+import com.mapbox.maps.plugin.viewport.ViewportStatusObserver
+import com.mapbox.maps.plugin.viewport.data.ViewportStatusChangeReason
+import com.mapbox.maps.plugin.viewport.state.FollowPuckViewportState
+import com.mapbox.maps.plugin.viewport.viewport
+import com.rnmapbox.rnmbx.components.mapview.RNMBXMapView
+
+/**
+ * Implements the object form of `transitionsToIdleUponUserInteraction`: the SDK's own option is off,
+ * and this idles the viewport only when one of the selected gestures begins.
+ *
+ * The SDK has no double-tap listener, but its double-tap and two-finger-tap zoom animations notify
+ * `OnScaleListener.onScaleBegin` without starting the scale detector. A scale begin is therefore
+ * classified on the next main loop iteration: a pinch or quick zoom if the scale detector is in
+ * progress by then, a double tap otherwise.
+ */
+class RNMBXViewportGestureHandler(
+ private val map: RNMBXMapView,
+ initialIdleGestures: IdleGestures,
+ private val willIdle: () -> Unit
+) : ViewportStatusObserver {
+ data class IdleGestures(
+ val pan: Boolean = true,
+ val pinch: Boolean = true,
+ val doubleTap: Boolean = true,
+ val rotate: Boolean = true,
+ val pitch: Boolean = true
+ ) {
+ companion object {
+ fun from(value: ReadableMap?): IdleGestures {
+ fun get(key: String) = if (value?.hasKey(key) == true && !value.isNull(key)) value.getBoolean(key) else true
+ return IdleGestures(get("pan"), get("pinch"), get("doubleTap"), get("rotate"), get("pitch"))
+ }
+ }
+ }
+
+ private enum class Gesture { PAN, PINCH, DOUBLE_TAP, ROTATE, PITCH }
+
+ var idleGestures: IdleGestures = initialIdleGestures
+ set(value) {
+ field = value
+ updateZoomOverrides()
+ }
+
+ private val mapView = map.mapView
+ private val handler = Handler(Looper.getMainLooper())
+
+ private var cameraChangedCancelable: Cancelable? = null
+ /** The focal point this handler set, or null if it isn't managing the focal point. */
+ private var managedFocalPoint: ScreenCoordinate? = null
+ private var managesPinchScroll = false
+
+ private val moveListener = object : OnMoveListener {
+ override fun onMoveBegin(detector: MoveGestureDetector) = gestureBegan(Gesture.PAN)
+ override fun onMove(detector: MoveGestureDetector) = false
+ override fun onMoveEnd(detector: MoveGestureDetector) {}
+ }
+
+ private val scaleListener = object : OnScaleListener {
+ override fun onScaleBegin(detector: StandardScaleGestureDetector) {
+ if (idleGestures.pinch == idleGestures.doubleTap) {
+ gestureBegan(Gesture.PINCH)
+ return
+ }
+ // the follow state would override the zoom before the classification
+ clearFollowPuckOption(Gesture.PINCH)
+ handler.post {
+ gestureBegan(if (detector.isInProgress) Gesture.PINCH else Gesture.DOUBLE_TAP)
+ }
+ }
+ override fun onScale(detector: StandardScaleGestureDetector) {}
+ override fun onScaleEnd(detector: StandardScaleGestureDetector) {}
+ }
+
+ private val rotateListener = object : OnRotateListener {
+ override fun onRotateBegin(detector: RotateGestureDetector) = gestureBegan(Gesture.ROTATE)
+ override fun onRotate(detector: RotateGestureDetector) {}
+ override fun onRotateEnd(detector: RotateGestureDetector) {}
+ }
+
+ private val shoveListener = object : OnShoveListener {
+ override fun onShoveBegin(detector: ShoveGestureDetector) = gestureBegan(Gesture.PITCH)
+ override fun onShove(detector: ShoveGestureDetector) {}
+ override fun onShoveEnd(detector: ShoveGestureDetector) {}
+ }
+
+ init {
+ mapView.gestures.addOnMoveListener(moveListener)
+ mapView.gestures.addOnScaleListener(scaleListener)
+ mapView.gestures.addOnRotateListener(rotateListener)
+ mapView.gestures.addOnShoveListener(shoveListener)
+ mapView.viewport.addStatusObserver(this)
+ updateZoomOverrides()
+ }
+
+ fun detach() {
+ handler.removeCallbacksAndMessages(null)
+ stopZoomOverrides()
+ mapView.gestures.removeOnMoveListener(moveListener)
+ mapView.gestures.removeOnScaleListener(scaleListener)
+ mapView.gestures.removeOnRotateListener(rotateListener)
+ mapView.gestures.removeOnShoveListener(shoveListener)
+ mapView.viewport.removeStatusObserver(this)
+ }
+
+ private fun gestureBegan(gesture: Gesture) {
+ val idles = when (gesture) {
+ Gesture.PAN -> idleGestures.pan
+ Gesture.PINCH -> idleGestures.pinch
+ Gesture.DOUBLE_TAP -> idleGestures.doubleTap
+ Gesture.ROTATE -> idleGestures.rotate
+ Gesture.PITCH -> idleGestures.pitch
+ }
+ if (idles) {
+ if (mapView.viewport.status == ViewportStatus.Idle) return
+ willIdle()
+ mapView.viewport.idle()
+ } else {
+ clearFollowPuckOption(gesture)
+ }
+ }
+
+ private fun clearFollowPuckOption(gesture: Gesture) {
+ val followPuck = followPuckState(mapView.viewport.status) ?: return
+ val options = followPuck.options.toBuilder()
+ when (gesture) {
+ Gesture.PINCH, Gesture.DOUBLE_TAP -> options.zoom(null)
+ Gesture.ROTATE -> options.bearing(null)
+ Gesture.PITCH -> options.pitch(null)
+ Gesture.PAN -> return
+ }
+ followPuck.options = options.build()
+ }
+
+ private fun followPuckState(status: ViewportStatus): FollowPuckViewportState? =
+ when (status) {
+ is ViewportStatus.State -> status.state as? FollowPuckViewportState
+ is ViewportStatus.Transition -> status.toState as? FollowPuckViewportState
+ else -> null
+ }
+
+ // region ViewportStatusObserver
+
+ override fun onViewportStatusChanged(
+ from: ViewportStatus,
+ to: ViewportStatus,
+ reason: ViewportStatusChangeReason
+ ) {
+ updateZoomOverrides()
+ }
+
+ // endregion
+
+ // region Zooming around the puck
+
+ private fun updateZoomOverrides() {
+ val status = mapView.viewport.status
+ val following = status is ViewportStatus.State && status.state is FollowPuckViewportState
+ if (following && (!idleGestures.pinch || !idleGestures.doubleTap)) {
+ startZoomOverrides()
+ } else {
+ stopZoomOverrides()
+ }
+ }
+
+ private fun startZoomOverrides() {
+ if (cameraChangedCancelable != null) return
+
+ if (mapView.gestures.focalPoint == null) {
+ val focalPoint = puckScreenPoint()
+ mapView.gestures.focalPoint = focalPoint
+ managedFocalPoint = focalPoint
+ }
+
+ // a two-finger pan during a pinch would begin a move, which idles if `pan` is set
+ if (!map.pinchPanEnabledSetByApp && mapView.gestures.pinchScrollEnabled) {
+ mapView.gestures.pinchScrollEnabled = false
+ managesPinchScroll = true
+ }
+
+ cameraChangedCancelable = mapView.mapboxMap.subscribeCameraChanged {
+ val managed = managedFocalPoint ?: return@subscribeCameraChanged
+ if (mapView.gestures.focalPoint != managed) {
+ // someone else set a focal point, theirs wins
+ managedFocalPoint = null
+ return@subscribeCameraChanged
+ }
+ val focalPoint = puckScreenPoint()
+ if (focalPoint != managed) {
+ mapView.gestures.focalPoint = focalPoint
+ managedFocalPoint = focalPoint
+ }
+ }
+ }
+
+ private fun stopZoomOverrides() {
+ cameraChangedCancelable?.cancel()
+ cameraChangedCancelable = null
+ managedFocalPoint?.let {
+ if (mapView.gestures.focalPoint == it) {
+ mapView.gestures.focalPoint = null
+ }
+ }
+ managedFocalPoint = null
+ if (managesPinchScroll && !map.pinchPanEnabledSetByApp && !mapView.gestures.pinchScrollEnabled) {
+ mapView.gestures.pinchScrollEnabled = true
+ }
+ managesPinchScroll = false
+ }
+
+ /** While following, the puck is at the centre of the padded viewport. Both are in pixels. */
+ private fun puckScreenPoint(): ScreenCoordinate {
+ val padding = mapView.mapboxMap.cameraState.padding
+ return ScreenCoordinate(
+ padding.left + (mapView.width - padding.left - padding.right) / 2.0,
+ padding.top + (mapView.height - padding.top - padding.bottom) / 2.0
+ )
+ }
+
+ // endregion
+}
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 8f0936626..99165a798 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
@@ -221,6 +221,9 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie
var savedStyle: Style? = null
private set
+ /** Whether `gestureSettings.pinchPanEnabled` is set, so it must not be overridden temporarily. */
+ var pinchPanEnabledSetByApp = false
+
private var styleLoaded = false
private var mHandledMapChangedEvents: HashSet? = null
diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapViewManager.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapViewManager.kt
index 1401a5649..51686165b 100644
--- a/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapViewManager.kt
+++ b/android/src/main/java/com/rnmapbox/rnmbx/components/mapview/RNMBXMapViewManager.kt
@@ -173,6 +173,7 @@ open class RNMBXMapViewManager(context: ReactApplicationContext, val viewTagReso
Logger.e(LOG_TAG, "gestureSettings map is null")
return@gesturesPlugin Unit
}
+ mapView.pinchPanEnabledSetByApp = map.hasKey("pinchPanEnabled") && !map.isNull("pinchPanEnabled")
this.updateSettings {
map.getAndLogIfNotBoolean("doubleTapToZoomInEnabled", LOG_TAG)?.let {
this.doubleTapToZoomInEnabled = it
diff --git a/docs/Viewport.md b/docs/Viewport.md
index 6a642cf0e..6f374fbff 100644
--- a/docs/Viewport.md
+++ b/docs/Viewport.md
@@ -24,15 +24,37 @@ See [android](https://docs.mapbox.com/android/maps/api/${ANDROID_SDK_VERSION}/ma
### transitionsToIdleUponUserInteraction
```tsx
-boolean
+| boolean
+| {
+ pan?: boolean;
+ pinch?: boolean;
+ doubleTap?: boolean;
+ rotate?: boolean;
+ pitch?: boolean;
+ }
```
Indicates whether the Viewport should idle when the MapView receives touch input.
-Set this property to false to enable building custom ViewportStates that can work simultaneously with certain types of gestures.
+- `true`: any gesture idles the viewport.
+- `false`: no gesture idles it. Set this to build custom ViewportStates that can work simultaneously with certain types of gestures.
+- an object, e.g. `{ pan: true, pinch: false, doubleTap: false }`: only the gestures set to `true` idle the viewport,
+ the others leave the current state running. Omitted keys default to `true`.
+ - `pan`: one-finger pan
+ - `pinch`: pinch, and quick zoom (double-tap and drag)
+ - `doubleTap`: double-tap to zoom in, two-finger tap to zoom out
+ - `rotate`: two-finger rotate
+ - `pitch`: two-finger vertical drag (shove on Android)
+
+With the object form, while in a followPuck state:
+- once the user zooms, rotates or pitches with a gesture that doesn't idle, the state stops applying its zoom,
+ bearing or pitch, so the user's value sticks. The next transition to a followPuck state applies them again.
+- if `pinch` or `doubleTap` doesn't idle, zoom gestures zoom around the puck, and a two-finger drag doesn't pan the map.
+ This temporarily sets the gesture focal point and turns off `gestureSettings.pinchPanEnabled`,
+ unless the app has set them.
Defaults to true.
-
+[Viewport Gestures](../examples/Camera/ViewportGestures)
### onStatusChanged
diff --git a/docs/docs.json b/docs/docs.json
index 512649fc1..e80549f65 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -10879,9 +10879,9 @@
{
"name": "transitionsToIdleUponUserInteraction",
"required": false,
- "type": "boolean",
+ "type": "\\| boolean\n\\| {\n pan?: boolean;\n pinch?: boolean;\n doubleTap?: boolean;\n rotate?: boolean;\n pitch?: boolean;\n }",
"default": "none",
- "description": "Indicates whether the Viewport should idle when the MapView receives touch input.\n\nSet this property to false to enable building custom ViewportStates that can work simultaneously with certain types of gestures.\n\nDefaults to true."
+ "description": "Indicates whether the Viewport should idle when the MapView receives touch input.\n\n- `true`: any gesture idles the viewport.\n- `false`: no gesture idles it. Set this to build custom ViewportStates that can work simultaneously with certain types of gestures.\n- an object, e.g. `{ pan: true, pinch: false, doubleTap: false }`: only the gestures set to `true` idle the viewport,\n the others leave the current state running. Omitted keys default to `true`.\n - `pan`: one-finger pan\n - `pinch`: pinch, and quick zoom (double-tap and drag)\n - `doubleTap`: double-tap to zoom in, two-finger tap to zoom out\n - `rotate`: two-finger rotate\n - `pitch`: two-finger vertical drag (shove on Android)\n\nWith the object form, while in a followPuck state:\n- once the user zooms, rotates or pitches with a gesture that doesn't idle, the state stops applying its zoom,\n bearing or pitch, so the user's value sticks. The next transition to a followPuck state applies them again.\n- if `pinch` or `doubleTap` doesn't idle, zoom gestures zoom around the puck, and a two-finger drag doesn't pan the map.\n This temporarily sets the gesture focal point and turns off `gestureSettings.pinchPanEnabled`,\n unless the app has set them.\n\nDefaults to true."
},
{
"name": "onStatusChanged",
diff --git a/docs/examples.json b/docs/examples.json
index 63a8bd7e1..f14411674 100644
--- a/docs/examples.json
+++ b/docs/examples.json
@@ -1051,6 +1051,20 @@
"fullPath": "example/src/examples/Camera/GetZoom.js",
"relPath": "Camera/GetZoom.js",
"name": "GetZoom"
+ },
+ {
+ "metadata": {
+ "title": "Viewport Gestures",
+ "tags": [
+ "Viewport",
+ "Viewport#transitionsToIdleUponUserInteraction"
+ ],
+ "docs": "\nFollows a simulated moving location, and chooses which gestures stop following with `transitionsToIdleUponUserInteraction`.\n\nIn the object form, pan stops following, while zooming, rotating and pitching keep following and keep the user's value.\n",
+ "disableSync": true
+ },
+ "fullPath": "example/src/examples/Camera/ViewportGestures.tsx",
+ "relPath": "Camera/ViewportGestures.tsx",
+ "name": "ViewportGestures"
}
]
}
diff --git a/example/src/examples/Camera/ViewportGestures.tsx b/example/src/examples/Camera/ViewportGestures.tsx
new file mode 100644
index 000000000..a30daf6a1
--- /dev/null
+++ b/example/src/examples/Camera/ViewportGestures.tsx
@@ -0,0 +1,154 @@
+import { useEffect, useRef, useState } from 'react';
+import { Button, StyleSheet, Text, View } from 'react-native';
+import {
+ CustomLocationProvider,
+ LocationPuck,
+ MapView,
+ Viewport,
+ type MapState,
+} from '@rnmapbox/maps';
+
+import { type ExampleWithMetadata } from '../common/ExampleMetadata'; // exclude-from-example-doc
+
+type Gesture = 'pan' | 'pinch' | 'doubleTap' | 'rotate' | 'pitch';
+const GESTURES: Gesture[] = ['pan', 'pinch', 'doubleTap', 'rotate', 'pitch'];
+
+type Mode = 'true' | 'false' | 'object';
+const MODES: Mode[] = ['true', 'false', 'object'];
+
+const START: [number, number] = [19.0402, 47.4979];
+// about 15 m/s eastwards
+const STEP_DEGREES = 0.0002;
+
+function useSimulatedLocation(): [number, number] {
+ const [coordinate, setCoordinate] = useState(START);
+ useEffect(() => {
+ const timer = setInterval(() => {
+ setCoordinate(([lng, lat]) => [lng + STEP_DEGREES, lat]);
+ }, 1000);
+ return () => clearInterval(timer);
+ }, []);
+ return coordinate;
+}
+
+// native also reports `{ kind: 'state', state }` once a transition has finished
+function describeStatus(status: {
+ kind: string;
+ state?: { kind: string };
+ toState?: { kind: string };
+}): string {
+ const state = status.state ?? status.toState;
+ return state ? `${status.kind} ${state.kind}` : status.kind;
+}
+
+const ViewportGestures = () => {
+ const viewport = useRef(null);
+ const coordinate = useSimulatedLocation();
+ const [mode, setMode] = useState('object');
+ const [idleOn, setIdleOn] = useState>({
+ pan: true,
+ pinch: false,
+ doubleTap: false,
+ rotate: false,
+ pitch: false,
+ });
+ const [status, setStatus] = useState('idle');
+ const [events, setEvents] = useState([]);
+ const [camera, setCamera] = useState('');
+
+ const transitionsToIdle =
+ mode === 'object' ? idleOn : mode === 'true' ? true : false;
+
+ const log = (line: string) =>
+ setEvents((previous) => [line, ...previous].slice(0, 4));
+
+ const follow = () =>
+ viewport.current?.transitionTo(
+ { kind: 'followPuck', options: { zoom: 16, bearing: 0, pitch: 0 } },
+ { kind: 'immediate' },
+ );
+
+ return (
+
+
+ {MODES.map((value) => (
+
+ {mode === 'object' && (
+
+ {GESTURES.map((gesture) => (
+
+ setIdleOn((previous) => ({
+ ...previous,
+ [gesture]: !previous[gesture],
+ }))
+ }
+ />
+ ))}
+
+ )}
+
+ status: {status}
+ {camera}
+ {events.map((event, index) => (
+
+ {event}
+
+ ))}
+
+ {
+ const { zoom, heading, pitch } = state.properties;
+ const gesture = state.gestures.isGestureActive ? ' gesture' : '';
+ setCamera(
+ `zoom ${zoom.toFixed(2)} bearing ${heading.toFixed(0)} pitch ${pitch.toFixed(0)}${gesture}`,
+ );
+ }}
+ >
+ {
+ setStatus(describeStatus(to));
+ log(`${describeStatus(to)} (${reason})`);
+ }}
+ />
+
+
+
+
+ );
+};
+
+export default ViewportGestures;
+
+/* end-example-doc */
+
+const styles = StyleSheet.create({
+ matchParent: { flex: 1 },
+ row: { flexDirection: 'row', flexWrap: 'wrap' },
+ readout: { padding: 4 },
+ event: { fontSize: 11 },
+});
+
+const metadata: ExampleWithMetadata['metadata'] = {
+ title: 'Viewport Gestures',
+ tags: ['Viewport', 'Viewport#transitionsToIdleUponUserInteraction'],
+ docs: `
+Follows a simulated moving location, and chooses which gestures stop following with \`transitionsToIdleUponUserInteraction\`.
+
+In the object form, pan stops following, while zooming, rotating and pitching keep following and keep the user's value.
+`,
+ disableSync: true,
+};
+ViewportGestures.metadata = metadata;
diff --git a/example/src/examples/Camera/index.js b/example/src/examples/Camera/index.js
index bb9ce0cb1..7042bc189 100644
--- a/example/src/examples/Camera/index.js
+++ b/example/src/examples/Camera/index.js
@@ -10,6 +10,7 @@ export { default as SetUserTrackingModes } from './SetUserTrackingModes';
export { default as TakeSnapshot } from './TakeSnapshot';
export { default as TakeSnapshotWithMap } from './TakeSnapshotWithMap';
export { default as Viewport } from './Viewport';
+export { default as ViewportGestures } from './ViewportGestures';
export { default as YoYo } from './YoYo';
export const metadata = {
diff --git a/ios/RNMBX/RNMBXViewport.swift b/ios/RNMBX/RNMBXViewport.swift
index 65d35dc44..b192d0399 100644
--- a/ios/RNMBX/RNMBXViewport.swift
+++ b/ios/RNMBX/RNMBXViewport.swift
@@ -3,6 +3,9 @@
@objc(RNMBXViewport)
open class RNMBXViewport : UIView, RNMBXMapAndMapViewComponent, ViewportStatusObserver {
var mapView: MapView? = nil
+ weak var map: RNMBXMapView? = nil
+ var gestureHandler: RNMBXViewportGestureHandler? = nil
+ var gestureIdlePending = false
// MARK: React properties
@objc
@@ -29,6 +32,11 @@ open class RNMBXViewport : UIView, RNMBXMapAndMapViewComponent, ViewportStatusOb
to toStatus: ViewportStatus,
reason: ViewportStatusChangeReason)
{
+ var reason = reason
+ if gestureIdlePending, case .idle = toStatus, reason == .idleRequested {
+ gestureIdlePending = false
+ reason = .userInteraction
+ }
onStatusChanged?([
"type": "statuschanged",
"payload": [
@@ -38,8 +46,9 @@ open class RNMBXViewport : UIView, RNMBXMapAndMapViewComponent, ViewportStatusOb
]])
}
+ /// `NSNumber` (boolean) or a dictionary of gesture names to booleans.
@objc
- public var transitionsToIdleUponUserInteraction: NSNumber? = nil {
+ public var transitionsToIdleUponUserInteraction: Any? = nil {
didSet {
if let mapView = mapView {
apply(mapView: mapView)
@@ -53,6 +62,7 @@ open class RNMBXViewport : UIView, RNMBXMapAndMapViewComponent, ViewportStatusOb
public func addToMap(_ map: RNMBXMapView, mapView: MapView, style: Style) {
self.mapView = mapView
+ self.map = map
applyHasStatusChanged(mapView: mapView)
apply(mapView: mapView)
}
@@ -61,8 +71,11 @@ open class RNMBXViewport : UIView, RNMBXMapAndMapViewComponent, ViewportStatusOb
if (hasStatusChanged) {
mapView.viewport.removeStatusObserver(self)
}
+ gestureHandler?.detach()
+ gestureHandler = nil
restoreTransitionsToIdleUponUserInteraction(mapView: mapView)
self.mapView = nil
+ self.map = nil
return true
}
@@ -70,14 +83,35 @@ open class RNMBXViewport : UIView, RNMBXMapAndMapViewComponent, ViewportStatusOb
private var savedTransitionsToIdleUponUserInteraction: Bool? = nil
func apply(mapView: MapView) {
- if let value = transitionsToIdleUponUserInteraction {
- var origOptions = mapView.viewport.options
- if savedTransitionsToIdleUponUserInteraction == nil {
- savedTransitionsToIdleUponUserInteraction = origOptions.transitionsToIdleUponUserInteraction
+ if let value = transitionsToIdleUponUserInteraction as? NSNumber {
+ gestureHandler?.detach()
+ gestureHandler = nil
+ setSdkTransitionsToIdle(mapView: mapView, value.boolValue)
+ } else if let value = transitionsToIdleUponUserInteraction as? [String: Any] {
+ let gestures = RNMBXViewportGestureHandler.IdleGestures(value)
+ if let gestureHandler = gestureHandler {
+ gestureHandler.idleGestures = gestures
+ } else if let map = map {
+ gestureHandler = RNMBXViewportGestureHandler(map: map, mapView: mapView, idleGestures: gestures) { [weak self] in
+ guard let self = self else { return }
+ self.gestureIdlePending = self.hasStatusChanged
+ }
}
- origOptions.transitionsToIdleUponUserInteraction = value.boolValue
- mapView.viewport.options = origOptions
+ setSdkTransitionsToIdle(mapView: mapView, false)
+ } else if let gestureHandler = gestureHandler {
+ gestureHandler.detach()
+ self.gestureHandler = nil
+ restoreTransitionsToIdleUponUserInteraction(mapView: mapView)
+ }
+ }
+
+ private func setSdkTransitionsToIdle(mapView: MapView, _ value: Bool) {
+ var options = mapView.viewport.options
+ if savedTransitionsToIdleUponUserInteraction == nil {
+ savedTransitionsToIdleUponUserInteraction = options.transitionsToIdleUponUserInteraction
}
+ options.transitionsToIdleUponUserInteraction = value
+ mapView.viewport.options = options
}
func restoreTransitionsToIdleUponUserInteraction(mapView: MapView) {
@@ -355,3 +389,189 @@ func toPadding(_ value: [String: NSNumber]) -> UIEdgeInsets {
return result
}
+
+/// Implements the object form of `transitionsToIdleUponUserInteraction`: the SDK's own option is off,
+/// and this idles the viewport only when one of the selected gestures begins.
+class RNMBXViewportGestureHandler: NSObject, GestureManagerDelegate, ViewportStatusObserver {
+ struct IdleGestures: Equatable {
+ var pan = true
+ var pinch = true
+ var doubleTap = true
+ var rotate = true
+ var pitch = true
+
+ init(_ value: [String: Any]) {
+ pan = (value["pan"] as? NSNumber)?.boolValue ?? true
+ pinch = (value["pinch"] as? NSNumber)?.boolValue ?? true
+ doubleTap = (value["doubleTap"] as? NSNumber)?.boolValue ?? true
+ rotate = (value["rotate"] as? NSNumber)?.boolValue ?? true
+ pitch = (value["pitch"] as? NSNumber)?.boolValue ?? true
+ }
+ }
+
+ var idleGestures: IdleGestures {
+ didSet { updateZoomOverrides() }
+ }
+
+ private weak var map: RNMBXMapView?
+ private weak var mapView: MapView?
+ private let willIdle: () -> Void
+
+ private var cameraChangedCancelable: AnyCancelable? = nil
+ /// The focal point this handler set, or nil if it isn't managing the focal point.
+ private var managedFocalPoint: CGPoint? = nil
+ private var managesPinchPan = false
+
+ init(map: RNMBXMapView, mapView: MapView, idleGestures: IdleGestures, willIdle: @escaping () -> Void) {
+ self.map = map
+ self.mapView = mapView
+ self.idleGestures = idleGestures
+ self.willIdle = willIdle
+ super.init()
+ map.addGestureDelegate(self)
+ mapView.viewport.addStatusObserver(self)
+ updateZoomOverrides()
+ }
+
+ func detach() {
+ stopZoomOverrides()
+ map?.removeGestureDelegate(self)
+ mapView?.viewport.removeStatusObserver(self)
+ }
+
+ // MARK: GestureManagerDelegate
+
+ func gestureManager(_ gestureManager: GestureManager, didBegin gestureType: GestureType) {
+ guard let mapView = mapView else { return }
+ let idles: Bool
+ switch gestureType {
+ case .pan:
+ idles = idleGestures.pan
+ case .pinch, .quickZoom:
+ idles = idleGestures.pinch
+ case .doubleTapToZoomIn, .doubleTouchToZoomOut:
+ idles = idleGestures.doubleTap
+ case .rotation:
+ idles = idleGestures.rotate
+ case .pitch:
+ idles = idleGestures.pitch
+ @unknown default:
+ idles = true
+ }
+
+ if idles {
+ // the SDK option, when on, also cancels camera animations on touch
+ mapView.camera.cancelAnimations()
+ if case .idle = mapView.viewport.status { return }
+ willIdle()
+ mapView.viewport.idle()
+ } else if let followPuck = followPuckState(mapView.viewport.status) {
+ switch gestureType {
+ case .pinch, .quickZoom, .doubleTapToZoomIn, .doubleTouchToZoomOut:
+ followPuck.options.zoom = nil
+ case .rotation:
+ followPuck.options.bearing = nil
+ case .pitch:
+ followPuck.options.pitch = nil
+ default:
+ break
+ }
+ }
+ }
+
+ func gestureManager(_ gestureManager: GestureManager, didEnd gestureType: GestureType, willAnimate: Bool) {}
+
+ func gestureManager(_ gestureManager: GestureManager, didEndAnimatingFor gestureType: GestureType) {}
+
+ // MARK: ViewportStatusObserver
+
+ func viewportStatusDidChange(from fromStatus: ViewportStatus, to toStatus: ViewportStatus, reason: ViewportStatusChangeReason) {
+ updateZoomOverrides()
+ }
+
+ // MARK: Zooming around the puck
+
+ private func followPuckState(_ status: ViewportStatus) -> FollowPuckViewportState? {
+ switch status {
+ case .state(let state):
+ return state as? FollowPuckViewportState
+ case .transition(_, let toState):
+ return toState as? FollowPuckViewportState
+ case .idle:
+ return nil
+ }
+ }
+
+ private func updateZoomOverrides() {
+ guard let mapView = mapView else { return }
+ let following: Bool
+ if case .state(let state) = mapView.viewport.status, state is FollowPuckViewportState {
+ following = true
+ } else {
+ following = false
+ }
+ if following && (!idleGestures.pinch || !idleGestures.doubleTap) {
+ startZoomOverrides(mapView)
+ } else {
+ stopZoomOverrides()
+ }
+ }
+
+ private func startZoomOverrides(_ mapView: MapView) {
+ guard cameraChangedCancelable == nil else { return }
+
+ if mapView.gestures.options.focalPoint == nil {
+ let focalPoint = puckScreenPoint(mapView)
+ mapView.gestures.options.focalPoint = focalPoint
+ managedFocalPoint = focalPoint
+ }
+
+ // pinch ignores focalPoint while pinchPanEnabled is set, and it makes a drifting pinch also begin a pan
+ if !pinchPanSetByApp() && mapView.gestures.options.pinchPanEnabled {
+ mapView.gestures.options.pinchPanEnabled = false
+ managesPinchPan = true
+ }
+
+ cameraChangedCancelable = mapView.mapboxMap.onCameraChanged.observe { [weak self, weak mapView] _ in
+ guard let self = self, let mapView = mapView, let managed = self.managedFocalPoint else { return }
+ guard mapView.gestures.options.focalPoint == managed else {
+ // someone else set a focal point, theirs wins
+ self.managedFocalPoint = nil
+ return
+ }
+ let focalPoint = self.puckScreenPoint(mapView)
+ if focalPoint != managed {
+ mapView.gestures.options.focalPoint = focalPoint
+ self.managedFocalPoint = focalPoint
+ }
+ }
+ }
+
+ private func stopZoomOverrides() {
+ cameraChangedCancelable?.cancel()
+ cameraChangedCancelable = nil
+ guard let mapView = mapView else { return }
+ if let managed = managedFocalPoint, mapView.gestures.options.focalPoint == managed {
+ mapView.gestures.options.focalPoint = nil
+ }
+ managedFocalPoint = nil
+ if managesPinchPan && !pinchPanSetByApp() && !mapView.gestures.options.pinchPanEnabled {
+ mapView.gestures.options.pinchPanEnabled = true
+ }
+ managesPinchPan = false
+ }
+
+ private func pinchPanSetByApp() -> Bool {
+ return map?.gestureSettings.pinchPanEnabled != nil || map?.scrollEnabled == false
+ }
+
+ /// While following, the puck is at the centre of the padded viewport.
+ private func puckScreenPoint(_ mapView: MapView) -> CGPoint {
+ let padding = mapView.mapboxMap.cameraState.padding
+ let bounds = mapView.bounds
+ return CGPoint(
+ x: padding.left + (bounds.width - padding.left - padding.right) / 2.0,
+ y: padding.top + (bounds.height - padding.top - padding.bottom) / 2.0
+ )
+ }
+}
diff --git a/ios/RNMBX/RNMBXViewportComponentView.mm b/ios/RNMBX/RNMBXViewportComponentView.mm
index 00ed1b4ef..826e9e20c 100644
--- a/ios/RNMBX/RNMBXViewportComponentView.mm
+++ b/ios/RNMBX/RNMBXViewportComponentView.mm
@@ -12,6 +12,7 @@
#import "rnmapbox_maps-Swift.pre.h"
#import "RNMBXFollyConvert.h"
+#import "RNMBXFabricHelpers.h"
// TODO: use generated RNMBXViewportEventEmitter, but need 0.73+ for dynamic support
@@ -35,23 +36,6 @@ void onStatusChanged(OnStatusChanged $event) const {
using namespace facebook::react;
-NSNumber* convertDynamicToOptional_boolean(const folly::dynamic &dyn, NSString* propertyName) {
- switch (dyn.type()) {
- case folly::dynamic::NULLT:
- return NULL;
- case folly::dynamic::BOOL:
- return [NSNumber numberWithBool:dyn.getBool()];
- default:
- std::stringstream ss;
- ss << dyn;
- [RNMBXLogger error:[NSString stringWithFormat:@"Property %@ expected to be a boolean or nil but was: $d",
- propertyName,
- ss.str().c_str()
- ]];
- return NULL;
- }
-}
-
@implementation RNMBXViewportComponentView {
RNMBXViewport *_view;
}
@@ -106,7 +90,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
const auto &newViewProps = static_cast(*props);
if (!oldProps.get() || oldViewProps.transitionsToIdleUponUserInteraction != newViewProps.transitionsToIdleUponUserInteraction) {
- _view.transitionsToIdleUponUserInteraction = convertDynamicToOptional_boolean(newViewProps.transitionsToIdleUponUserInteraction, @"transitionsToIdleUponUserInteraction");
+ _view.transitionsToIdleUponUserInteraction = RNMBXConvertFollyDynamicToId(newViewProps.transitionsToIdleUponUserInteraction);
}
if (!oldProps.get() ||
oldViewProps.hasStatusChanged !=
diff --git a/src/components/Viewport.tsx b/src/components/Viewport.tsx
index ccd7ef467..930c807a0 100644
--- a/src/components/Viewport.tsx
+++ b/src/components/Viewport.tsx
@@ -134,15 +134,40 @@ type ViewportStatusChangedEvent = {
reason: ViewportStatusChangeReason;
};
+type ViewportGesture = 'pan' | 'pinch' | 'doubleTap' | 'rotate' | 'pitch';
+
type Props = {
/**
* Indicates whether the Viewport should idle when the MapView receives touch input.
*
- * Set this property to false to enable building custom ViewportStates that can work simultaneously with certain types of gestures.
+ * - `true`: any gesture idles the viewport.
+ * - `false`: no gesture idles it. Set this to build custom ViewportStates that can work simultaneously with certain types of gestures.
+ * - an object, e.g. `{ pan: true, pinch: false, doubleTap: false }`: only the gestures set to `true` idle the viewport,
+ * the others leave the current state running. Omitted keys default to `true`.
+ * - `pan`: one-finger pan
+ * - `pinch`: pinch, and quick zoom (double-tap and drag)
+ * - `doubleTap`: double-tap to zoom in, two-finger tap to zoom out
+ * - `rotate`: two-finger rotate
+ * - `pitch`: two-finger vertical drag (shove on Android)
+ *
+ * With the object form, while in a followPuck state:
+ * - once the user zooms, rotates or pitches with a gesture that doesn't idle, the state stops applying its zoom,
+ * bearing or pitch, so the user's value sticks. The next transition to a followPuck state applies them again.
+ * - if `pinch` or `doubleTap` doesn't idle, zoom gestures zoom around the puck, and a two-finger drag doesn't pan the map.
+ * This temporarily sets the gesture focal point and turns off `gestureSettings.pinchPanEnabled`,
+ * unless the app has set them.
*
* Defaults to true.
*/
- transitionsToIdleUponUserInteraction?: boolean;
+ transitionsToIdleUponUserInteraction?:
+ | boolean
+ | {
+ pan?: boolean;
+ pinch?: boolean;
+ doubleTap?: boolean;
+ rotate?: boolean;
+ pitch?: boolean;
+ };
/**
* Subscribes to status changes, will be called when the status changes.
@@ -156,6 +181,25 @@ type Props = {
onStatusChanged?: (event: ViewportStatusChangedEvent) => void;
};
+const GESTURES: ViewportGesture[] = [
+ 'pan',
+ 'pinch',
+ 'doubleTap',
+ 'rotate',
+ 'pitch',
+];
+
+export function normalizeTransitionsToIdleUponUserInteraction(
+ value: Props['transitionsToIdleUponUserInteraction'],
+): boolean | Record | undefined {
+ if (value == null || typeof value === 'boolean') {
+ return value;
+ }
+ return Object.fromEntries(
+ GESTURES.map((gesture) => [gesture, value[gesture] ?? true]),
+ ) as Record;
+}
+
export interface Ref {
getState(): Promise;
idle(): Promise;
@@ -211,9 +255,18 @@ export const Viewport = memo(
}
}, [props.onStatusChanged]);
+ const normalizedTransitionsToIdle = useMemo(
+ () =>
+ normalizeTransitionsToIdleUponUserInteraction(
+ props.transitionsToIdleUponUserInteraction,
+ ),
+ [props.transitionsToIdleUponUserInteraction],
+ );
+
return (
& {
+type NativeProps = Omit<
+ Props,
+ 'onStatusChanged' | 'transitionsToIdleUponUserInteraction'
+> & {
+ transitionsToIdleUponUserInteraction?:
+ | boolean
+ | Record;
hasStatusChanged: boolean;
onStatusChanged?: (
event: NativeSyntheticEvent<{
diff --git a/src/specs/RNMBXViewportNativeComponent.ts b/src/specs/RNMBXViewportNativeComponent.ts
index 20160acee..d9babd0a2 100644
--- a/src/specs/RNMBXViewportNativeComponent.ts
+++ b/src/specs/RNMBXViewportNativeComponent.ts
@@ -102,7 +102,16 @@ export type OnStatusChangedEventTypeReal = {
};
export interface NativeProps extends ViewProps {
- transitionsToIdleUponUserInteraction?: OptionalProp;
+ transitionsToIdleUponUserInteraction?: OptionalProp<
+ | boolean
+ | {
+ pan: boolean;
+ pinch: boolean;
+ doubleTap: boolean;
+ rotate: boolean;
+ pitch: boolean;
+ }
+ >;
hasStatusChanged: boolean;
onStatusChanged?: DirectEventHandler;
}