Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ When no animation prop is set, the default is `system`: each provider keeps its

Explicit configs use milliseconds. `duration` defaults to `180`, `delay` defaults to `0`, and both values are clamped to `0..3000` before they reach the native provider. `reduceMotion` defaults to `system`, which disables explicit animations when the platform Reduced Motion setting asks for it; use `never` only when the app intentionally ignores that setting for this overlay.

On iOS with `provider="google"`, marker and cluster entering animations can reduce UI-thread frame rate when a large viewport refresh adds many markers at once. The provider caps animated markers per refresh and may show the remaining markers immediately to preserve map gesture performance. For very large marker sets, prefer clustering, shorter durations, or `markerEnteringAnimation={false}` / `clusterEnteringAnimation={false}` when smooth gestures are more important than entrance motion.
On Google Maps providers, marker and cluster entering animations can reduce UI-thread frame rate when a large viewport refresh adds many markers at once. The provider caps animated markers per refresh and may show the remaining markers immediately to preserve map gesture performance. For very large marker sets, prefer clustering, shorter durations, or `markerEnteringAnimation={false}` / `clusterEnteringAnimation={false}` when smooth gestures are more important than entrance motion.

### Capability matrix

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ Map and overlay callbacks are wired through Nitro listeners on the HybridView. C

Marker and marker-cluster entering animations follow the same descriptor model. The public API accepts `false`, `system`, or a serializable preset config; the React wrapper normalizes that into native descriptors. Native provider adapters execute the animation when a marker render element appears in the render diff. Updating animation config for an already retained marker does not restart the animation; the new config is used the next time that marker is added again.

Google Maps on iOS is more sensitive to marker animation churn than MapKit. Large viewport refreshes can add many `GMSMarker` instances on the main thread, so the Google provider limits how many markers animate per refresh and reveals the rest immediately. This keeps gestures responsive, but very large marker sets may still need clustering, disabled entering animations, or a future provider-specific animation strategy.
Google Maps SDKs are sensitive to marker animation churn. Large viewport refreshes can add many native marker instances on the main thread, so the Google provider limits how many markers animate per refresh and reveals the rest immediately. This keeps gestures responsive, but very large marker sets may still need clustering, disabled entering animations, or a future provider-specific animation strategy.

