diff --git a/__tests__/components/MapView.test.js b/__tests__/components/MapView.test.js index e580fe217..e0e22966e 100644 --- a/__tests__/components/MapView.test.js +++ b/__tests__/components/MapView.test.js @@ -13,4 +13,8 @@ describe('MapView', () => { getByTestId(expectedTestId); }).not.toThrow(); }); + + test('defaults cameraChangedThrottleInterval to zero', () => { + expect(MapView.defaultProps.cameraChangedThrottleInterval).toBe(0); + }); }); 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 63ca76733..826781e0b 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 @@ -4,6 +4,7 @@ import android.content.Context import android.graphics.BitmapFactory import android.os.Handler import android.os.Looper +import android.os.SystemClock import android.view.Gravity import android.view.View import android.view.View.OnLayoutChangeListener @@ -176,6 +177,8 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie private var wasGestureActive = false private var isGestureActive = false + private var mCameraChangedThrottleInterval: Long = 0 + private var mLastCameraChangedEventTimestamp: Long = 0 var mapViewImpl: String? = null @@ -581,6 +584,32 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie } } + fun setReactCameraChangedThrottleInterval(cameraChangedThrottleInterval: Int) { + mCameraChangedThrottleInterval = cameraChangedThrottleInterval.toLong().coerceAtLeast(0L) + if (mCameraChangedThrottleInterval == 0L) { + resetCameraChangedThrottle() + } + } + + private fun resetCameraChangedThrottle() { + mLastCameraChangedEventTimestamp = 0L + } + + private fun shouldEmitCameraChangedEvent(): Boolean { + val interval = mCameraChangedThrottleInterval + if (interval <= 0L) { + return true + } + + val now = SystemClock.elapsedRealtime() + if (now - mLastCameraChangedEventTimestamp < interval) { + return false + } + + mLastCameraChangedEventTimestamp = now + return true + } + fun setReactMaxPitch(maxPitch: Double?) { mMaxPitch = maxPitch changes.add(Property.MAX_PITCH) @@ -782,11 +811,13 @@ open class RNMBXMapView(private val mContext: Context, var mManager: RNMBXMapVie fun sendRegionDidChangeEvent() { handleMapChangedEvent(EventTypes.REGION_DID_CHANGE) mCameraChangeTracker.setReason(CameraChangeReason.NONE) + resetCameraChangedThrottle() } private fun handleMapChangedEvent(eventType: String) { this.wasGestureActive = isGestureActive if (!canHandleEvent(eventType)) return + if (eventType == EventTypes.CAMERA_CHANGED && !shouldEmitCameraChangedEvent()) return val event: IEvent event = when (eventType) { 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 bdc25714d..620441486 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 @@ -220,6 +220,15 @@ open class RNMBXMapViewManager(context: ReactApplicationContext, val viewTagReso mapView.setReactPreferredFramesPerSecond(preferredFramesPerSecond.asInt()) } + @ReactProp(name = "cameraChangedThrottleInterval") + override fun setCameraChangedThrottleInterval(mapView: RNMBXMapView, cameraChangedThrottleInterval: Dynamic) { + if (cameraChangedThrottleInterval.type == ReadableType.Null) { + mapView.setReactCameraChangedThrottleInterval(0) + return + } + mapView.setReactCameraChangedThrottleInterval(cameraChangedThrottleInterval.asInt()) + } + @ReactProp(name = "zoomEnabled") override fun setZoomEnabled(map: RNMBXMapView, zoomEnabled: Dynamic) { map.withMapView { diff --git a/docs/MapView.md b/docs/MapView.md index dbc7f1b0c..b16aaf5bb 100644 --- a/docs/MapView.md +++ b/docs/MapView.md @@ -378,6 +378,8 @@ func v10 only, replaces onRegionIsChanging *signature:*`(state:{properties: {center: GeoJSON.Position, bounds: {ne: GeoJSON.Position, sw: GeoJSON.Position}, zoom: number, heading: number, pitch: number}, gestures: {isGestureActive: boolean}, timestamp: number}) => void` +[Map Handlers](../examples/V10/MapHandlers) + ### onMapIdle @@ -531,6 +533,23 @@ The emitted frequency of regiondidchange events _defaults to:_ `500` +### cameraChangedThrottleInterval + +```tsx +number +``` +Native-side throttle interval for onCameraChanged emissions, in milliseconds. + +This is useful when camera changes trigger expensive JS work during gestures +like pinch-zoom. The final camera state remains available through onMapIdle. +Opt-in only: omitting this prop preserves the current behavior. +Defaults to 0, which emits every native camera change event. + + _defaults to:_ `0` + +[Map Handlers](../examples/V10/MapHandlers) + + ### deselectAnnotationOnTap ```tsx diff --git a/docs/examples.json b/docs/examples.json index 6567edf54..a779053f2 100644 --- a/docs/examples.json +++ b/docs/examples.json @@ -547,9 +547,11 @@ "metadata": { "title": "Map Handlers", "tags": [ - "MapView#onMapIdle" + "MapView#onCameraChanged", + "MapView#onMapIdle", + "MapView#cameraChangedThrottleInterval" ], - "docs": "\nMap Handlers\n" + "docs": "\nMap Handlers and cameraChangedThrottleInterval\n" }, "fullPath": "example/src/examples/V10/MapHandlers.tsx", "relPath": "V10/MapHandlers.tsx", diff --git a/example/src/examples/V10/MapHandlers.tsx b/example/src/examples/V10/MapHandlers.tsx index 8f84234d1..5c3c2d98e 100644 --- a/example/src/examples/V10/MapHandlers.tsx +++ b/example/src/examples/V10/MapHandlers.tsx @@ -1,4 +1,4 @@ -import { Divider, Text } from '@rneui/base'; +import { Button, Divider, Text } from '@rneui/base'; import { Camera, CircleLayer, @@ -32,6 +32,9 @@ const styles = { flex: 0, padding: 10, }, + controls: { + gap: 8, + }, divider: { marginVertical: 6, }, @@ -40,8 +43,14 @@ const styles = { }, }; +const CAMERA_CHANGED_THROTTLE_MS = 250; + const MapHandlers = () => { const [lastCallback, setLastCallback] = useState(''); + const [cameraChangedThrottleInterval, setCameraChangedThrottleInterval] = + useState(0); + const [cameraChangedCount, setCameraChangedCount] = useState(0); + const [mapIdleCount, setMapIdleCount] = useState(0); const [mapState, setMapState] = useState({ properties: { center: [0, 0], @@ -65,6 +74,15 @@ const MapHandlers = () => { const heading = properties?.heading; const gestures = mapState?.gestures; + const toggleCameraChangedThrottle = () => { + setCameraChangedThrottleInterval((current) => + current > 0 ? 0 : CAMERA_CHANGED_THROTTLE_MS, + ); + setCameraChangedCount(0); + setMapIdleCount(0); + setLastCallback(''); + }; + const buildShape = (feature: Feature): Geometry => { return { type: 'Point', @@ -91,6 +109,7 @@ const MapHandlers = () => { <> ) => { addFeature(_feature, 'press'); }} @@ -99,10 +118,12 @@ const MapHandlers = () => { }} onCameraChanged={(_state) => { setLastCallback('onCameraChanged'); + setCameraChangedCount((count) => count + 1); setMapState(_state); }} onMapIdle={(_state) => { setLastCallback('onMapIdle'); + setMapIdleCount((count) => count + 1); setMapState(_state); }} > @@ -136,11 +157,29 @@ const MapHandlers = () => { - Tap or long-press to create a marker. + Tap or long-press to create a marker. Pan or pinch-zoom the map to + compare event volume with and without throttling. + +