From 0a195ba8b9cde0a282bbe55635ec86e1ae7b9f38 Mon Sep 17 00:00:00 2001 From: Ted Mundy Date: Fri, 27 Feb 2026 16:24:41 +0000 Subject: [PATCH 1/4] feat: add focalpoint to ios --- ios/RNMBX/RNMBXMapView.swift | 10 ++++++++++ src/components/MapView.tsx | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/ios/RNMBX/RNMBXMapView.swift b/ios/RNMBX/RNMBXMapView.swift index 9757490882..149ba2351a 100644 --- a/ios/RNMBX/RNMBXMapView.swift +++ b/ios/RNMBX/RNMBXMapView.swift @@ -579,6 +579,7 @@ open class RNMBXMapView: UIView, RCTInvalidating { var panEnabled: Bool? = nil; var panDecelerationFactor: CGFloat? = nil; var simultaneousRotateAndPinchZoomEnabled: Bool? = nil; + var focalPoint: CGPoint? = nil; } var gestureSettings = GestureSettings() @@ -626,6 +627,12 @@ open class RNMBXMapView: UIView, RCTInvalidating { if let simultaneousRotateAndPinchZoomEnabled = value["simultaneousRotateAndPinchZoomEnabled"] as? NSNumber { options.simultaneousRotateAndPinchZoomEnabled = simultaneousRotateAndPinchZoomEnabled.boolValue } + if let focalPoint = value["focalPoint"] as? NSDictionary { + if let x = focalPoint["x"] as? NSNumber, let y = focalPoint["y"] as? NSNumber { + options.focalPoint = CGPoint(x: x.doubleValue, y: y.doubleValue) + } + } + /* android only if let zoomAnimationAmount = value["zoomAnimationAmount"] as? NSNumber { options.zoomAnimationAmount = zoomAnimationAmount.CGFloat @@ -674,6 +681,9 @@ open class RNMBXMapView: UIView, RCTInvalidating { if let simultaneousRotateAndPinchZoomEnabled = settings.simultaneousRotateAndPinchZoomEnabled as? Bool { options.simultaneousRotateAndPinchZoomEnabled = simultaneousRotateAndPinchZoomEnabled } + if let focalPoint = settings.focalPoint as? CGPoint { + options.focalPoint = focalPoint + } /* android only if let zoomAnimationAmount = value["zoomAnimationAmount"] as? NSNumber { options.zoomAnimationAmount = zoomAnimationAmount.CGFloat diff --git a/src/components/MapView.tsx b/src/components/MapView.tsx index b458c85176..aac8a18d0f 100644 --- a/src/components/MapView.tsx +++ b/src/components/MapView.tsx @@ -74,6 +74,12 @@ export type GestureSettings = { * Whether single tapping the map with two touches results in a zoom-out animation. */ doubleTouchToZoomOutEnabled?: boolean; + /** + * By default, gestures rotate and zoom around the center of the gesture. Set + * this property to rotate and zoom around a fixed point instead. + */ + focalPoint?: Point; + /** * Whether pan/scroll is enabled for the pinch gesture. */ From f03cd3b9876de7ab82b6e5d4172402ed793ec86e Mon Sep 17 00:00:00 2001 From: Ted Mundy Date: Fri, 27 Feb 2026 16:36:04 +0000 Subject: [PATCH 2/4] feat(docs): yarn generate --- docs/MapView.md | 2 ++ docs/docs.json | 7 +++++++ src/components/MapView.tsx | 4 ++-- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/MapView.md b/docs/MapView.md index d2ad2d0c82..69ec2c9e8a 100644 --- a/docs/MapView.md +++ b/docs/MapView.md @@ -289,6 +289,8 @@ Set map's label locale, e.g. `{ "locale": "es" }` will localize labels to Spanis 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: signature; /* By default, gestures rotate and zoom around the center of the gesture. Set +this property to rotate and zoom around a fixed point instead. */ 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. diff --git a/docs/docs.json b/docs/docs.json index d10948f88a..52fa80e6ac 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -5308,6 +5308,13 @@ "default": "none", "description": "Whether single tapping the map with two touches results in a zoom-out animation." }, + { + "name": "focalPoint", + "required": false, + "type": "signature", + "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." + }, { "name": "pinchPanEnabled", "required": false, diff --git a/src/components/MapView.tsx b/src/components/MapView.tsx index aac8a18d0f..a1bcb8ee8e 100644 --- a/src/components/MapView.tsx +++ b/src/components/MapView.tsx @@ -77,8 +77,8 @@ export type GestureSettings = { /** * By default, gestures rotate and zoom around the center of the gesture. Set * this property to rotate and zoom around a fixed point instead. - */ - focalPoint?: Point; + */ + focalPoint?: Point; /** * Whether pan/scroll is enabled for the pinch gesture. From 64f9d423c205a08cfe0f8fc2d9897561dbb38a5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Thu, 15 Oct 2026 17:29:40 +0200 Subject: [PATCH 3/4] feat: support gestureSettings.focalPoint on Android, reset it on removal, add example Android converts the dp focal point to pixels for GesturesSettings.focalPoint. Both platforms clear the focal point when it's removed from gestureSettings. The doc generator now expands nested object types instead of printing 'signature'. --- .../components/mapview/RNMBXMapViewManager.kt | 14 ++- docs/Camera.md | 2 +- docs/MapView.md | 8 +- docs/docs.json | 6 +- docs/examples.json | 12 +++ .../src/examples/Map/GestureFocalPoint.tsx | 92 +++++++++++++++++++ example/src/examples/Map/index.js | 1 + ios/RNMBX/RNMBXMapView.swift | 17 ++-- scripts/autogenHelpers/DocJSONBuilder.mjs | 2 +- src/components/MapView.tsx | 7 +- 10 files changed, 141 insertions(+), 20 deletions(-) create mode 100644 example/src/examples/Map/GestureFocalPoint.tsx 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 05d3bac8d1..bf499a322d 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.extension.style.layers.properties.generated.ProjectionName import com.mapbox.maps.plugin.gestures.gestures import com.rnmapbox.rnmbx.events.constants.eventMapOf @@ -20,6 +21,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 @@ -157,10 +159,20 @@ open class RNMBXMapViewManager(context: ReactApplicationContext, val viewTagReso it.gesturesPlugin { val map = settings.asMap() if (map == null) { - Logger.e(LOG_TAG, "gestureSettings map is null") + this.updateSettings { this.focalPoint = null } return@gesturesPlugin Unit } 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 1b5b3fcddc..f1421b791f 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 96bfb7d35c..335eb7ad55 100644 --- a/docs/MapView.md +++ b/docs/MapView.md @@ -300,8 +300,10 @@ Removing the prop stops localizing labels from the next style load. 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: signature; /* By default, gestures rotate and zoom around the center of the gesture. Set -this property to rotate and zoom around a fixed point instead. */ + 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. */ 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. @@ -323,7 +325,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 d265d7d624..0e3be0e0a7 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -975,7 +975,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." }, @@ -5503,9 +5503,9 @@ { "name": "focalPoint", "required": false, - "type": "signature", + "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." + "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." }, { "name": "pinchPanEnabled", diff --git a/docs/examples.json b/docs/examples.json index a9e67ad08c..b6ba586b72 100644 --- a/docs/examples.json +++ b/docs/examples.json @@ -314,6 +314,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 0000000000..4c6721d97b --- /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} +