## Data flow (target state)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import com.facebook.react.uimanager.ThemedReactContext
import com.google.android.gms.maps.CameraUpdateFactory
import com.google.android.gms.maps.GoogleMap
Expand All @@ -22,6 +23,7 @@ class MapOverlayController(
private val context: ThemedReactContext,
) {
private val markers = HashMap<String, Marker>()
private val markerVersions = HashMap<String, Long>()
private val clusterByKey = HashMap<String, ClusterElement.Cluster>()
private val polylines = LinkedHashMap<String, Polyline>()
private val polygons = LinkedHashMap<String, Polygon>()
Expand All @@ -36,7 +38,9 @@ class MapOverlayController(
private var refreshGeneration: Int = 0
private var viewWidthPx: Int = 0
private var viewHeightPx: Int = 0
private var liveRefreshPending = false
private var idleRefreshRunnable: Runnable? = null
private var liveRefreshRunnable: Runnable? = null
private var lastLiveRefreshMs: Long = 0L
private var computeExecutor = Executors.newSingleThreadExecutor()
private val mainHandler = Handler(Looper.getMainLooper())
private val density: Float = context.resources.displayMetrics.density
Expand Down Expand Up @@ -77,12 +81,15 @@ class MapOverlayController(

fun clear() {
markerEnterAnimators.values.toSet().forEach { it.cancel() }
cancelIdleRefresh()
cancelLiveRefresh()
markerEnterAnimators.clear()
markers.values.forEach { it.remove() }
polylines.values.forEach { it.remove() }
polygons.values.forEach { it.remove() }
circles.values.forEach { it.remove() }
markers.clear()
markerVersions.clear()
clusterByKey.clear()
polylines.clear()
polygons.clear()
Expand Down Expand Up @@ -125,7 +132,11 @@ class MapOverlayController(
}
}

fun refreshViewportMarkers() {
fun refreshViewportMarkers(
animateEntering: Boolean = true,
updateRetained: Boolean = true,
maxAnimatedMarkers: Int = MAX_ANIMATED_MARKERS_PER_DIFF,
) {
val map = googleMap ?: return
val index = spatialIndex ?: return
if (!usesViewportPipeline()) {
Expand All @@ -140,7 +151,7 @@ class MapOverlayController(
val clustering = clusteringEnabled
val widthPx = viewWidthPx
val heightPx = viewHeightPx
val displayed = HashSet(markers.keys)
val displayedVersions = HashMap(markerVersions)
refreshGeneration += 1
val generation = refreshGeneration

Expand All @@ -161,19 +172,22 @@ class MapOverlayController(
if (!nextKeys.add(key)) {
continue
}
if (displayed.contains(key)) {
retained.add(element)
val version = element.renderVersion
if (displayedVersions[key] != null) {
if (updateRetained && displayedVersions[key] != version) {
retained.add(element)
}
} else {
added.add(element)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
val removed = displayed - nextKeys
val removed = displayedVersions.keys - nextKeys

mainHandler.post {
if (generation != refreshGeneration) {
return@post
}
applyDiff(removed, added, retained)
applyDiff(removed, added, retained, animateEntering, maxAnimatedMarkers)
}
}
}
Expand All @@ -199,45 +213,63 @@ class MapOverlayController(
removedKeys: Set<String>,
added: List<ClusterElement>,
retained: List<ClusterElement>,
animateEntering: Boolean = true,
maxAnimatedMarkers: Int = MAX_ANIMATED_MARKERS_PER_DIFF,
) {
val map = googleMap ?: return

for (key in removedKeys) {
cancelEnteringAnimation(key)
markers.remove(key)?.remove()
markerVersions.remove(key)
clusterByKey.remove(key)
}

val addedMarkers = ArrayList<AddedMarker>(added.size)
var remainingAnimationBudget = maxAnimatedMarkers.coerceAtLeast(0)
val addedMarkers = ArrayList<AddedMarker>(minOf(added.size, remainingAnimationBudget))
for (element in added) {
val key = element.diffKey
when (element) {
is ClusterElement.Single -> {
val animation = enteringAnimation(element)
val shouldAnimate = animateEntering &&
remainingAnimationBudget > 0 &&
OverlayEnteringAnimationResolver.shouldRun(animation)
val options = element.descriptor.toMarkerOptions()
if (OverlayEnteringAnimationResolver.shouldRun(animation)) {
if (shouldAnimate) {
options.alpha(0f)
}
map.addMarker(options)?.also { marker ->
marker.tag = key
markers[key] = marker
addedMarkers.add(AddedMarker(key, marker, animation))
markerVersions[key] = element.renderVersion
if (shouldAnimate) {
addedMarkers.add(AddedMarker(key, marker, animation))
remainingAnimationBudget -= 1
}
}
}
is ClusterElement.Cluster -> {
val animation = enteringAnimation(element)
val shouldAnimate = animateEntering &&
remainingAnimationBudget > 0 &&
OverlayEnteringAnimationResolver.shouldRun(animation)
val options = MarkerOptions()
.position(element.position)
.icon(iconFactory.icon(element.count))
.anchor(0.5f, 0.5f)
if (OverlayEnteringAnimationResolver.shouldRun(animation)) {
if (shouldAnimate) {
options.alpha(0f)
}
map.addMarker(options)?.also { marker ->
marker.tag = key
markers[key] = marker
markerVersions[key] = element.renderVersion
clusterByKey[key] = element
addedMarkers.add(AddedMarker(key, marker, animation))
if (shouldAnimate) {
addedMarkers.add(AddedMarker(key, marker, animation))
remainingAnimationBudget -= 1
}
}
}
}
Expand All @@ -257,13 +289,15 @@ class MapOverlayController(
marker.title = element.descriptor.title
marker.snippet = element.descriptor.subtitle
marker.isDraggable = element.descriptor.draggable == true
clusterByKey.remove(key)
}
is ClusterElement.Cluster -> {
marker.position = element.position
marker.setIcon(iconFactory.icon(element.count))
clusterByKey[key] = element
}
}
markerVersions[key] = element.renderVersion
}

animateEntering(addedMarkers)
Expand Down Expand Up @@ -352,6 +386,11 @@ class MapOverlayController(

private fun applyMarkersSync(descriptors: Array<MarkerDescriptor>) {
val map = googleMap ?: return
refreshGeneration += 1
cancelIdleRefresh()
cancelLiveRefresh()
markerVersions.clear()
clusterByKey.clear()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
reconcile(
current = markers,
next = descriptors.associate { ("s:" + it.id) to it },
Expand All @@ -369,10 +408,13 @@ class MapOverlayController(
}
map.addMarker(options)?.also { marker ->
marker.tag = key
markerVersions[key] = element.renderVersion
animateEntering(listOf(AddedMarker(key, marker, animation)))
}
},
update = { marker, descriptor ->
val element = ClusterElement.Single(descriptor)
val key = "s:" + descriptor.id
(marker.tag as? String)?.let { cancelEnteringAnimation(it) }
marker.alpha = 1f
marker.position = LatLng(
Expand All @@ -382,29 +424,77 @@ class MapOverlayController(
marker.title = descriptor.title
marker.snippet = descriptor.subtitle
marker.isDraggable = descriptor.draggable == true
markerVersions[key] = element.renderVersion
marker
},
)
}

fun onCameraIdle() {
if (usesViewportPipeline()) {
refreshViewportMarkers()
scheduleIdleRefresh()
}
}

/** Throttled live recompute while the camera is moving. */
/** Runs a lightweight live pass while deferring exact marker updates to idle. */
fun onCameraMove() {
if (!usesViewportPipeline() || liveRefreshPending) {
return
}
liveRefreshPending = true
mainHandler.postDelayed({
liveRefreshPending = false
cancelIdleRefresh()
scheduleLiveRefresh()
}

private fun scheduleIdleRefresh() {
cancelLiveRefresh()
cancelIdleRefresh()
val runnable = Runnable {
idleRefreshRunnable = null
if (usesViewportPipeline()) {
refreshViewportMarkers()
}
}, LIVE_REFRESH_THROTTLE_MS)
}
idleRefreshRunnable = runnable
mainHandler.postDelayed(runnable, IDLE_REFRESH_DEBOUNCE_MS)
}

private fun scheduleLiveRefresh() {
if (!usesViewportPipeline() || liveRefreshRunnable != null) {
return
}

val now = SystemClock.uptimeMillis()
val elapsed = now - lastLiveRefreshMs
if (lastLiveRefreshMs == 0L || elapsed >= LIVE_REFRESH_THROTTLE_MS) {
runLiveRefresh()
return
}

val runnable = Runnable {
liveRefreshRunnable = null
runLiveRefresh()
}
liveRefreshRunnable = runnable
mainHandler.postDelayed(runnable, LIVE_REFRESH_THROTTLE_MS - elapsed)
}

private fun runLiveRefresh() {
lastLiveRefreshMs = SystemClock.uptimeMillis()
if (usesViewportPipeline()) {
refreshViewportMarkers(
animateEntering = true,
updateRetained = true,
maxAnimatedMarkers = MAX_LIVE_ANIMATED_MARKERS_PER_DIFF,
)
}
}

private fun cancelIdleRefresh() {
idleRefreshRunnable?.let(mainHandler::removeCallbacks)
idleRefreshRunnable = null
}

private fun cancelLiveRefresh() {
liveRefreshRunnable?.let(mainHandler::removeCallbacks)
liveRefreshRunnable = null
lastLiveRefreshMs = 0L
}

fun onMarkerClick(marker: Marker): Boolean {
Expand Down Expand Up @@ -523,8 +613,17 @@ class MapOverlayController(
/** Non-clustered datasets at or below this size reconcile synchronously. */
const val ASYNC_THRESHOLD = 500

/** Minimum gap between live recomputes while the camera moves. */
const val LIVE_REFRESH_THROTTLE_MS = 100L
/** Main-thread marker animations are capped so bulk refreshes do not block gestures. */
const val MAX_ANIMATED_MARKERS_PER_DIFF = 96

/** Live refresh keeps entrance motion visible without animating every marker during gestures. */
const val MAX_LIVE_ANIMATED_MARKERS_PER_DIFF = 24

/** Coalesces rapid Google Maps idle callbacks produced by repeated short pans. */
const val IDLE_REFRESH_DEBOUNCE_MS = 120L

/** Minimum delay between lightweight viewport updates while the camera moves. */
const val LIVE_REFRESH_THROTTLE_MS = 180L
}

private data class AddedMarker(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,20 @@ import kotlin.math.roundToInt
/** A single display element: an individual marker or a cluster badge. */
internal sealed interface ClusterElement {
val diffKey: String
val renderVersion: Long

data class Single(val descriptor: MarkerDescriptor) : ClusterElement {
override val diffKey: String get() = "s:" + descriptor.id
override val renderVersion: Long = renderSignature(
"single",
descriptor.id,
descriptor.coordinate.latitude,
descriptor.coordinate.longitude,
descriptor.title,
descriptor.subtitle,
descriptor.draggable,
descriptor.clusterable,
)
}

data class Cluster(
Expand All @@ -23,7 +34,27 @@ internal sealed interface ClusterElement {
val bounds: LatLngBounds,
) : ClusterElement {
override val diffKey: String get() = "c:$key"
override val renderVersion: Long = renderSignature(
"cluster",
key,
position.latitude,
position.longitude,
count,
memberIds.sorted(),
bounds.southwest.latitude,
bounds.southwest.longitude,
bounds.northeast.latitude,
bounds.northeast.longitude,
)
}
}

private fun renderSignature(vararg parts: Any?): Long {
var hash = -3750763034362895579L
for (part in parts) {
hash = 1099511628211L * hash + (part?.hashCode()?.toLong() ?: 0L)
}
return hash
}

/**
Expand Down
Loading