Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions __tests__/components/MapView.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,8 @@ describe('MapView', () => {
getByTestId(expectedTestId);
}).not.toThrow();
});

test('defaults cameraChangedThrottleInterval to zero', () => {
expect(MapView.defaultProps.cameraChangedThrottleInterval).toBe(0);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
19 changes: 19 additions & 0 deletions docs/MapView.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}) =&gt; void`

[Map Handlers](../examples/V10/MapHandlers)



### onMapIdle
Expand Down Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions docs/examples.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
61 changes: 57 additions & 4 deletions example/src/examples/V10/MapHandlers.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Divider, Text } from '@rneui/base';
import { Button, Divider, Text } from '@rneui/base';
import {
Camera,
CircleLayer,
Expand Down Expand Up @@ -32,6 +32,9 @@ const styles = {
flex: 0,
padding: 10,
},
controls: {
gap: 8,
},
divider: {
marginVertical: 6,
},
Expand All @@ -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<MapState>({
properties: {
center: [0, 0],
Expand All @@ -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>): Geometry => {
return {
type: 'Point',
Expand All @@ -91,6 +109,7 @@ const MapHandlers = () => {
<>
<MapView
style={styles.map}
cameraChangedThrottleInterval={cameraChangedThrottleInterval}
onPress={(_feature: Feature<Geometry, GeoJsonProperties>) => {
addFeature(_feature, 'press');
}}
Expand All @@ -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);
}}
>
Expand Down Expand Up @@ -136,11 +157,29 @@ const MapHandlers = () => {
<SafeAreaView>
<View style={styles.info}>
<Text style={styles.fadedText}>
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.
</Text>

<Divider style={styles.divider} />

<View style={styles.controls}>
<Button
title={
cameraChangedThrottleInterval > 0
? 'Disable onCameraChanged throttle'
: 'Enable onCameraChanged throttle'
}
type="outline"
onPress={toggleCameraChangedThrottle}
/>

<Text style={styles.fadedText}>cameraChangedThrottleInterval</Text>
<Text>{cameraChangedThrottleInterval} ms</Text>
</View>

<Divider style={styles.divider} />

<Text style={styles.fadedText}>center</Text>
<Text>{displayCoord(center)}</Text>

Expand All @@ -162,6 +201,16 @@ const MapHandlers = () => {

<Divider style={styles.divider} />

<Text style={styles.fadedText}>onCameraChanged count</Text>
<Text>{cameraChangedCount}</Text>

<Divider style={styles.divider} />

<Text style={styles.fadedText}>onMapIdle count</Text>
<Text>{mapIdleCount}</Text>

<Divider style={styles.divider} />

<View
style={{
flex: 0,
Expand All @@ -186,9 +235,13 @@ export default MapHandlers;

const metadata: ExampleWithMetadata['metadata'] = {
title: 'Map Handlers',
tags: ['MapView#onMapIdle'],
tags: [
'MapView#onCameraChanged',
'MapView#onMapIdle',
'MapView#cameraChangedThrottleInterval',
],
docs: `
Map Handlers
Map Handlers and cameraChangedThrottleInterval
`,
};
MapHandlers.metadata = metadata;
32 changes: 32 additions & 0 deletions ios/RNMBX/RNMBXMapView.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
@_spi(Restricted) import MapboxMaps
import Turf
import MapKit
import QuartzCore

public typealias RNMBXMapViewFactoryFunc = (String, UIView) -> (MapView?)

Expand Down Expand Up @@ -218,6 +219,8 @@ open class RNMBXMapView: UIView, RCTInvalidating {
private var isPendingInitialLayout = true
private var wasGestureActive = false
private var isGestureActive = false
private var cameraChangedThrottleInterval: TimeInterval = 0
private var lastCameraChangedEventAt: CFTimeInterval = 0

var layerWaiters : [String:[(String) -> Void]] = [:]

Expand All @@ -227,6 +230,15 @@ open class RNMBXMapView: UIView, RCTInvalidating {
@objc
public var mapViewImpl : String? = nil

@objc public var reactCameraChangedThrottleInterval: NSNumber? {
didSet {
cameraChangedThrottleInterval = max(0.0, reactCameraChangedThrottleInterval?.doubleValue ?? 0.0) / 1000.0
if cameraChangedThrottleInterval == 0 {
resetCameraChangedThrottle()
}
}
}

var cancelables = Set<AnyCancelable>()

lazy var pointAnnotationManager : RNMBXPointAnnotationManager = {
Expand Down Expand Up @@ -1025,6 +1037,24 @@ open class RNMBXMapView: UIView, RCTInvalidating {
// MARK: - event handlers

extension RNMBXMapView {
private func resetCameraChangedThrottle() {
lastCameraChangedEventAt = 0
}

private func shouldEmitCameraChangedEvent() -> Bool {
guard cameraChangedThrottleInterval > 0 else {
return true
}

let now = CACurrentMediaTime()
guard now - lastCameraChangedEventAt >= cameraChangedThrottleInterval else {
return false
}

lastCameraChangedEventAt = now
return true
}

private func onEvery<T>(event: MapEventType<T>, handler: @escaping (RNMBXMapView, T) -> Void) {
let signal = event.method(self.mapView.mapboxMap)
signal.observe { [weak self] (mapEvent) in
Expand Down Expand Up @@ -1055,6 +1085,7 @@ extension RNMBXMapView {
let event = RNMBXEvent(type:.regionIsChanging, payload: self.buildRegionObject())
self.fireEvent(event: event, callback: self.reactOnMapChange)
} else if self.handleMapChangedEvents.contains(.cameraChanged) {
guard self.shouldEmitCameraChangedEvent() else { return }
let event = RNMBXCameraChanged(type:.cameraChanged, payload: self.buildStateObject(), reactTag: self.reactTag)
self.eventDispatcher.send(event)
}
Expand All @@ -1070,6 +1101,7 @@ extension RNMBXMapView {
}

self.wasGestureActive = false
self.resetCameraChangedThrottle()
})
}

Expand Down
4 changes: 4 additions & 0 deletions ios/RNMBX/RNMBXMapViewComponentView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
_view.reactPreferredFramesPerSecond = [preferredFramesPerSecond integerValue];
}

if (!oldProps.get() || oldViewProps.cameraChangedThrottleInterval != newViewProps.cameraChangedThrottleInterval) {
_view.reactCameraChangedThrottleInterval = RNMBXPropConvert_Optional_NSNumber(newViewProps.cameraChangedThrottleInterval, @"cameraChangedThrottleInterval");
}

id projection = RNMBXConvertFollyDynamicToId(newViewProps.projection);
if (projection != nil) {
_view.reactProjection = projection;
Expand Down
11 changes: 11 additions & 0 deletions src/components/MapView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,16 @@ type Props = ViewProps & {
*/
regionDidChangeDebounceTime?: 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.
*/
cameraChangedThrottleInterval?: number;

/**
* Set to true to deselect any selected annotation when the map is tapped. If set to true you will not receive
* the onPress event for the taps that deselect the annotation. Default is false.
Expand Down Expand Up @@ -502,6 +512,7 @@ class MapView extends NativeBridgeComponent(
requestDisallowInterceptTouchEvent: false,
regionWillChangeDebounceTime: 10,
regionDidChangeDebounceTime: 500,
cameraChangedThrottleInterval: 0,
};

deprecationLogged: {
Expand Down
1 change: 1 addition & 0 deletions src/specs/RNMBXMapViewNativeComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export interface NativeProps extends ViewProps {

mapViewImpl?: OptionalProp<string>;
preferredFramesPerSecond?: OptionalProp<Int32>;
cameraChangedThrottleInterval?: OptionalProp<Int32>;
}

// @ts-ignore-error - Codegen requires single cast but TypeScript prefers double cast
Expand Down