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 66e5a6e61..0e02ccee6 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 @@ -13,6 +13,7 @@ import com.rnmapbox.rnmbx.events.constants.EventKeys import com.facebook.react.uimanager.ViewManagerDelegate import com.facebook.react.viewmanagers.RNMBXMapViewManagerDelegate import com.facebook.react.viewmanagers.RNMBXMapViewManagerInterface +import com.mapbox.maps.ScreenCoordinate import com.mapbox.maps.MapboxExperimental import com.mapbox.maps.SymbolScaleBehavior import com.mapbox.maps.extension.style.layers.properties.generated.ProjectionName @@ -22,6 +23,7 @@ import com.rnmapbox.rnmbx.utils.Logger import com.rnmapbox.rnmbx.utils.ViewTagResolver import com.rnmapbox.rnmbx.utils.extensions.getAndLogIfNotBoolean import com.rnmapbox.rnmbx.utils.extensions.getAndLogIfNotDouble +import com.rnmapbox.rnmbx.utils.extensions.getAndLogIfNotMap import java.lang.Exception import java.util.HashMap @@ -170,13 +172,24 @@ open class RNMBXMapViewManager(context: ReactApplicationContext, val viewTagReso override fun setGestureSettings(mapView: RNMBXMapView, settings: Dynamic) { mapView.withMap { it.gesturesPlugin { - val map = settings.asMap() + val map = if (settings.type == ReadableType.Map) settings.asMap() else null if (map == null) { - Logger.e(LOG_TAG, "gestureSettings map is null") + mapView.pinchPanEnabledSetByApp = false + this.updateSettings { this.focalPoint = null } return@gesturesPlugin Unit } mapView.pinchPanEnabledSetByApp = map.hasKey("pinchPanEnabled") && !map.isNull("pinchPanEnabled") this.updateSettings { + val focalPoint = map.takeIf { it.hasKey("focalPoint") && !it.isNull("focalPoint") } + ?.getAndLogIfNotMap("focalPoint", LOG_TAG) + val x = focalPoint?.getAndLogIfNotDouble("x", LOG_TAG) + val y = focalPoint?.getAndLogIfNotDouble("y", LOG_TAG) + this.focalPoint = if (x != null && y != null) { + val density = mapView.resources.displayMetrics.density + ScreenCoordinate(x * density, y * density) + } else { + null + } map.getAndLogIfNotBoolean("doubleTapToZoomInEnabled", LOG_TAG)?.let { this.doubleTapToZoomInEnabled = it } diff --git a/docs/Camera.md b/docs/Camera.md index 1b5b3fcdd..f1421b791 100644 --- a/docs/Camera.md +++ b/docs/Camera.md @@ -218,7 +218,7 @@ compatibility; the root `padding` prop should be used instead. */ heading: number; /* Heading (bearing, orientation) of the map, measured in degrees clockwise from true north. */ pitch: number; /* The pitch toward the horizon measured in degrees, with 0 degrees resulting in a top-down view for a two-dimensional map. */ zoomLevel: number; /* The zoom level of the map. */ - padding: signature; /* The viewport padding in points. An omitted edge keeps its current padding. */ + padding: {paddingLeft: number, paddingRight: number, paddingTop: number, paddingBottom: number}; /* The viewport padding in points. An omitted edge keeps its current padding. */ animationDuration: number; /* The duration the map takes to animate to a new configuration. */ animationMode: 'flyTo' \| 'easeTo' \| 'linearTo' \| 'moveTo' \| 'none'; /* The easing or path the camera uses to animate to a new configuration. */ } diff --git a/docs/MapView.md b/docs/MapView.md index 504bc9073..804d44b19 100644 --- a/docs/MapView.md +++ b/docs/MapView.md @@ -367,6 +367,12 @@ Options a platform doesn't support are ignored there with a warning. An empty ar type GestureSettings = { doubleTapToZoomInEnabled: boolean; /* Whether double tapping the map with one touch results in a zoom-in animation. */ doubleTouchToZoomOutEnabled: boolean; /* Whether single tapping the map with two touches results in a zoom-out animation. */ + focalPoint: {x: number, y: number}; /* By default, gestures rotate and zoom around the center of the gesture. Set +this property to rotate and zoom around a fixed point instead, given in +points (iOS) / dp (Android) relative to the top-left of the map view. +Applies to pinch, rotate, double-tap and two-finger tap zoom. +On iOS, pinch ignores the focal point while `pinchPanEnabled` is `true` (the default), +so set `pinchPanEnabled: false` to keep pinch anchored to it. */ pinchPanEnabled: boolean; /* Whether pan/scroll is enabled for the pinch gesture. */ pinchZoomEnabled: boolean; /* Whether zoom is enabled for the pinch gesture. */ pinchZoomDecelerationEnabled: boolean; /* Whether a deceleration animation following a pinch-zoom gesture is enabled. True by default. @@ -388,7 +394,7 @@ On android set to 0 to disable deceleration, and non zero to enabled it. */ ``` Gesture configuration allows to control the user touch interaction. - +[Gesture Focal Point](../examples/Map/GestureFocalPoint) ### onPress diff --git a/docs/docs.json b/docs/docs.json index 5dff1c0f7..d0c650c4b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -976,7 +976,7 @@ { "name": "padding", "required": false, - "type": "signature", + "type": "{paddingLeft: number, paddingRight: number, paddingTop: number, paddingBottom: number}", "default": "none", "description": "The viewport padding in points. An omitted edge keeps its current padding." }, @@ -5566,6 +5566,13 @@ "default": "none", "description": "Whether single tapping the map with two touches results in a zoom-out animation." }, + { + "name": "focalPoint", + "required": false, + "type": "{x: number, y: number}", + "default": "none", + "description": "By default, gestures rotate and zoom around the center of the gesture. Set\nthis property to rotate and zoom around a fixed point instead, given in\npoints (iOS) / dp (Android) relative to the top-left of the map view.\nApplies to pinch, rotate, double-tap and two-finger tap zoom.\nOn iOS, pinch ignores the focal point while `pinchPanEnabled` is `true` (the default),\nso set `pinchPanEnabled: false` to keep pinch anchored to it." + }, { "name": "pinchPanEnabled", "required": false, diff --git a/docs/examples.json b/docs/examples.json index a2bfbc54e..3a2d9db2d 100644 --- a/docs/examples.json +++ b/docs/examples.json @@ -344,6 +344,18 @@ "relPath": "Map/DynamicUrl.tsx", "name": "DynamicUrl" }, + { + "metadata": { + "title": "Gesture Focal Point", + "tags": [ + "MapView#gestureSettings" + ], + "docs": "\nPinch, rotate and double-tap zoom pivot around the red marker instead of the touch location while `gestureSettings.focalPoint` is set. The coordinate under the marker stays the same across those gestures.\n" + }, + "fullPath": "example/src/examples/Map/GestureFocalPoint.tsx", + "relPath": "Map/GestureFocalPoint.tsx", + "name": "GestureFocalPoint" + }, { "metadata": { "title": "Localize Labels", diff --git a/example/src/examples/Map/GestureFocalPoint.tsx b/example/src/examples/Map/GestureFocalPoint.tsx new file mode 100644 index 000000000..4c6721d97 --- /dev/null +++ b/example/src/examples/Map/GestureFocalPoint.tsx @@ -0,0 +1,92 @@ +import { useCallback, useRef, useState } from 'react'; +import { Button, StyleSheet, Text, View } from 'react-native'; +import Mapbox from '@rnmapbox/maps'; +import { type ExampleWithMetadata } from '../common/ExampleMetadata'; + +const FOCAL_POINT = { x: 100, y: 150 }; +const MARKER_SIZE = 24; + +const GestureFocalPoint = () => { + const mapRef = useRef(null); + const [focalPointEnabled, setFocalPointEnabled] = useState(true); + const [status, setStatus] = useState(''); + + const handleMapIdle = useCallback(async (state: Mapbox.MapState) => { + const coordinate = await mapRef.current?.getCoordinateFromView([ + FOCAL_POINT.x, + FOCAL_POINT.y, + ]); + if (!coordinate) return; + const [lon = 0, lat = 0] = coordinate; + setStatus( + `zoom ${state.properties.zoom.toFixed( + 2, + )} · heading ${state.properties.heading.toFixed(0)}°\n` + + `under marker: ${lon.toFixed(4)}, ${lat.toFixed(4)}`, + ); + }, []); + + return ( + + + + + + {focalPointEnabled && ( + + )} + + + {status} +