From 4f3a51df0ed093791e95b5ddd8f25a6babbac431 Mon Sep 17 00:00:00 2001
From: Piotr Graczyk
Date: Sun, 28 Jun 2026 10:13:13 +0200
Subject: [PATCH 1/3] feat: add native overlay entering animations
---
CONTEXT.md | 12 ++
README.md | 60 ++++++--
...0003-native-overlay-entering-animations.md | 19 +++
docs/architecture.md | 4 +
docs/roadmap.md | 1 +
example/App.tsx | 82 ++++++++---
example/examples/landmarks.ts | 1 +
.../nitromaps/GoogleMapProviderAdapter.kt | 29 ++++
.../margelo/nitro/nitromaps/HybridMapView.kt | 20 +++
.../nitro/nitromaps/MapOverlayController.kt | 137 +++++++++++++++---
.../nitro/nitromaps/MapProviderAdapter.kt | 2 +
.../nitro/nitromaps/MarkerClusterEngine.kt | 2 +-
.../nitro/nitromaps/MarkerViewportFilter.kt | 1 +
.../nitromaps/OverlayEnteringAnimation.kt | 67 +++++++++
package/ios/AppleMapProviderAdapter.swift | 14 ++
package/ios/GoogleMapOverlayController.swift | 19 +++
package/ios/GoogleMapProviderAdapter.swift | 14 ++
package/ios/HybridMapView.swift | 22 +++
package/ios/HybridMapViewDelegate.swift | 35 +++--
package/ios/MapClusterAnnotation.swift | 5 +-
package/ios/MapMarkerAnnotation.swift | 7 +-
package/ios/MapOverlayController.swift | 8 +-
package/ios/MapProviderAdapter.swift | 2 +
package/ios/MarkerClusterEngine.swift | 16 +-
package/ios/MarkerViewportFilter.swift | 4 +
package/ios/NitroPinAnnotationView.swift | 6 +-
package/ios/OverlayEnteringAnimation.swift | 133 +++++++++++++++++
package/src/components/MapView.tsx | 16 +-
package/src/hooks/useCollectedOverlays.ts | 2 +
package/src/index.ts | 5 +
package/src/native/specs/MapView.nitro.ts | 7 +
package/src/native/specs/overlays.ts | 28 ++++
package/src/types/index.ts | 5 +
package/src/types/map.ts | 16 +-
package/src/types/overlays.ts | 52 +++++++
package/src/utils/enteringAnimation.ts | 45 ++++++
package/type-tests/provider-props.ts | 24 +++
37 files changed, 840 insertions(+), 82 deletions(-)
create mode 100644 docs/adr/0003-native-overlay-entering-animations.md
create mode 100644 package/android/src/main/java/com/margelo/nitro/nitromaps/OverlayEnteringAnimation.kt
create mode 100644 package/ios/OverlayEnteringAnimation.swift
create mode 100644 package/src/utils/enteringAnimation.ts
diff --git a/CONTEXT.md b/CONTEXT.md
index 2716e4c..1ec3c7c 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -11,3 +11,15 @@ _Avoid_: Tile source, geocoding provider
**Google Map ID**:
A Google Cloud Map ID used by the Google Maps SDK to apply cloud-based map styling. It is supported only by the `google` map provider and is distinct from the Google Maps API key required to load the SDK.
_Avoid_: API key, style JSON
+
+**Marker**:
+A point annotation rendered at a geographic coordinate on the map.
+_Avoid_: Pin
+
+**Marker Cluster**:
+A grouped marker representation shown when nearby clusterable markers collapse into one map annotation.
+_Avoid_: Cluster pin, marker group
+
+**Entering Animation**:
+The visual transition used when a marker or marker cluster first appears on the map.
+_Avoid_: Appear animation, spawn animation
diff --git a/README.md b/README.md
index 9865a00..6fa6d74 100644
--- a/README.md
+++ b/README.md
@@ -140,24 +140,50 @@ The `google` provider also accepts `googleMapId` for Google Cloud Map ID styling
`googleMapId` is creation-time configuration for native SDK views. Changing it remounts the native map view, matching provider changes.
+### Marker entering animations
+
+`MapView` can configure native entering animations for markers and marker clusters:
+
+```tsx
+
+
+
+```
+
+`markerEnteringAnimation` is the map-level default for all markers, including bulk `markers` descriptors. `Marker.enteringAnimation` and bulk marker `enteringAnimation` override that default for one marker; `false` is an explicit opt-out. `clusterEnteringAnimation` applies to marker clusters when clustering is enabled.
+
+When no animation prop is set, the default is `system`: each provider keeps its native entering behavior. Explicit presets (`fade`, `fade-scale`) are the cross-provider contract. `fade-scale` may gracefully fall back to `fade` on SDK marker surfaces that do not support efficient scaling.
+
+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.
+
### Capability matrix
-| Capability | `apple` iOS | `google` iOS | `google` Android | Future providers |
-| -------------------- | ----------------------------------------------------------- | ------------------------------------------ | ------------------------------------------ | -------------------- |
-| Region / camera | Supported | Supported | Supported | Planned |
-| Camera animation | Supported | Supported | Supported | Planned |
-| Visible region | Supported | Supported | Supported | Planned |
-| Fit to coordinates | Supported | Supported | Supported | Planned |
-| Map types | Standard, satellite, hybrid; terrain falls back to standard | Standard, satellite, hybrid, terrain | Standard, satellite, hybrid, terrain | Planned |
-| Gestures | Supported | Supported | Supported | Planned |
-| User location | Supported; host app owns permission prompt | Supported; host app owns permission prompt | Supported; host app owns permission prompt | Planned |
-| Compass | Supported | Supported | Supported | Planned |
-| Scale control | Supported | Unsupported | Unsupported | Planned per provider |
-| Markers / overlays | Supported | Supported | Supported | Planned |
-| Overlay press events | Supported | Supported | Supported | Planned |
-| Clustering | Supported | Supported | Supported | Planned per provider |
-| Custom styles | Curated subset on iOS 16+ | Google Maps JSON styles | Google Maps JSON styles | Planned per provider |
-| Google Map ID | Unsupported | Supported | Supported | Planned per provider |
+| Capability | `apple` iOS | `google` iOS | `google` Android | Future providers |
+| -------------------------- | ----------------------------------------------------------- | ------------------------------------------ | ------------------------------------------ | -------------------- |
+| Region / camera | Supported | Supported | Supported | Planned |
+| Camera animation | Supported | Supported | Supported | Planned |
+| Visible region | Supported | Supported | Supported | Planned |
+| Fit to coordinates | Supported | Supported | Supported | Planned |
+| Map types | Standard, satellite, hybrid; terrain falls back to standard | Standard, satellite, hybrid, terrain | Standard, satellite, hybrid, terrain | Planned |
+| Gestures | Supported | Supported | Supported | Planned |
+| User location | Supported; host app owns permission prompt | Supported; host app owns permission prompt | Supported; host app owns permission prompt | Planned |
+| Compass | Supported | Supported | Supported | Planned |
+| Scale control | Supported | Unsupported | Unsupported | Planned per provider |
+| Markers / overlays | Supported | Supported | Supported | Planned |
+| Overlay press events | Supported | Supported | Supported | Planned |
+| Marker entering animation | System + `fade`, `fade-scale` | System + `fade`; scale fallback | System + `fade`; scale fallback | Planned per provider |
+| Cluster entering animation | System + `fade`, `fade-scale` | System + `fade`; scale fallback | System + `fade`; scale fallback | Planned per provider |
+| Clustering | Supported | Supported | Supported | Planned per provider |
+| Custom styles | Curated subset on iOS 16+ | Google Maps JSON styles | Google Maps JSON styles | Planned per provider |
+| Google Map ID | Unsupported | Supported | Supported | Planned per provider |
## Public API
@@ -183,7 +209,9 @@ The `google` provider also accepts `googleMapId` for Google Cloud Map ID styling
| `MapViewRef` | Imperative handle for camera control |
| `MapViewProps` | Props for `MapView` |
| `MapViewPropsForProvider` | Provider-specific `MapView` props |
+| `MarkerDescriptor` | Bulk marker descriptor |
| `MarkerProps` | Props for `Marker` |
+| `OverlayEnteringAnimation` | Marker / marker-cluster entering animation config |
| `PolylineProps` | Props for `Polyline` |
| `PolygonProps` | Props for `Polygon` |
| `CircleProps` | Props for `Circle` |
diff --git a/docs/adr/0003-native-overlay-entering-animations.md b/docs/adr/0003-native-overlay-entering-animations.md
new file mode 100644
index 0000000..91088e6
--- /dev/null
+++ b/docs/adr/0003-native-overlay-entering-animations.md
@@ -0,0 +1,19 @@
+# ADR 0003: Native overlay entering animations
+
+## Status
+
+Accepted
+
+## Context
+
+Markers and marker clusters are rendered from serialized overlay descriptors, not from React Native child views. Reanimated is a good fit for animating real React Native views, but making it the core path for descriptor-backed map overlays would add an optional ecosystem dependency to the package's default API without matching the current rendering model.
+
+## Decision
+
+Implement marker and marker-cluster entering animations as provider-owned native behavior configured through serializable TypeScript props. The core package does not require Reanimated for these animations; Reanimated integrations can be added later behind optional subpath exports when there is a view-backed animation surface.
+
+## Consequences
+
+- Marker and marker-cluster entering animations work for descriptor and bulk-marker APIs without requiring host apps to install Reanimated.
+- Providers may keep native `system` defaults while explicit presets define the cross-provider contract.
+- Future Reanimated support should be additive and optional, not a replacement for the native descriptor animation path.
diff --git a/docs/architecture.md b/docs/architecture.md
index 1d82ca9..7e8693a 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -81,6 +81,8 @@ Map and overlay callbacks are wired through Nitro listeners on the HybridView. C
| `googleMapId` | Google Cloud Map ID for the `google` provider. It is creation-time SDK configuration, so changing it remounts the native map view. |
| `clusteringEnabled` | Custom grid-based clustering via `MarkerClusterEngine` on both platforms (viewport-aware, background compute). |
| `Marker.clusterable` | Opt-out per marker (defaults to `true`). Non-clusterable markers always render individually. |
+| `markerEnteringAnimation` / `Marker.enteringAnimation` | Native entering animation for newly added marker render elements. Per-marker values override the map-level default; `false` is an explicit opt-out. |
+| `clusterEnteringAnimation` | Native entering animation for newly added marker-cluster render elements. Available only on providers with clustering support. |
| `customMapStyle` | JSON string. The `google` provider uses Google Maps JSON styles on iOS and Android. The `apple` provider maps a curated subset to `MKMapConfiguration` on iOS 16+. |
| `showsUserLocation` / `followsUserLocation` | Toggles the native user-location layer. Host app must request location permission (`NSLocationWhenInUseUsageDescription` on iOS; `ACCESS_FINE_LOCATION` on Android). |
| `showsCompass` / `showsScale` | Compass on both platforms. Scale is iOS-only (`showsScale` is a no-op on Android). |
@@ -99,6 +101,8 @@ Map and overlay callbacks are wired through Nitro listeners on the HybridView. C
`Marker`, `Polyline`, `Polygon`, and `Circle` are overlay components that compose inside `MapView`. Overlay props are collected on the JS side and serialized into descriptor structs passed to the native `HybridMapView` (data-driven architecture).
+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.
+
## Data flow (target state)
```
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 873f0a9..8fb47e1 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -83,6 +83,7 @@ Delivered incrementally during Phases 3–5; polished for platform consistency i
| Overlay architecture | Per-view native vs data-driven descriptors | Data-driven (Option B) |
| Clustering library | Custom vs platform-native | Platform-native (MKClusterAnnotation / maps-utils) |
| Provider architecture | In-place SDK switching vs adapter remount | Provider adapter + React remount |
+| Overlay animations | Reanimated core path vs native descriptors | Native descriptor animations; Reanimated optional |
| Offline support | Tile caching strategy | Future consideration |
## Future provider work
diff --git a/example/App.tsx b/example/App.tsx
index 31cb0d3..6cf9201 100644
--- a/example/App.tsx
+++ b/example/App.tsx
@@ -205,18 +205,26 @@ const ScenarioChip = memo(function ScenarioChip({
onPress,
}: ScenarioChipProps) {
return (
-
+
{index + 1}
{label}
@@ -269,14 +277,14 @@ const ScenarioDock = memo(function ScenarioDock({
return (
{expanded ? (
@@ -288,8 +296,14 @@ const ScenarioDock = memo(function ScenarioDock({
{scenario.name}
-
- ▴
+
+
+ ▴
+
@@ -298,7 +312,10 @@ const ScenarioDock = memo(function ScenarioDock({
-
+
↻
Animate
@@ -316,7 +333,10 @@ const ScenarioDock = memo(function ScenarioDock({
{canCycleProvider ? (
-
+
⌁
{providerLabel}
@@ -343,8 +363,14 @@ const ScenarioDock = memo(function ScenarioDock({
))}
{!expanded ? (
-
- ▴
+
+
+ ▴
+
) : null}
@@ -393,6 +419,10 @@ const MapScene = memo(
showsCompass: scenario.advanced?.showsCompass,
customMapStyle: scenario.advanced?.customMapStyle,
mapPadding,
+ markerEnteringAnimation: { preset: 'fade-scale', duration: 180 } as const,
+ clusterEnteringAnimation: scenario.advanced?.clusteringEnabled
+ ? ({ preset: 'fade', duration: 160 } as const)
+ : undefined,
markers: scenario.markers,
polylines: scenario.polylines,
polygons: scenario.polygons,
@@ -459,7 +489,7 @@ const StatusHeader = memo(function StatusHeader({
return (
@@ -558,14 +588,17 @@ export default function App() {
});
}, [provider]);
- const selectScenario = useCallback((index: number) => {
- if (index === scenarioIndex) {
- return;
- }
- setScenarioIndex(index);
- setMapReady(false);
- setStatus(MAP_SCENARIOS[index].name);
- }, [scenarioIndex]);
+ const selectScenario = useCallback(
+ (index: number) => {
+ if (index === scenarioIndex) {
+ return;
+ }
+ setScenarioIndex(index);
+ setMapReady(false);
+ setStatus(MAP_SCENARIOS[index].name);
+ },
+ [scenarioIndex],
+ );
const toggleDockExpanded = useCallback(() => {
setDockExpanded((current) => !current);
@@ -601,7 +634,10 @@ export default function App() {
setMapReady(true);
setStatus(scenario.name);
- if (scenario.advanced?.fitToCoordinatesOnReady && scenario.markers != null) {
+ if (
+ scenario.advanced?.fitToCoordinatesOnReady &&
+ scenario.markers != null
+ ) {
mapRef.current?.fitToCoordinates(
scenario.markers.map((marker) => marker.coordinate),
mapPadding,
diff --git a/example/examples/landmarks.ts b/example/examples/landmarks.ts
index bc7539e..b0d301e 100644
--- a/example/examples/landmarks.ts
+++ b/example/examples/landmarks.ts
@@ -42,6 +42,7 @@ export const landmarksScenario: MapScenario = {
title: 'Warsaw Uprising Museum',
subtitle: 'Interactive history museum',
draggable: true,
+ enteringAnimation: false,
},
],
};
diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt
index e5cba0a..cec960f 100644
--- a/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt
+++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/GoogleMapProviderAdapter.kt
@@ -180,6 +180,7 @@ class GoogleMapProviderAdapter(
get() = _clusteringEnabled
set(value) {
_clusteringEnabled = value
+ updateOverlayViewportSize()
overlayController.setClusteringEnabled(value == true)
googleMap?.let { map ->
if (value == true) {
@@ -207,6 +208,22 @@ class GoogleMapProviderAdapter(
applyMapPadding()
}
+ private var _markerEnteringAnimation: OverlayEnteringAnimationDescriptor? = null
+ override var markerEnteringAnimation: OverlayEnteringAnimationDescriptor?
+ get() = _markerEnteringAnimation
+ set(value) {
+ _markerEnteringAnimation = value
+ overlayController.markerEnteringAnimation = value
+ }
+
+ private var _clusterEnteringAnimation: OverlayEnteringAnimationDescriptor? = null
+ override var clusterEnteringAnimation: OverlayEnteringAnimationDescriptor?
+ get() = _clusterEnteringAnimation
+ set(value) {
+ _clusterEnteringAnimation = value
+ overlayController.clusterEnteringAnimation = value
+ }
+
override var onRegionChange: ((region: Region) -> Unit)? = null
override var onRegionChangeComplete: ((region: Region) -> Unit)? = null
override var onMapReady: (() -> Unit)? = null
@@ -219,6 +236,7 @@ class GoogleMapProviderAdapter(
set(value) {
_markers = value
if (googleMap != null) {
+ updateOverlayViewportSize()
overlayController.setMarkers(value)
} else {
pendingMarkers = value
@@ -383,6 +401,7 @@ class GoogleMapProviderAdapter(
applyMapPadding(map)
applyCustomMapStyle(map)
overlayController.setGoogleMap(map)
+ updateOverlayViewportSize()
overlayController.setClusteringEnabled(_clusteringEnabled == true)
syncMarkerPressHandlers()
@@ -584,6 +603,7 @@ class GoogleMapProviderAdapter(
private fun runWhenMapViewLaidOut(block: () -> Unit) {
if (view.width > 0 && view.height > 0) {
+ updateOverlayViewportSize()
block()
return
}
@@ -596,12 +616,17 @@ class GoogleMapProviderAdapter(
}
view.viewTreeObserver.removeOnGlobalLayoutListener(this)
+ updateOverlayViewportSize()
block()
}
},
)
}
+ private fun updateOverlayViewportSize() {
+ overlayController.setViewportSize(view.width, view.height)
+ }
+
private fun runOnMain(block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) {
block()
@@ -737,6 +762,10 @@ class GoogleMapProviderAdapter(
_customMapStyle = null
_clusteringEnabled = null
_mapPadding = null
+ _markerEnteringAnimation = null
+ _clusterEnteringAnimation = null
+ overlayController.markerEnteringAnimation = null
+ overlayController.clusterEnteringAnimation = null
googleMap?.mapType = MapType.STANDARD.toGoogleMapType()
googleMap?.isMyLocationEnabled = false
googleMap?.setMapStyle(null)
diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt
index 7448397..60799e3 100644
--- a/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt
+++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/HybridMapView.kt
@@ -32,6 +32,8 @@ class HybridMapView(private val context: ThemedReactContext) :
private var _googleMapId: String? = null
private var _clusteringEnabled: Boolean? = null
private var _mapPadding: EdgePadding? = null
+ private var _markerEnteringAnimation: OverlayEnteringAnimationDescriptor? = null
+ private var _clusterEnteringAnimation: OverlayEnteringAnimationDescriptor? = null
override val view: FrameLayout = FrameLayout(context)
@@ -157,6 +159,20 @@ class HybridMapView(private val context: ThemedReactContext) :
adapter?.mapPadding = value
}
+ override var markerEnteringAnimation: OverlayEnteringAnimationDescriptor?
+ get() = _markerEnteringAnimation
+ set(value) {
+ _markerEnteringAnimation = value
+ adapter?.markerEnteringAnimation = value
+ }
+
+ override var clusterEnteringAnimation: OverlayEnteringAnimationDescriptor?
+ get() = _clusterEnteringAnimation
+ set(value) {
+ _clusterEnteringAnimation = value
+ adapter?.clusterEnteringAnimation = value
+ }
+
override var onRegionChange: ((region: Region) -> Unit)? = null
set(value) {
field = value
@@ -287,6 +303,8 @@ class HybridMapView(private val context: ThemedReactContext) :
_googleMapId = null
_clusteringEnabled = null
_mapPadding = null
+ _markerEnteringAnimation = null
+ _clusterEnteringAnimation = null
onRegionChange = null
onRegionChangeComplete = null
onMapReady = null
@@ -357,6 +375,8 @@ class HybridMapView(private val context: ThemedReactContext) :
adapter.customMapStyle = _customMapStyle
adapter.googleMapId = _googleMapId
adapter.clusteringEnabled = _clusteringEnabled
+ adapter.markerEnteringAnimation = _markerEnteringAnimation
+ adapter.clusterEnteringAnimation = _clusterEnteringAnimation
adapter.onRegionChange = onRegionChange
adapter.onRegionChangeComplete = onRegionChangeComplete
adapter.onMapReady = onMapReady
diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapOverlayController.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapOverlayController.kt
index e7c4417..6278d6c 100644
--- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapOverlayController.kt
+++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapOverlayController.kt
@@ -1,5 +1,7 @@
package com.margelo.nitro.nitromaps
+import android.animation.Animator
+import android.animation.AnimatorListenerAdapter
import android.animation.ValueAnimator
import android.os.Handler
import android.os.Looper
@@ -24,6 +26,7 @@ class MapOverlayController(
private val polylines = LinkedHashMap()
private val polygons = LinkedHashMap()
private val circles = LinkedHashMap()
+ private val markerEnterAnimators = HashMap()
private var clusteringEnabled = false
private var onMarkerPress: ((String) -> Unit)? = null
private var onClusterPress: ((List, Coordinate) -> Unit)? = null
@@ -39,6 +42,9 @@ class MapOverlayController(
private val density: Float = context.resources.displayMetrics.density
private val iconFactory = ClusterIconFactory(density)
+ var markerEnteringAnimation: OverlayEnteringAnimationDescriptor? = null
+ var clusterEnteringAnimation: OverlayEnteringAnimationDescriptor? = null
+
fun setGoogleMap(map: GoogleMap?) {
googleMap = map
if (map == null) {
@@ -70,6 +76,8 @@ class MapOverlayController(
}
fun clear() {
+ markerEnterAnimators.values.toSet().forEach { it.cancel() }
+ markerEnterAnimators.clear()
markers.values.forEach { it.remove() }
polylines.values.forEach { it.remove() }
polygons.values.forEach { it.remove() }
@@ -195,31 +203,41 @@ class MapOverlayController(
val map = googleMap ?: return
for (key in removedKeys) {
+ cancelEnteringAnimation(key)
markers.remove(key)?.remove()
clusterByKey.remove(key)
}
- val addedMarkers = ArrayList(added.size)
+ val addedMarkers = ArrayList(added.size)
for (element in added) {
val key = element.diffKey
when (element) {
is ClusterElement.Single -> {
- map.addMarker(element.descriptor.toMarkerOptions())?.also { marker ->
+ val animation = enteringAnimation(element)
+ val options = element.descriptor.toMarkerOptions()
+ if (OverlayEnteringAnimationResolver.shouldRun(animation)) {
+ options.alpha(0f)
+ }
+ map.addMarker(options)?.also { marker ->
marker.tag = key
markers[key] = marker
- addedMarkers.add(marker)
+ addedMarkers.add(AddedMarker(key, marker, animation))
}
}
is ClusterElement.Cluster -> {
+ val animation = enteringAnimation(element)
val options = MarkerOptions()
.position(element.position)
.icon(iconFactory.icon(element.count))
.anchor(0.5f, 0.5f)
+ if (OverlayEnteringAnimationResolver.shouldRun(animation)) {
+ options.alpha(0f)
+ }
map.addMarker(options)?.also { marker ->
marker.tag = key
markers[key] = marker
clusterByKey[key] = element
- addedMarkers.add(marker)
+ addedMarkers.add(AddedMarker(key, marker, animation))
}
}
}
@@ -228,6 +246,8 @@ class MapOverlayController(
for (element in retained) {
val key = element.diffKey
val marker = markers[key] ?: continue
+ cancelEnteringAnimation(key)
+ marker.alpha = 1f
when (element) {
is ClusterElement.Single -> {
marker.position = LatLng(
@@ -246,31 +266,87 @@ class MapOverlayController(
}
}
- animateFadeIn(addedMarkers)
+ animateEntering(addedMarkers)
}
- /** Fades newly added markers in via a single shared animator. */
- private fun animateFadeIn(added: List) {
+ /** Applies entering animations to newly added markers via a single shared animator. */
+ private fun animateEntering(added: List) {
if (added.isEmpty()) {
return
}
- added.forEach { marker ->
- if (markers.containsValue(marker)) {
- marker.alpha = 0f
+ val animated = added.mapNotNull { addedMarker ->
+ if (!OverlayEnteringAnimationResolver.shouldRun(addedMarker.animation)) {
+ return@mapNotNull null
}
+ cancelEnteringAnimation(addedMarker.key)
+ addedMarker.marker.alpha = 0f
+ addedMarker
}
- ValueAnimator.ofFloat(0f, 1f).apply {
- duration = 220
+
+ if (animated.isEmpty()) {
+ return
+ }
+
+ val startDelayMs = animated.minOf { it.animation.delayMs }
+ val totalDurationMs = animated.maxOf {
+ it.animation.delayMs + it.animation.durationMs
+ } - startDelayMs
+
+ val animator = ValueAnimator.ofFloat(0f, 1f)
+ animator.duration = totalDurationMs
+ animator.startDelay = startDelayMs
+ animator.apply {
addUpdateListener { animator ->
- val value = animator.animatedValue as Float
- added.forEach { marker ->
- if (markers.containsValue(marker)) {
- marker.alpha = value
- }
+ val elapsed = (animator.animatedFraction * duration).toLong()
+ animated.forEach { animatedMarker ->
+ val localElapsed = elapsed - (animatedMarker.animation.delayMs - startDelay)
+ val progress = (localElapsed.toFloat() / animatedMarker.animation.durationMs.toFloat())
+ .coerceIn(0f, 1f)
+ animatedMarker.marker.alpha = progress
+ }
+ }
+ addListener(object : AnimatorListenerAdapter() {
+ override fun onAnimationEnd(animation: Animator) {
+ revealAnimatedMarkers(animated)
+ clearCompletedAnimator(animation, animated)
}
+
+ override fun onAnimationCancel(animation: Animator) {
+ revealAnimatedMarkers(animated)
+ clearCompletedAnimator(animation, animated)
+ }
+ })
+ }
+ animated.forEach { markerEnterAnimators[it.key] = animator }
+ animator.start()
+ }
+
+ private fun revealAnimatedMarkers(animated: List) {
+ animated.forEach { animatedMarker ->
+ animatedMarker.marker.alpha = 1f
+ }
+ }
+
+ private fun cancelEnteringAnimation(key: String) {
+ markerEnterAnimators.remove(key)?.cancel()
+ }
+
+ private fun clearCompletedAnimator(animator: Animator, animated: List) {
+ animated.forEach { animatedMarker ->
+ if (markerEnterAnimators[animatedMarker.key] === animator) {
+ markerEnterAnimators.remove(animatedMarker.key)
}
- start()
+ }
+ }
+
+ private fun enteringAnimation(element: ClusterElement): ResolvedOverlayEnteringAnimation {
+ return when (element) {
+ is ClusterElement.Single -> OverlayEnteringAnimationResolver.resolve(
+ element.descriptor.enteringAnimation,
+ markerEnteringAnimation,
+ )
+ is ClusterElement.Cluster -> OverlayEnteringAnimationResolver.resolve(clusterEnteringAnimation)
}
}
@@ -279,13 +355,26 @@ class MapOverlayController(
reconcile(
current = markers,
next = descriptors.associate { ("s:" + it.id) to it },
- remove = { it.remove() },
+ remove = { marker ->
+ (marker.tag as? String)?.let { cancelEnteringAnimation(it) }
+ marker.remove()
+ },
add = { descriptor ->
- map.addMarker(descriptor.toMarkerOptions())?.also { marker ->
- marker.tag = "s:" + descriptor.id
+ val element = ClusterElement.Single(descriptor)
+ val key = "s:" + descriptor.id
+ val animation = enteringAnimation(element)
+ val options = descriptor.toMarkerOptions()
+ if (OverlayEnteringAnimationResolver.shouldRun(animation)) {
+ options.alpha(0f)
+ }
+ map.addMarker(options)?.also { marker ->
+ marker.tag = key
+ animateEntering(listOf(AddedMarker(key, marker, animation)))
}
},
update = { marker, descriptor ->
+ (marker.tag as? String)?.let { cancelEnteringAnimation(it) }
+ marker.alpha = 1f
marker.position = LatLng(
descriptor.coordinate.latitude,
descriptor.coordinate.longitude,
@@ -437,4 +526,10 @@ class MapOverlayController(
/** Minimum gap between live recomputes while the camera moves. */
const val LIVE_REFRESH_THROTTLE_MS = 100L
}
+
+ private data class AddedMarker(
+ val key: String,
+ val marker: Marker,
+ val animation: ResolvedOverlayEnteringAnimation,
+ )
}
diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt
index a9ff4d4..4f171e6 100644
--- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt
+++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MapProviderAdapter.kt
@@ -21,6 +21,8 @@ interface MapProviderAdapter {
var googleMapId: String?
var clusteringEnabled: Boolean?
var mapPadding: EdgePadding?
+ var markerEnteringAnimation: OverlayEnteringAnimationDescriptor?
+ var clusterEnteringAnimation: OverlayEnteringAnimationDescriptor?
var onRegionChange: ((region: Region) -> Unit)?
var onRegionChangeComplete: ((region: Region) -> Unit)?
diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerClusterEngine.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerClusterEngine.kt
index 503ebdc..62b9c48 100644
--- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerClusterEngine.kt
+++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerClusterEngine.kt
@@ -22,7 +22,7 @@ internal sealed interface ClusterElement {
val memberIds: List,
val bounds: LatLngBounds,
) : ClusterElement {
- override val diffKey: String get() = "c:$key:$count"
+ override val diffKey: String get() = "c:$key"
}
}
diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerViewportFilter.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerViewportFilter.kt
index 89d8122..989a08f 100644
--- a/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerViewportFilter.kt
+++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerViewportFilter.kt
@@ -49,6 +49,7 @@ internal object MarkerViewportFilter {
hash = 31 * hash + (descriptor.subtitle?.hashCode() ?: 0)
hash = 31 * hash + (descriptor.draggable?.hashCode() ?: 0)
hash = 31 * hash + (descriptor.clusterable?.hashCode() ?: 0)
+ hash = 31 * hash + (descriptor.enteringAnimation?.hashCode() ?: 0)
}
return hash
}
diff --git a/package/android/src/main/java/com/margelo/nitro/nitromaps/OverlayEnteringAnimation.kt b/package/android/src/main/java/com/margelo/nitro/nitromaps/OverlayEnteringAnimation.kt
new file mode 100644
index 0000000..3b0c1ab
--- /dev/null
+++ b/package/android/src/main/java/com/margelo/nitro/nitromaps/OverlayEnteringAnimation.kt
@@ -0,0 +1,67 @@
+package com.margelo.nitro.nitromaps
+
+import android.animation.ValueAnimator
+import android.os.Build
+
+internal enum class ResolvedOverlayEnteringAnimationKind {
+ NONE,
+ SYSTEM,
+ FADE,
+}
+
+internal data class ResolvedOverlayEnteringAnimation(
+ val kind: ResolvedOverlayEnteringAnimationKind,
+ val durationMs: Long,
+ val delayMs: Long,
+ val reduceMotion: OverlayEnteringAnimationReduceMotion,
+)
+
+internal object OverlayEnteringAnimationResolver {
+ fun resolve(
+ animation: OverlayEnteringAnimationDescriptor?,
+ fallback: OverlayEnteringAnimationDescriptor? = null,
+ ): ResolvedOverlayEnteringAnimation {
+ val descriptor = animation ?: fallback
+ val kind = when (descriptor?.kind) {
+ OverlayEnteringAnimationKind.NONE -> ResolvedOverlayEnteringAnimationKind.NONE
+ OverlayEnteringAnimationKind.FADE -> ResolvedOverlayEnteringAnimationKind.FADE
+ OverlayEnteringAnimationKind.FADE_SCALE -> ResolvedOverlayEnteringAnimationKind.FADE
+ OverlayEnteringAnimationKind.SYSTEM,
+ null,
+ -> ResolvedOverlayEnteringAnimationKind.SYSTEM
+ }
+
+ return ResolvedOverlayEnteringAnimation(
+ kind = kind,
+ durationMs = milliseconds(descriptor?.duration, DEFAULT_DURATION_MS),
+ delayMs = milliseconds(descriptor?.delay, 0),
+ reduceMotion = descriptor?.reduceMotion ?: OverlayEnteringAnimationReduceMotion.SYSTEM,
+ )
+ }
+
+ fun shouldRun(animation: ResolvedOverlayEnteringAnimation): Boolean {
+ if (
+ animation.kind == ResolvedOverlayEnteringAnimationKind.NONE ||
+ animation.kind == ResolvedOverlayEnteringAnimationKind.SYSTEM
+ ) {
+ return false
+ }
+ if (
+ animation.reduceMotion == OverlayEnteringAnimationReduceMotion.SYSTEM &&
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
+ !ValueAnimator.areAnimatorsEnabled()
+ ) {
+ return false
+ }
+ return animation.durationMs > 0
+ }
+
+ private fun milliseconds(value: Double?, fallback: Long): Long {
+ if (value == null || !value.isFinite()) {
+ return fallback
+ }
+ return value.coerceIn(0.0, 3000.0).toLong()
+ }
+
+ private const val DEFAULT_DURATION_MS = 180L
+}
diff --git a/package/ios/AppleMapProviderAdapter.swift b/package/ios/AppleMapProviderAdapter.swift
index e362d84..94fc17d 100644
--- a/package/ios/AppleMapProviderAdapter.swift
+++ b/package/ios/AppleMapProviderAdapter.swift
@@ -134,6 +134,18 @@ final class AppleMapProviderAdapter: MapProviderAdapter {
}
}
+ var markerEnteringAnimation: OverlayEnteringAnimationDescriptor? {
+ didSet {
+ overlayController.markerEnteringAnimation = markerEnteringAnimation
+ }
+ }
+
+ var clusterEnteringAnimation: OverlayEnteringAnimationDescriptor? {
+ didSet {
+ overlayController.clusterEnteringAnimation = clusterEnteringAnimation
+ }
+ }
+
var onRegionChange: ((Region) -> Void)?
var onRegionChangeComplete: ((Region) -> Void)?
var onMapReady: (() -> Void)? {
@@ -430,6 +442,8 @@ final class AppleMapProviderAdapter: MapProviderAdapter {
googleMapId = nil
clusteringEnabled = nil
mapPadding = nil
+ markerEnteringAnimation = nil
+ clusterEnteringAnimation = nil
view.mapType = .standard
view.isScrollEnabled = true
view.isZoomEnabled = true
diff --git a/package/ios/GoogleMapOverlayController.swift b/package/ios/GoogleMapOverlayController.swift
index 26c47f3..815e3be 100644
--- a/package/ios/GoogleMapOverlayController.swift
+++ b/package/ios/GoogleMapOverlayController.swift
@@ -26,6 +26,8 @@ final class GoogleMapOverlayController {
var onCirclePress: ((String) -> Void)?
var onClusterPress: (([String], Coordinate) -> Void)?
var animateToClusterRegion: ((MKCoordinateRegion) -> Void)?
+ var markerEnteringAnimation: OverlayEnteringAnimationDescriptor?
+ var clusterEnteringAnimation: OverlayEnteringAnimationDescriptor?
init(mapView: GMSMapView) {
self.mapView = mapView
@@ -183,7 +185,10 @@ final class GoogleMapOverlayController {
for entry in diff.added {
let marker = makeMarker(for: entry.element)
+ let animation = enteringAnimation(for: entry.element)
+ OverlayEnteringAnimationResolver.prepareGoogleMarker(marker, animation: animation)
marker.map = mapView
+ OverlayEnteringAnimationResolver.animateGoogleMarker(marker, animation: animation)
markers[entry.key] = marker
markerVersions[entry.key] = entry.version
}
@@ -203,6 +208,20 @@ final class GoogleMapOverlayController {
return marker
}
+ private func enteringAnimation(
+ for element: MarkerClusterEngine.Element
+ ) -> ResolvedOverlayEnteringAnimation {
+ switch element {
+ case let .single(descriptor):
+ return OverlayEnteringAnimationResolver.resolve(
+ descriptor.enteringAnimation,
+ fallback: markerEnteringAnimation
+ )
+ case .cluster:
+ return OverlayEnteringAnimationResolver.resolve(clusterEnteringAnimation)
+ }
+ }
+
private func updateMarker(_ marker: GMSMarker, with element: MarkerClusterEngine.Element) {
switch element {
case let .single(descriptor):
diff --git a/package/ios/GoogleMapProviderAdapter.swift b/package/ios/GoogleMapProviderAdapter.swift
index 8b275e1..7c2f9f0 100644
--- a/package/ios/GoogleMapProviderAdapter.swift
+++ b/package/ios/GoogleMapProviderAdapter.swift
@@ -155,6 +155,18 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter {
}
}
+ var markerEnteringAnimation: OverlayEnteringAnimationDescriptor? {
+ didSet {
+ overlayController.markerEnteringAnimation = markerEnteringAnimation
+ }
+ }
+
+ var clusterEnteringAnimation: OverlayEnteringAnimationDescriptor? {
+ didSet {
+ overlayController.clusterEnteringAnimation = clusterEnteringAnimation
+ }
+ }
+
var onRegionChange: ((Region) -> Void)?
var onRegionChangeComplete: ((Region) -> Void)?
var onMapReady: (() -> Void)? {
@@ -281,6 +293,8 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter {
googleMapId = nil
clusteringEnabled = nil
mapPadding = nil
+ markerEnteringAnimation = nil
+ clusterEnteringAnimation = nil
}
private func applyRegion(_ region: Region, animated: Bool = false) {
diff --git a/package/ios/HybridMapView.swift b/package/ios/HybridMapView.swift
index 99f1ff1..b2afbb7 100644
--- a/package/ios/HybridMapView.swift
+++ b/package/ios/HybridMapView.swift
@@ -21,6 +21,8 @@ final class HybridMapView: HybridMapViewSpec {
private var _googleMapId: String?
private var _clusteringEnabled: Bool?
private var _mapPadding: EdgePadding?
+ private var _markerEnteringAnimation: OverlayEnteringAnimationDescriptor?
+ private var _clusterEnteringAnimation: OverlayEnteringAnimationDescriptor?
lazy var view: UIView = {
containerView
@@ -165,6 +167,22 @@ final class HybridMapView: HybridMapViewSpec {
}
}
+ var markerEnteringAnimation: OverlayEnteringAnimationDescriptor? {
+ get { _markerEnteringAnimation }
+ set {
+ _markerEnteringAnimation = newValue
+ adapter?.markerEnteringAnimation = newValue
+ }
+ }
+
+ var clusterEnteringAnimation: OverlayEnteringAnimationDescriptor? {
+ get { _clusterEnteringAnimation }
+ set {
+ _clusterEnteringAnimation = newValue
+ adapter?.clusterEnteringAnimation = newValue
+ }
+ }
+
var onRegionChange: ((Region) -> Void)? {
didSet { adapter?.onRegionChange = onRegionChange }
}
@@ -273,6 +291,8 @@ final class HybridMapView: HybridMapViewSpec {
_googleMapId = nil
_clusteringEnabled = nil
_mapPadding = nil
+ _markerEnteringAnimation = nil
+ _clusterEnteringAnimation = nil
markers = nil
polylines = nil
polygons = nil
@@ -347,6 +367,8 @@ final class HybridMapView: HybridMapViewSpec {
adapter.googleMapId = _googleMapId
adapter.clusteringEnabled = _clusteringEnabled
adapter.mapPadding = _mapPadding
+ adapter.markerEnteringAnimation = _markerEnteringAnimation
+ adapter.clusterEnteringAnimation = _clusterEnteringAnimation
adapter.onRegionChange = onRegionChange
adapter.onRegionChangeComplete = onRegionChangeComplete
adapter.onMapReady = onMapReady
diff --git a/package/ios/HybridMapViewDelegate.swift b/package/ios/HybridMapViewDelegate.swift
index e736f6d..7eb4b2d 100644
--- a/package/ios/HybridMapViewDelegate.swift
+++ b/package/ios/HybridMapViewDelegate.swift
@@ -119,17 +119,30 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni
func mapView(_ mapView: MKMapView, didAdd views: [MKAnnotationView]) {
for view in views {
- // MKMarkerAnnotationView handles its own drop animation; only fade in cluster badges.
- guard view.annotation is MapClusterAnnotation else {
- continue
- }
- view.alpha = 0
- UIView.animate(
- withDuration: 0.16,
- delay: 0,
- options: [.allowUserInteraction, .beginFromCurrentState, .curveEaseOut]
- ) {
- view.alpha = 1
+ if let marker = view.annotation as? MapMarkerAnnotation,
+ marker.enteringAnimation.kind != .system {
+ OverlayEnteringAnimationResolver.animateAnnotationView(
+ view,
+ animation: marker.enteringAnimation,
+ supportsScale: true
+ )
+ } else if let cluster = view.annotation as? MapClusterAnnotation {
+ let animation: ResolvedOverlayEnteringAnimation
+ if cluster.enteringAnimation.kind == .system {
+ animation = ResolvedOverlayEnteringAnimation(
+ kind: .fade,
+ duration: 0.16,
+ delay: 0,
+ reduceMotion: .system
+ )
+ } else {
+ animation = cluster.enteringAnimation
+ }
+ OverlayEnteringAnimationResolver.animateAnnotationView(
+ view,
+ animation: animation,
+ supportsScale: true
+ )
}
}
}
diff --git a/package/ios/MapClusterAnnotation.swift b/package/ios/MapClusterAnnotation.swift
index 968386f..2bee883 100644
--- a/package/ios/MapClusterAnnotation.swift
+++ b/package/ios/MapClusterAnnotation.swift
@@ -10,6 +10,7 @@ final class MapClusterAnnotation: NSObject, MKAnnotation {
var memberIds: [String]
/// Region that frames this cluster's members, used for tap-to-zoom.
var region: MKCoordinateRegion
+ let enteringAnimation: ResolvedOverlayEnteringAnimation
@objc dynamic var coordinate: CLLocationCoordinate2D
@@ -18,13 +19,15 @@ final class MapClusterAnnotation: NSObject, MKAnnotation {
coordinate: CLLocationCoordinate2D,
count: Int,
memberIds: [String],
- region: MKCoordinateRegion
+ region: MKCoordinateRegion,
+ enteringAnimation: ResolvedOverlayEnteringAnimation
) {
self.id = id
self.coordinate = coordinate
self.count = count
self.memberIds = memberIds
self.region = region
+ self.enteringAnimation = enteringAnimation
}
func update(
diff --git a/package/ios/MapMarkerAnnotation.swift b/package/ios/MapMarkerAnnotation.swift
index 0934364..6a2b676 100644
--- a/package/ios/MapMarkerAnnotation.swift
+++ b/package/ios/MapMarkerAnnotation.swift
@@ -5,13 +5,18 @@ final class MapMarkerAnnotation: NSObject, MKAnnotation {
let id: String
let draggable: Bool
let isClusterable: Bool
+ let enteringAnimation: ResolvedOverlayEnteringAnimation
@objc dynamic var coordinate: CLLocationCoordinate2D
@objc dynamic var title: String?
@objc dynamic var subtitle: String?
- init(descriptor: MarkerDescriptor) {
+ init(
+ descriptor: MarkerDescriptor,
+ enteringAnimation: ResolvedOverlayEnteringAnimation
+ ) {
id = descriptor.id
+ self.enteringAnimation = enteringAnimation
coordinate = CLLocationCoordinate2D(
latitude: descriptor.coordinate.latitude,
longitude: descriptor.coordinate.longitude
diff --git a/package/ios/MapOverlayController.swift b/package/ios/MapOverlayController.swift
index 2fb7609..f1810d2 100644
--- a/package/ios/MapOverlayController.swift
+++ b/package/ios/MapOverlayController.swift
@@ -26,6 +26,9 @@ final class MapOverlayController {
private var shapeOverlays: [String: MKOverlay] = [:]
private var overlayStyles: [ObjectIdentifier: OverlayStyle] = [:]
+ var markerEnteringAnimation: OverlayEnteringAnimationDescriptor?
+ var clusterEnteringAnimation: OverlayEnteringAnimationDescriptor?
+
init(mapView: MKMapView) {
self.mapView = mapView
}
@@ -126,7 +129,10 @@ final class MapOverlayController {
var annotations: [MKAnnotation] = []
annotations.reserveCapacity(diff.added.count)
for entry in diff.added {
- let annotation = entry.element.makeAnnotation()
+ let annotation = entry.element.makeAnnotation(
+ markerEnteringAnimation: markerEnteringAnimation,
+ clusterEnteringAnimation: clusterEnteringAnimation
+ )
displayedAnnotations[entry.key] = annotation
displayedAnnotationVersions[entry.key] = entry.version
annotations.append(annotation)
diff --git a/package/ios/MapProviderAdapter.swift b/package/ios/MapProviderAdapter.swift
index 06c8fb1..d91aeb8 100644
--- a/package/ios/MapProviderAdapter.swift
+++ b/package/ios/MapProviderAdapter.swift
@@ -19,6 +19,8 @@ protocol MapProviderAdapter: AnyObject {
var googleMapId: String? { get set }
var clusteringEnabled: Bool? { get set }
var mapPadding: EdgePadding? { get set }
+ var markerEnteringAnimation: OverlayEnteringAnimationDescriptor? { get set }
+ var clusterEnteringAnimation: OverlayEnteringAnimationDescriptor? { get set }
var onRegionChange: ((Region) -> Void)? { get set }
var onRegionChangeComplete: ((Region) -> Void)? { get set }
diff --git a/package/ios/MarkerClusterEngine.swift b/package/ios/MarkerClusterEngine.swift
index 328a210..ab04492 100644
--- a/package/ios/MarkerClusterEngine.swift
+++ b/package/ios/MarkerClusterEngine.swift
@@ -57,17 +57,27 @@ enum MarkerClusterEngine {
return hasher.finalize()
}
- func makeAnnotation() -> MKAnnotation {
+ func makeAnnotation(
+ markerEnteringAnimation: OverlayEnteringAnimationDescriptor?,
+ clusterEnteringAnimation: OverlayEnteringAnimationDescriptor?
+ ) -> MKAnnotation {
switch self {
case let .single(descriptor):
- return MapMarkerAnnotation(descriptor: descriptor)
+ return MapMarkerAnnotation(
+ descriptor: descriptor,
+ enteringAnimation: OverlayEnteringAnimationResolver.resolve(
+ descriptor.enteringAnimation,
+ fallback: markerEnteringAnimation
+ )
+ )
case let .cluster(key, coordinate, count, memberIds, region):
return MapClusterAnnotation(
id: key,
coordinate: coordinate,
count: count,
memberIds: memberIds,
- region: region
+ region: region,
+ enteringAnimation: OverlayEnteringAnimationResolver.resolve(clusterEnteringAnimation)
)
}
}
diff --git a/package/ios/MarkerViewportFilter.swift b/package/ios/MarkerViewportFilter.swift
index ba66eac..be62e31 100644
--- a/package/ios/MarkerViewportFilter.swift
+++ b/package/ios/MarkerViewportFilter.swift
@@ -36,6 +36,10 @@ enum MarkerViewportFilter {
hasher.combine(marker.subtitle)
hasher.combine(marker.draggable)
hasher.combine(marker.clusterable)
+ hasher.combine(marker.enteringAnimation?.kind.stringValue)
+ hasher.combine(marker.enteringAnimation?.duration)
+ hasher.combine(marker.enteringAnimation?.delay)
+ hasher.combine(marker.enteringAnimation?.reduceMotion?.stringValue)
}
return hasher.finalize()
}
diff --git a/package/ios/NitroPinAnnotationView.swift b/package/ios/NitroPinAnnotationView.swift
index db5283b..0bea7eb 100644
--- a/package/ios/NitroPinAnnotationView.swift
+++ b/package/ios/NitroPinAnnotationView.swift
@@ -1,7 +1,7 @@
import MapKit
import UIKit
-/// Native MapKit marker pin — drop animation, selection bounce, and system styling.
+/// Native MapKit marker view with system insertion animation, selection bounce, and styling.
final class NitroPinAnnotationView: MKMarkerAnnotationView {
static let reuseIdentifier = "NitroPin"
@@ -19,7 +19,11 @@ final class NitroPinAnnotationView: MKMarkerAnnotationView {
}
func configure(for marker: MapMarkerAnnotation) {
+ layer.removeAllAnimations()
+ alpha = 1
+ transform = .identity
annotation = marker
+ animatesWhenAdded = marker.enteringAnimation.kind == .system
isDraggable = marker.draggable
canShowCallout = marker.title != nil || marker.subtitle != nil
displayPriority = .required
diff --git a/package/ios/OverlayEnteringAnimation.swift b/package/ios/OverlayEnteringAnimation.swift
new file mode 100644
index 0000000..73f93c7
--- /dev/null
+++ b/package/ios/OverlayEnteringAnimation.swift
@@ -0,0 +1,133 @@
+import GoogleMaps
+import QuartzCore
+import UIKit
+
+enum ResolvedOverlayEnteringAnimationKind {
+ case none
+ case system
+ case fade
+ case fadeScale
+}
+
+struct ResolvedOverlayEnteringAnimation {
+ let kind: ResolvedOverlayEnteringAnimationKind
+ let duration: TimeInterval
+ let delay: TimeInterval
+ let reduceMotion: OverlayEnteringAnimationReduceMotion
+}
+
+enum OverlayEnteringAnimationResolver {
+ static func resolve(
+ _ animation: OverlayEnteringAnimationDescriptor?,
+ fallback: OverlayEnteringAnimationDescriptor? = nil
+ ) -> ResolvedOverlayEnteringAnimation {
+ let descriptor = animation ?? fallback
+ let kind: ResolvedOverlayEnteringAnimationKind
+ switch descriptor?.kind {
+ case .some(.none):
+ kind = .none
+ case .some(.fade):
+ kind = .fade
+ case .some(.fadeScale):
+ kind = .fadeScale
+ case .some(.system), nil:
+ kind = .system
+ }
+
+ return ResolvedOverlayEnteringAnimation(
+ kind: kind,
+ duration: seconds(fromMilliseconds: descriptor?.duration, defaultMilliseconds: 180),
+ delay: seconds(fromMilliseconds: descriptor?.delay, defaultMilliseconds: 0),
+ reduceMotion: descriptor?.reduceMotion ?? .system
+ )
+ }
+
+ static func shouldRun(_ animation: ResolvedOverlayEnteringAnimation) -> Bool {
+ guard animation.kind != .none else {
+ return false
+ }
+ guard animation.reduceMotion != .system || !UIAccessibility.isReduceMotionEnabled else {
+ return false
+ }
+ return animation.duration > 0
+ }
+
+ static func animateAnnotationView(
+ _ view: UIView,
+ animation: ResolvedOverlayEnteringAnimation,
+ supportsScale: Bool
+ ) {
+ guard shouldRun(animation) else {
+ return
+ }
+
+ let shouldScale = animation.kind == .fadeScale && supportsScale
+ view.alpha = 0
+ if shouldScale {
+ view.transform = CGAffineTransform(scaleX: 0.85, y: 0.85)
+ }
+
+ UIView.animate(
+ withDuration: animation.duration,
+ delay: animation.delay,
+ options: [.allowUserInteraction, .beginFromCurrentState, .curveEaseOut]
+ ) {
+ view.alpha = 1
+ if shouldScale {
+ view.transform = .identity
+ }
+ }
+ }
+
+ static func prepareGoogleMarker(_ marker: GMSMarker, animation: ResolvedOverlayEnteringAnimation) {
+ marker.appearAnimation = .none
+ let markerAnimation = googleMarkerAnimation(animation)
+ guard shouldRun(markerAnimation) else {
+ return
+ }
+ guard markerAnimation.kind != .system else {
+ marker.appearAnimation = .pop
+ return
+ }
+ marker.opacity = 0
+ }
+
+ static func animateGoogleMarker(_ marker: GMSMarker, animation: ResolvedOverlayEnteringAnimation) {
+ let markerAnimation = googleMarkerAnimation(animation)
+ guard shouldRun(markerAnimation), markerAnimation.kind != .system else {
+ return
+ }
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + markerAnimation.delay) {
+ CATransaction.begin()
+ CATransaction.setAnimationDuration(markerAnimation.duration)
+ CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeOut))
+ marker.opacity = 1
+ CATransaction.commit()
+ }
+ }
+
+ private static func googleMarkerAnimation(
+ _ animation: ResolvedOverlayEnteringAnimation
+ ) -> ResolvedOverlayEnteringAnimation {
+ guard animation.kind == .fadeScale else {
+ return animation
+ }
+ return ResolvedOverlayEnteringAnimation(
+ kind: .fade,
+ duration: animation.duration,
+ delay: animation.delay,
+ reduceMotion: animation.reduceMotion
+ )
+ }
+
+ private static func seconds(
+ fromMilliseconds value: Double?,
+ defaultMilliseconds: Double
+ ) -> TimeInterval {
+ guard let value, value.isFinite else {
+ return defaultMilliseconds / 1000
+ }
+ return min(3000, max(0, value)) / 1000
+ }
+}
diff --git a/package/src/components/MapView.tsx b/package/src/components/MapView.tsx
index ee0ef52..79712da 100644
--- a/package/src/components/MapView.tsx
+++ b/package/src/components/MapView.tsx
@@ -1,4 +1,4 @@
-import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
+import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from 'react';
import { callback } from 'react-native-nitro-modules';
import { useCollectedOverlays } from '../hooks/useCollectedOverlays';
import { NativeMapView } from '../native/MapViewNative';
@@ -8,6 +8,10 @@ import { resolveMapProvider } from '../providers';
import type { Coordinate } from '../types/coordinate';
import type { MapViewProps } from '../types/map';
import type { MapViewRef } from '../types/ref';
+import {
+ normalizeEnteringAnimation,
+ normalizeMarkerDescriptor,
+} from '../utils/enteringAnimation';
export const MapView = forwardRef(function MapView(
{
@@ -29,6 +33,8 @@ export const MapView = forwardRef(function MapView(
customMapStyle,
clusteringEnabled,
mapPadding,
+ markerEnteringAnimation,
+ clusterEnteringAnimation,
markers: markersProp,
polylines: polylinesProp,
polygons: polygonsProp,
@@ -61,9 +67,13 @@ export const MapView = forwardRef(function MapView(
hasPolygonPress,
hasCirclePress,
} = useCollectedOverlays(children);
+ const normalizedMarkersProp = useMemo(
+ () => markersProp?.map(normalizeMarkerDescriptor),
+ [markersProp],
+ );
const markers =
- markersProp != null ? markersProp : collectedMarkers;
+ normalizedMarkersProp != null ? normalizedMarkersProp : collectedMarkers;
const polylines =
polylinesProp != null ? polylinesProp : collectedPolylines;
const polygons =
@@ -173,6 +183,8 @@ export const MapView = forwardRef(function MapView(
customMapStyle={customMapStyle}
clusteringEnabled={clusteringEnabled}
mapPadding={mapPadding}
+ markerEnteringAnimation={normalizeEnteringAnimation(markerEnteringAnimation)}
+ clusterEnteringAnimation={normalizeEnteringAnimation(clusterEnteringAnimation)}
markers={markers}
polylines={polylines}
polygons={polygons}
diff --git a/package/src/hooks/useCollectedOverlays.ts b/package/src/hooks/useCollectedOverlays.ts
index 32c8788..e4125fc 100644
--- a/package/src/hooks/useCollectedOverlays.ts
+++ b/package/src/hooks/useCollectedOverlays.ts
@@ -18,6 +18,7 @@ import { Polygon } from '../components/Polygon';
import { Polyline } from '../components/Polyline';
import type { OverlayComponentType, OverlayTypeName } from '../overlays/overlayType';
import { OverlayType, overlayCallbackKey } from '../overlays/overlayType';
+import { normalizeEnteringAnimation } from '../utils/enteringAnimation';
interface OverlayCallbacks {
onPress?: () => void;
@@ -110,6 +111,7 @@ const overlayCollectors: OverlayCollector[] = [
subtitle: props.subtitle,
draggable: props.draggable,
clusterable: props.clusterable,
+ enteringAnimation: normalizeEnteringAnimation(props.enteringAnimation),
});
state.registry.set(overlayCallbackKey(OverlayType.Marker, id), {
onPress: props.onPress,
diff --git a/package/src/index.ts b/package/src/index.ts
index 0a997fa..5145307 100644
--- a/package/src/index.ts
+++ b/package/src/index.ts
@@ -10,7 +10,12 @@ export type {
MapType,
MapViewProps,
MapViewPropsForProvider,
+ MarkerDescriptor,
MarkerProps,
+ OverlayEnteringAnimation,
+ OverlayEnteringAnimationConfig,
+ OverlayEnteringAnimationPreset,
+ OverlayEnteringAnimationReduceMotion,
PolylineProps,
PolygonProps,
CircleProps,
diff --git a/package/src/native/specs/MapView.nitro.ts b/package/src/native/specs/MapView.nitro.ts
index 60bd920..6928625 100644
--- a/package/src/native/specs/MapView.nitro.ts
+++ b/package/src/native/specs/MapView.nitro.ts
@@ -10,6 +10,7 @@ import type { EdgePadding, Region, VisibleRegion } from '../../types/region';
import type {
CircleDescriptor,
MarkerDescriptor,
+ OverlayEnteringAnimationDescriptor,
PolygonDescriptor,
PolylineDescriptor,
} from './overlays';
@@ -72,6 +73,12 @@ export interface MapViewProps extends HybridViewProps {
/** Padding applied to map edges, in density-independent pixels. */
mapPadding?: EdgePadding;
+ /** Default entering animation for marker overlays. */
+ markerEnteringAnimation?: OverlayEnteringAnimationDescriptor;
+
+ /** Entering animation for marker clusters. */
+ clusterEnteringAnimation?: OverlayEnteringAnimationDescriptor;
+
/** Called when the visible region changes during user interaction. */
onRegionChange?: (region: Region) => void;
diff --git a/package/src/native/specs/overlays.ts b/package/src/native/specs/overlays.ts
index 0bb2674..dc9ec76 100644
--- a/package/src/native/specs/overlays.ts
+++ b/package/src/native/specs/overlays.ts
@@ -1,5 +1,30 @@
import type { Coordinate } from '../../types/coordinate';
+export type OverlayEnteringAnimationKind =
+ | 'none'
+ | 'system'
+ | 'fade'
+ | 'fade-scale';
+
+export type OverlayEnteringAnimationReduceMotion = 'system' | 'never';
+
+/**
+ * Serialized entering animation config passed to native map providers.
+ */
+export interface OverlayEnteringAnimationDescriptor {
+ /** Resolved animation kind. */
+ kind: OverlayEnteringAnimationKind;
+
+ /** Animation duration in milliseconds. */
+ duration?: number;
+
+ /** Delay before starting the animation, in milliseconds. */
+ delay?: number;
+
+ /** Whether system Reduced Motion settings should disable the animation. */
+ reduceMotion?: OverlayEnteringAnimationReduceMotion;
+}
+
/**
* Serialized marker overlay passed to the native map view.
*/
@@ -21,6 +46,9 @@ export interface MarkerDescriptor {
/** Whether the marker participates in clustering when enabled on the map. */
clusterable?: boolean;
+
+ /** Entering animation override for this marker. */
+ enteringAnimation?: OverlayEnteringAnimationDescriptor;
}
/**
diff --git a/package/src/types/index.ts b/package/src/types/index.ts
index d8de6c1..02c6c3b 100644
--- a/package/src/types/index.ts
+++ b/package/src/types/index.ts
@@ -8,7 +8,12 @@ export type {
MapViewPropsForProvider,
} from './map';
export type {
+ MarkerDescriptor,
MarkerProps,
+ OverlayEnteringAnimation,
+ OverlayEnteringAnimationConfig,
+ OverlayEnteringAnimationPreset,
+ OverlayEnteringAnimationReduceMotion,
PolylineProps,
PolygonProps,
CircleProps,
diff --git a/package/src/types/map.ts b/package/src/types/map.ts
index f4cbcce..1bc0fd8 100644
--- a/package/src/types/map.ts
+++ b/package/src/types/map.ts
@@ -4,10 +4,10 @@ import type { Camera } from './camera';
import type { Coordinate } from './coordinate';
import type {
CircleDescriptor,
- MarkerDescriptor,
PolygonDescriptor,
PolylineDescriptor,
} from '../native/specs/overlays';
+import type { MarkerDescriptor, OverlayEnteringAnimation } from './overlays';
import type { EdgePadding, Region } from './region';
/**
@@ -101,6 +101,9 @@ interface BaseMapViewProps {
/** Called when a marker cluster is pressed. */
onClusterPress?: (markerIds: string[], coordinate: Coordinate) => void;
+
+ /** Default entering animation for marker overlays. */
+ markerEnteringAnimation?: OverlayEnteringAnimation;
}
interface ExistingDefaultProviderProps extends BaseMapViewProps {
@@ -121,6 +124,9 @@ interface ExistingDefaultProviderProps extends BaseMapViewProps {
/** Whether to cluster nearby markers. */
clusteringEnabled?: boolean;
+
+ /** Entering animation for marker clusters. */
+ clusterEnteringAnimation?: OverlayEnteringAnimation;
}
interface AppleMapViewProps extends BaseMapViewProps {
@@ -137,6 +143,9 @@ interface AppleMapViewProps extends BaseMapViewProps {
/** Whether to cluster nearby markers. */
clusteringEnabled?: boolean;
+
+ /** Entering animation for marker clusters. */
+ clusterEnteringAnimation?: OverlayEnteringAnimation;
}
interface GoogleMapViewProps extends BaseMapViewProps {
@@ -153,6 +162,9 @@ interface GoogleMapViewProps extends BaseMapViewProps {
/** Whether to cluster nearby markers. */
clusteringEnabled?: boolean;
+
+ /** Entering animation for marker clusters. */
+ clusterEnteringAnimation?: OverlayEnteringAnimation;
}
interface OpenStreetMapViewProps extends BaseMapViewProps {
@@ -161,6 +173,7 @@ interface OpenStreetMapViewProps extends BaseMapViewProps {
showsScale?: never;
customMapStyle?: never;
clusteringEnabled?: never;
+ clusterEnteringAnimation?: never;
}
interface MapboxMapViewProps extends BaseMapViewProps {
@@ -169,6 +182,7 @@ interface MapboxMapViewProps extends BaseMapViewProps {
showsScale?: never;
customMapStyle?: never;
clusteringEnabled?: never;
+ clusterEnteringAnimation?: never;
}
export type MapViewPropsForProvider =
diff --git a/package/src/types/overlays.ts b/package/src/types/overlays.ts
index 781deaa..d3af6f7 100644
--- a/package/src/types/overlays.ts
+++ b/package/src/types/overlays.ts
@@ -1,5 +1,54 @@
import type { Coordinate } from './coordinate';
+export type OverlayEnteringAnimationPreset = 'fade' | 'fade-scale';
+
+export type OverlayEnteringAnimationReduceMotion = 'system' | 'never';
+
+export interface OverlayEnteringAnimationConfig {
+ /** Cross-provider entering animation preset. */
+ preset: OverlayEnteringAnimationPreset;
+
+ /** Animation duration in milliseconds. */
+ duration?: number;
+
+ /** Delay before starting the animation, in milliseconds. */
+ delay?: number;
+
+ /** Whether system Reduced Motion settings should disable the animation. */
+ reduceMotion?: OverlayEnteringAnimationReduceMotion;
+}
+
+export type OverlayEnteringAnimation =
+ | false
+ | 'system'
+ | OverlayEnteringAnimationConfig;
+
+/**
+ * Descriptor for bulk marker rendering.
+ */
+export interface MarkerDescriptor {
+ /** Unique identifier for the marker. */
+ id: string;
+
+ /** Geographic position of the marker. */
+ coordinate: Coordinate;
+
+ /** Title displayed in the callout. */
+ title?: string;
+
+ /** Subtitle displayed in the callout. */
+ subtitle?: string;
+
+ /** Whether the marker is draggable. */
+ draggable?: boolean;
+
+ /** Whether the marker participates in clustering when enabled on the map. */
+ clusterable?: boolean;
+
+ /** Entering animation override for this marker. */
+ enteringAnimation?: OverlayEnteringAnimation;
+}
+
/**
* Props for a map marker overlay.
*/
@@ -22,6 +71,9 @@ export interface MarkerProps {
/** Whether the marker participates in clustering when enabled on the map. */
clusterable?: boolean;
+ /** Entering animation override for this marker. */
+ enteringAnimation?: OverlayEnteringAnimation;
+
/** Called when the marker is pressed. */
onPress?: () => void;
diff --git a/package/src/utils/enteringAnimation.ts b/package/src/utils/enteringAnimation.ts
new file mode 100644
index 0000000..cce3a56
--- /dev/null
+++ b/package/src/utils/enteringAnimation.ts
@@ -0,0 +1,45 @@
+import type {
+ MarkerDescriptor as PublicMarkerDescriptor,
+ OverlayEnteringAnimation,
+} from '../types/overlays';
+import type {
+ MarkerDescriptor,
+ OverlayEnteringAnimationDescriptor,
+} from '../native/specs/overlays';
+
+export function normalizeEnteringAnimation(
+ animation: OverlayEnteringAnimation | undefined,
+): OverlayEnteringAnimationDescriptor | undefined {
+ if (animation == null) {
+ return undefined;
+ }
+
+ if (animation === false) {
+ return { kind: 'none' };
+ }
+
+ if (animation === 'system') {
+ return { kind: 'system' };
+ }
+
+ return {
+ kind: animation.preset,
+ duration: animation.duration,
+ delay: animation.delay,
+ reduceMotion: animation.reduceMotion,
+ };
+}
+
+export function normalizeMarkerDescriptor(
+ marker: PublicMarkerDescriptor,
+): MarkerDescriptor {
+ return {
+ id: marker.id,
+ coordinate: marker.coordinate,
+ title: marker.title,
+ subtitle: marker.subtitle,
+ draggable: marker.draggable,
+ clusterable: marker.clusterable,
+ enteringAnimation: normalizeEnteringAnimation(marker.enteringAnimation),
+ };
+}
diff --git a/package/type-tests/provider-props.ts b/package/type-tests/provider-props.ts
index e1d47dc..4a7b323 100644
--- a/package/type-tests/provider-props.ts
+++ b/package/type-tests/provider-props.ts
@@ -4,6 +4,8 @@ export const appleProps: MapViewPropsForProvider<'apple'> = {
provider: 'apple',
showsScale: true,
clusteringEnabled: true,
+ markerEnteringAnimation: { preset: 'fade-scale', duration: 180 },
+ clusterEnteringAnimation: 'system',
};
export const googleProps: MapViewPropsForProvider<'google'> = {
@@ -11,11 +13,20 @@ export const googleProps: MapViewPropsForProvider<'google'> = {
googleMapId: 'google-map-id',
customMapStyle: '[]',
clusteringEnabled: true,
+ clusterEnteringAnimation: false,
};
export const defaultProviderProps: MapViewProps = {
showsScale: true,
customMapStyle: '[]',
+ markerEnteringAnimation: false,
+ markers: [
+ {
+ id: 'marker-1',
+ coordinate: { latitude: 52.2297, longitude: 21.0122 },
+ enteringAnimation: { preset: 'fade', reduceMotion: 'system' },
+ },
+ ],
};
// @ts-expect-error Google Map IDs require the explicit Google provider.
@@ -45,3 +56,16 @@ export const openStreetMapClusteringProps: MapViewPropsForProvider<'openstreetma
// @ts-expect-error Planned OpenStreetMap support has no clustering capability yet.
clusteringEnabled: true,
};
+
+export const openStreetMapClusterAnimationProps: MapViewPropsForProvider<'openstreetmap'> =
+ {
+ provider: 'openstreetmap',
+ // @ts-expect-error Planned OpenStreetMap support has no cluster animation capability yet.
+ clusterEnteringAnimation: { preset: 'fade' },
+ };
+
+export const mapboxClusterAnimationProps: MapViewPropsForProvider<'mapbox'> = {
+ provider: 'mapbox',
+ // @ts-expect-error Planned Mapbox support has no cluster animation capability yet.
+ clusterEnteringAnimation: { preset: 'fade' },
+};
From 33405d4ebb7103dee35f2ced7e13dc6e085ca6fd Mon Sep 17 00:00:00 2001
From: Piotr Graczyk
Date: Sun, 28 Jun 2026 11:39:03 +0200
Subject: [PATCH 2/3] feat: add example animation picker
---
example/App.tsx | 116 ++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 112 insertions(+), 4 deletions(-)
diff --git a/example/App.tsx b/example/App.tsx
index 6cf9201..ab0cba5 100644
--- a/example/App.tsx
+++ b/example/App.tsx
@@ -41,11 +41,19 @@ import {
type MapProvider,
type MapType,
type MapViewRef,
+ type OverlayEnteringAnimation,
} from 'react-native-nitro-maps';
import { MAP_SCENARIOS, type MapScenario } from './examples';
const MAP_TYPES: MapType[] = ['standard', 'satellite', 'hybrid'];
type SupportedExampleProvider = Extract;
+type AnimationOptionId = 'system' | 'fade' | 'fade-scale' | 'none';
+
+type AnimationOption = {
+ id: AnimationOptionId;
+ label: string;
+ value: OverlayEnteringAnimation;
+};
const PROVIDER_LABELS: Record = {
apple: 'Apple MapKit',
@@ -54,6 +62,17 @@ const PROVIDER_LABELS: Record = {
const SUPPORTED_MAP_PROVIDERS = getSupportedMapProviders();
+const ANIMATION_OPTIONS: AnimationOption[] = [
+ { id: 'system', label: 'System', value: 'system' },
+ { id: 'fade', label: 'Fade', value: { preset: 'fade', duration: 180 } },
+ {
+ id: 'fade-scale',
+ label: 'Scale',
+ value: { preset: 'fade-scale', duration: 180 },
+ },
+ { id: 'none', label: 'Off', value: false },
+];
+
function getSupportedMapProviders(): SupportedExampleProvider[] {
switch (Platform.OS) {
case 'ios':
@@ -239,9 +258,11 @@ type ScenarioDockProps = {
expanded: boolean;
mapTypeLabel: MapType;
providerLabel: string;
+ animationOptionId: AnimationOptionId;
canCycleProvider: boolean;
onToggleExpanded: () => void;
onSelect: (index: number) => void;
+ onSelectAnimation: (animation: AnimationOptionId) => void;
onAnimateCamera: () => void;
onGetCamera: () => void;
onCycleMapType: () => void;
@@ -254,9 +275,11 @@ const ScenarioDock = memo(function ScenarioDock({
expanded,
mapTypeLabel,
providerLabel,
+ animationOptionId,
canCycleProvider,
onToggleExpanded,
onSelect,
+ onSelectAnimation,
onAnimateCamera,
onGetCamera,
onCycleMapType,
@@ -311,6 +334,32 @@ const ScenarioDock = memo(function ScenarioDock({
+
+ Entering animation
+
+ {ANIMATION_OPTIONS.map((option) => (
+ onSelectAnimation(option.id)}
+ style={[
+ styles.optionChip,
+ option.id === animationOptionId && styles.optionChipActive,
+ ]}
+ >
+
+ {option.label}
+
+
+ ))}
+
+
+
void;
onClusterPress: (markerIds: string[], coordinate: Coordinate) => void;
onMarkerPress: (id: string) => void;
@@ -399,6 +449,7 @@ const MapScene = memo(
provider,
mapType,
mapPadding,
+ animationOption,
onMapReady,
onClusterPress,
onMarkerPress,
@@ -419,9 +470,9 @@ const MapScene = memo(
showsCompass: scenario.advanced?.showsCompass,
customMapStyle: scenario.advanced?.customMapStyle,
mapPadding,
- markerEnteringAnimation: { preset: 'fade-scale', duration: 180 } as const,
+ markerEnteringAnimation: animationOption.value,
clusterEnteringAnimation: scenario.advanced?.clusteringEnabled
- ? ({ preset: 'fade', duration: 160 } as const)
+ ? animationOption.value
: undefined,
markers: scenario.markers,
polylines: scenario.polylines,
@@ -442,7 +493,7 @@ const MapScene = memo(
return (
@@ -530,12 +581,14 @@ export default function App() {
const [scenarioIndex, setScenarioIndex] = useState(0);
const [mapTypeIndex, setMapTypeIndex] = useState(0);
const [providerIndex, setProviderIndex] = useState(0);
+ const [animationOptionIndex, setAnimationOptionIndex] = useState(2);
const [status, setStatus] = useState('Waiting for map...');
const [mapReady, setMapReady] = useState(false);
const [dockExpanded, setDockExpanded] = useState(false);
const scenario = MAP_SCENARIOS[scenarioIndex];
const provider = SUPPORTED_MAP_PROVIDERS[providerIndex] ?? 'google';
+ const animationOption = ANIMATION_OPTIONS[animationOptionIndex];
const showsScale = scenario.advanced?.showsScale === true;
const showsCompass = scenario.advanced?.showsCompass === true;
const mapPadding = useMemo(
@@ -604,6 +657,22 @@ export default function App() {
setDockExpanded((current) => !current);
}, []);
+ const selectAnimation = useCallback(
+ (animation: AnimationOptionId) => {
+ const nextIndex = ANIMATION_OPTIONS.findIndex(
+ (option) => option.id === animation,
+ );
+ if (nextIndex < 0 || nextIndex === animationOptionIndex) {
+ return;
+ }
+
+ setAnimationOptionIndex(nextIndex);
+ setMapReady(false);
+ setStatus(`Animation · ${ANIMATION_OPTIONS[nextIndex].label}`);
+ },
+ [animationOptionIndex],
+ );
+
const handleMarkerPress = useCallback((id: string) => {
setStatus(`Marker · ${id}`);
}, []);
@@ -666,6 +735,7 @@ export default function App() {
provider={provider}
mapType={MAP_TYPES[mapTypeIndex]}
mapPadding={mapPadding}
+ animationOption={animationOption}
onMapReady={handleMapReady}
onClusterPress={handleClusterPress}
onMarkerPress={handleMarkerPress}
@@ -688,9 +758,11 @@ export default function App() {
expanded={dockExpanded}
mapTypeLabel={MAP_TYPES[mapTypeIndex]}
providerLabel={PROVIDER_LABELS[provider]}
+ animationOptionId={animationOption.id}
canCycleProvider={SUPPORTED_MAP_PROVIDERS.length > 1}
onToggleExpanded={toggleDockExpanded}
onSelect={selectScenario}
+ onSelectAnimation={selectAnimation}
onAnimateCamera={handleAnimateCamera}
onGetCamera={handleGetCamera}
onCycleMapType={cycleMapType}
@@ -858,6 +930,42 @@ const styles = StyleSheet.create({
dockExpanded: {
gap: 12,
},
+ controlGroup: {
+ gap: 8,
+ },
+ controlLabel: {
+ fontSize: 11,
+ fontWeight: '600',
+ color: palette.textMuted,
+ },
+ optionRow: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 8,
+ },
+ optionChip: {
+ flexGrow: 1,
+ minWidth: 72,
+ paddingHorizontal: 10,
+ paddingVertical: 8,
+ borderRadius: 10,
+ backgroundColor: 'rgba(255, 255, 255, 0.06)',
+ borderWidth: 1,
+ borderColor: palette.border,
+ alignItems: 'center',
+ },
+ optionChipActive: {
+ backgroundColor: palette.accentSoft,
+ borderColor: 'rgba(59, 130, 246, 0.45)',
+ },
+ optionChipText: {
+ fontSize: 11,
+ fontWeight: '600',
+ color: palette.textSecondary,
+ },
+ optionChipTextActive: {
+ color: palette.text,
+ },
iconButton: {
width: 32,
height: 32,
From ced77ca5422e3d618cbb2fc36874fb28c2980529 Mon Sep 17 00:00:00 2001
From: Piotr Graczyk
Date: Sun, 28 Jun 2026 12:40:07 +0200
Subject: [PATCH 3/3] fix: address overlay animation review feedback
---
README.md | 2 +
docs/architecture.md | 2 +
example/App.tsx | 18 +++-
package/ios/GoogleMapOverlayController.swift | 71 +++++++++++--
package/ios/GoogleMapProviderAdapter.swift | 17 ++-
package/ios/GoogleMapsAPIKey.swift | 23 +++-
package/ios/HybridMapView.swift | 10 +-
package/ios/HybridMapViewDelegate.swift | 16 +--
package/ios/MapProviderAdapter.swift | 91 ++++++++++++++++
package/ios/OverlayEnteringAnimation.swift | 105 ++++++++++++++-----
10 files changed, 296 insertions(+), 59 deletions(-)
diff --git a/README.md b/README.md
index 6fa6d74..0c35991 100644
--- a/README.md
+++ b/README.md
@@ -164,6 +164,8 @@ 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.
+
### Capability matrix
| Capability | `apple` iOS | `google` iOS | `google` Android | Future providers |
diff --git a/docs/architecture.md b/docs/architecture.md
index 7e8693a..88e6962 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -103,6 +103,8 @@ 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.
+
## Data flow (target state)
```
diff --git a/example/App.tsx b/example/App.tsx
index ab0cba5..19b62a1 100644
--- a/example/App.tsx
+++ b/example/App.tsx
@@ -16,6 +16,8 @@ import {
StyleSheet,
Text,
View,
+ type AccessibilityRole,
+ type AccessibilityState,
type StyleProp,
type ViewStyle,
} from 'react-native';
@@ -141,6 +143,9 @@ type ScalePressableProps = {
style?: StyleProp;
children: ReactNode;
hitSlop?: number;
+ accessibilityRole?: AccessibilityRole;
+ accessibilityState?: AccessibilityState;
+ accessibilityLabel?: string;
};
const ScalePressable = memo(function ScalePressable({
@@ -148,6 +153,9 @@ const ScalePressable = memo(function ScalePressable({
style,
children,
hitSlop,
+ accessibilityRole,
+ accessibilityState,
+ accessibilityLabel,
}: ScalePressableProps) {
const scale = useSharedValue(1);
@@ -158,6 +166,9 @@ const ScalePressable = memo(function ScalePressable({
return (
{
@@ -341,6 +352,11 @@ const ScenarioDock = memo(function ScenarioDock({
onSelectAnimation(option.id)}
+ accessibilityRole="button"
+ accessibilityState={{
+ selected: option.id === animationOptionId,
+ }}
+ accessibilityLabel={`${option.label} entering animation`}
style={[
styles.optionChip,
option.id === animationOptionId && styles.optionChipActive,
@@ -581,7 +597,7 @@ export default function App() {
const [scenarioIndex, setScenarioIndex] = useState(0);
const [mapTypeIndex, setMapTypeIndex] = useState(0);
const [providerIndex, setProviderIndex] = useState(0);
- const [animationOptionIndex, setAnimationOptionIndex] = useState(2);
+ const [animationOptionIndex, setAnimationOptionIndex] = useState(0);
const [status, setStatus] = useState('Waiting for map...');
const [mapReady, setMapReady] = useState(false);
const [dockExpanded, setDockExpanded] = useState(false);
diff --git a/package/ios/GoogleMapOverlayController.swift b/package/ios/GoogleMapOverlayController.swift
index 815e3be..77212a6 100644
--- a/package/ios/GoogleMapOverlayController.swift
+++ b/package/ios/GoogleMapOverlayController.swift
@@ -4,12 +4,20 @@ import UIKit
final class GoogleMapOverlayController {
private static let clusterCellPoints: Double = 96
+ // GMSMarker entering animations are main-thread work; cap them per diff so
+ // bulk viewport refreshes do not block active Google Maps gestures.
+ private static let maximumAnimatedMarkersPerDiff = 96
private enum MarkerPayload {
case marker(String)
case cluster(memberIds: [String], region: MKCoordinateRegion)
}
+ private struct MarkerAnimationBatch {
+ let animation: ResolvedOverlayEnteringAnimation
+ var markers: [GMSMarker]
+ }
+
private weak var mapView: GMSMapView?
private var markers: [String: GMSMarker] = [:]
private var markerVersions: [String: Int] = [:]
@@ -58,7 +66,10 @@ final class GoogleMapOverlayController {
reapplyMarkers()
}
- func refreshViewportMarkers() {
+ func refreshViewportMarkers(
+ animateEntering: Bool = true,
+ animationBudget: Int = maximumAnimatedMarkersPerDiff
+ ) {
guard let mapView, usesViewportPipeline else {
return
}
@@ -68,12 +79,20 @@ final class GoogleMapOverlayController {
region: mapView.currentNitroRegion().toMKCoordinateRegion(),
viewSize: mapView.bounds.size,
apply: { [weak self] diff in
- self?.applyDiff(diff)
+ self?.applyDiff(
+ diff,
+ animateEntering: animateEntering,
+ animationBudget: animationBudget
+ )
}
)
}
- func scheduleViewportRefresh(immediate: Bool = false) {
+ func scheduleViewportRefresh(
+ immediate: Bool = false,
+ animateEntering: Bool = true,
+ animationBudget: Int = maximumAnimatedMarkersPerDiff
+ ) {
guard let mapView, usesViewportPipeline else {
return
}
@@ -84,7 +103,11 @@ final class GoogleMapOverlayController {
viewSize: mapView.bounds.size,
immediate: immediate,
apply: { [weak self] diff in
- self?.applyDiff(diff)
+ self?.applyDiff(
+ diff,
+ animateEntering: animateEntering,
+ animationBudget: animationBudget
+ )
}
)
}
@@ -173,7 +196,11 @@ final class GoogleMapOverlayController {
)
}
- private func applyDiff(_ diff: MarkerRenderDiff) {
+ private func applyDiff(
+ _ diff: MarkerRenderDiff,
+ animateEntering: Bool = true,
+ animationBudget: Int = maximumAnimatedMarkersPerDiff
+ ) {
guard let mapView else {
return
}
@@ -183,16 +210,34 @@ final class GoogleMapOverlayController {
markerVersions.removeValue(forKey: key)
}
+ var animationBatches: [MarkerAnimationBatch] = []
+ var remainingAnimationBudget = animateEntering ? max(0, animationBudget) : 0
+
for entry in diff.added {
let marker = makeMarker(for: entry.element)
let animation = enteringAnimation(for: entry.element)
- OverlayEnteringAnimationResolver.prepareGoogleMarker(marker, animation: animation)
+ let shouldAnimate = animateEntering
+ && remainingAnimationBudget > 0
+ && OverlayEnteringAnimationResolver.canAnimateGoogleMarker(animation)
+
+ if shouldAnimate {
+ OverlayEnteringAnimationResolver.prepareGoogleMarker(marker, animation: animation)
+ if OverlayEnteringAnimationResolver.usesBatchedGoogleMarkerAnimation(animation) {
+ append(marker, animation: animation, to: &animationBatches)
+ }
+ remainingAnimationBudget -= 1
+ } else {
+ OverlayEnteringAnimationResolver.showGoogleMarkerWithoutAnimation(marker)
+ }
marker.map = mapView
- OverlayEnteringAnimationResolver.animateGoogleMarker(marker, animation: animation)
markers[entry.key] = marker
markerVersions[entry.key] = entry.version
}
+ for batch in animationBatches {
+ OverlayEnteringAnimationResolver.animateGoogleMarkers(batch.markers, animation: batch.animation)
+ }
+
for entry in diff.retained {
guard let marker = markers[entry.key] else {
continue
@@ -208,6 +253,18 @@ final class GoogleMapOverlayController {
return marker
}
+ private func append(
+ _ marker: GMSMarker,
+ animation: ResolvedOverlayEnteringAnimation,
+ to batches: inout [MarkerAnimationBatch]
+ ) {
+ for index in batches.indices where batches[index].animation == animation {
+ batches[index].markers.append(marker)
+ return
+ }
+ batches.append(MarkerAnimationBatch(animation: animation, markers: [marker]))
+ }
+
private func enteringAnimation(
for element: MarkerClusterEngine.Element
) -> ResolvedOverlayEnteringAnimation {
diff --git a/package/ios/GoogleMapProviderAdapter.swift b/package/ios/GoogleMapProviderAdapter.swift
index 7c2f9f0..c7ff810 100644
--- a/package/ios/GoogleMapProviderAdapter.swift
+++ b/package/ios/GoogleMapProviderAdapter.swift
@@ -6,6 +6,9 @@ import QuartzCore
import UIKit
final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter {
+ private static let liveGestureRefreshInterval: CFTimeInterval = 0.18
+ private static let liveGestureAnimationBudget = 24
+
private var isProgrammaticUpdate = false
private var pendingProgrammaticUpdateIDs: [Int] = []
private var nextProgrammaticUpdateID = 0
@@ -30,7 +33,6 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter {
}
lazy var view: GMSMapView = {
- GoogleMapsAPIKey.configureIfNeeded()
let camera = self.camera?.toGMSCameraPosition()
?? GMSCameraPosition(latitude: 0, longitude: 0, zoom: 10)
let mapView: GMSMapView
@@ -55,7 +57,8 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter {
return mapView
}()
- init(googleMapId: String?) {
+ init(googleMapId: String?) throws {
+ try GoogleMapsAPIKey.configureIfNeeded()
_googleMapId = googleMapId
super.init()
}
@@ -377,6 +380,7 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter {
private func startGestureMarkerRefresh() {
isUserGestureMoving = true
lastLiveMarkerRefreshTime = 0
+ refreshGestureMarkersIfNeeded()
}
private func refreshGestureMarkersIfNeeded() {
@@ -385,18 +389,20 @@ final class GoogleMapProviderAdapter: NSObject, MapProviderAdapter {
}
let now = CACurrentMediaTime()
- guard now - lastLiveMarkerRefreshTime >= MarkerRenderPipeline.liveRefreshInterval else {
+ guard now - lastLiveMarkerRefreshTime >= Self.liveGestureRefreshInterval else {
return
}
lastLiveMarkerRefreshTime = now
- overlayController.refreshViewportMarkers()
+ overlayController.refreshViewportMarkers(
+ animateEntering: true,
+ animationBudget: Self.liveGestureAnimationBudget
+ )
}
private func stopGestureMarkerRefresh() {
isUserGestureMoving = false
lastLiveMarkerRefreshTime = 0
- refreshVisibleMarkers()
}
private func animateToClusterRegion(_ region: MKCoordinateRegion) {
@@ -528,6 +534,7 @@ extension GoogleMapProviderAdapter: GMSMapViewDelegate {
func mapView(_ mapView: GMSMapView, idleAt position: GMSCameraPosition) {
stopGestureMarkerRefresh()
+ refreshVisibleMarkers()
notifyRegionChange(complete: true)
endProgrammaticUpdate()
notifyMapReadyIfNeeded()
diff --git a/package/ios/GoogleMapsAPIKey.swift b/package/ios/GoogleMapsAPIKey.swift
index 4a64988..24feff3 100644
--- a/package/ios/GoogleMapsAPIKey.swift
+++ b/package/ios/GoogleMapsAPIKey.swift
@@ -4,13 +4,12 @@ import GoogleMaps
enum GoogleMapsAPIKey {
private static var configuredKey: String?
- static func configureIfNeeded() {
+ static func configureIfNeeded() throws {
let key = Bundle.main.object(forInfoDictionaryKey: "GoogleMapsIosApiKey") as? String
guard let key = key?.trimmingCharacters(in: .whitespacesAndNewlines),
- !key.isEmpty else {
- preconditionFailure(
- "react-native-nitro-maps: provider=\"google\" on iOS requires GoogleMapsIosApiKey in the host app Info.plist."
- )
+ !key.isEmpty,
+ !key.hasPrefix("$(") else {
+ throw MapProviderConfigurationError.missingGoogleMapsIosApiKey
}
guard configuredKey != key else {
@@ -21,3 +20,17 @@ enum GoogleMapsAPIKey {
configuredKey = key
}
}
+
+enum MapProviderConfigurationError: LocalizedError {
+ case missingGoogleMapsIosApiKey
+ case unsupportedIOSProvider(MapProvider)
+
+ var errorDescription: String? {
+ switch self {
+ case .missingGoogleMapsIosApiKey:
+ return "react-native-nitro-maps: provider=\"google\" on iOS requires GoogleMapsIosApiKey in the host app Info.plist."
+ case let .unsupportedIOSProvider(provider):
+ return "Map provider \"\(provider)\" is not supported on iOS."
+ }
+ }
+}
diff --git a/package/ios/HybridMapView.swift b/package/ios/HybridMapView.swift
index b2afbb7..b72cb40 100644
--- a/package/ios/HybridMapView.swift
+++ b/package/ios/HybridMapView.swift
@@ -334,9 +334,15 @@ final class HybridMapView: HybridMapViewSpec {
case .apple:
return AppleMapProviderAdapter()
case .google:
- return GoogleMapProviderAdapter(googleMapId: _googleMapId)
+ do {
+ return try GoogleMapProviderAdapter(googleMapId: _googleMapId)
+ } catch {
+ return UnavailableMapProviderAdapter(error: error)
+ }
case .openstreetmap, .mapbox:
- preconditionFailure("Map provider \"\(provider)\" is not supported on iOS.")
+ return UnavailableMapProviderAdapter(
+ error: MapProviderConfigurationError.unsupportedIOSProvider(provider)
+ )
}
}
diff --git a/package/ios/HybridMapViewDelegate.swift b/package/ios/HybridMapViewDelegate.swift
index 7eb4b2d..61a7fd2 100644
--- a/package/ios/HybridMapViewDelegate.swift
+++ b/package/ios/HybridMapViewDelegate.swift
@@ -126,21 +126,11 @@ final class HybridMapViewDelegate: NSObject, MKMapViewDelegate, UIGestureRecogni
animation: marker.enteringAnimation,
supportsScale: true
)
- } else if let cluster = view.annotation as? MapClusterAnnotation {
- let animation: ResolvedOverlayEnteringAnimation
- if cluster.enteringAnimation.kind == .system {
- animation = ResolvedOverlayEnteringAnimation(
- kind: .fade,
- duration: 0.16,
- delay: 0,
- reduceMotion: .system
- )
- } else {
- animation = cluster.enteringAnimation
- }
+ } else if let cluster = view.annotation as? MapClusterAnnotation,
+ cluster.enteringAnimation.kind != .system {
OverlayEnteringAnimationResolver.animateAnnotationView(
view,
- animation: animation,
+ animation: cluster.enteringAnimation,
supportsScale: true
)
}
diff --git a/package/ios/MapProviderAdapter.swift b/package/ios/MapProviderAdapter.swift
index d91aeb8..d7bd6df 100644
--- a/package/ios/MapProviderAdapter.swift
+++ b/package/ios/MapProviderAdapter.swift
@@ -47,3 +47,94 @@ protocol MapProviderAdapter: AnyObject {
func fitToCoordinates(coordinates: [Coordinate], padding: EdgePadding?, animated: Bool?) throws
func prepareForRecycle()
}
+
+final class UnavailableMapProviderAdapter: MapProviderAdapter {
+ let contentView: UIView
+ private let error: Error
+
+ var mapType: MapType = .standard
+ var region: Region?
+ var camera: Camera?
+ var scrollEnabled: Bool?
+ var zoomEnabled: Bool?
+ var rotateEnabled: Bool?
+ var pitchEnabled: Bool?
+ var showsUserLocation: Bool?
+ var followsUserLocation: Bool?
+ var showsCompass: Bool?
+ var showsScale: Bool?
+ var customMapStyle: String?
+ var googleMapId: String?
+ var clusteringEnabled: Bool?
+ var mapPadding: EdgePadding?
+ var markerEnteringAnimation: OverlayEnteringAnimationDescriptor?
+ var clusterEnteringAnimation: OverlayEnteringAnimationDescriptor?
+
+ var onRegionChange: ((Region) -> Void)?
+ var onRegionChangeComplete: ((Region) -> Void)?
+ var onMapReady: (() -> Void)?
+ var onPress: ((Coordinate) -> Void)?
+ var onLongPress: ((Coordinate) -> Void)?
+
+ var markers: [MarkerDescriptor]?
+ var polylines: [PolylineDescriptor]?
+ var polygons: [PolygonDescriptor]?
+ var circles: [CircleDescriptor]?
+
+ var onMarkerPress: ((String) -> Void)?
+ var onMarkerDragEnd: ((String, Coordinate) -> Void)?
+ var onPolylinePress: ((String) -> Void)?
+ var onPolygonPress: ((String) -> Void)?
+ var onCirclePress: ((String) -> Void)?
+ var onClusterPress: (([String], Coordinate) -> Void)?
+
+ init(error: Error) {
+ self.error = error
+
+ let view = UIView()
+ view.backgroundColor = .systemBackground
+
+ let label = UILabel()
+ label.translatesAutoresizingMaskIntoConstraints = false
+ label.text = error.localizedDescription
+ label.textAlignment = .center
+ label.textColor = .secondaryLabel
+ label.font = .preferredFont(forTextStyle: .footnote)
+ label.numberOfLines = 0
+
+ view.addSubview(label)
+ NSLayoutConstraint.activate([
+ label.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
+ label.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24),
+ label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
+ ])
+
+ contentView = view
+ }
+
+ func fetchCamera() throws -> Promise {
+ Promise.rejected(withError: error)
+ }
+
+ func applyCamera(camera: Camera) throws {
+ throw error
+ }
+
+ func animateCamera(camera: Camera, duration: Double?) throws {
+ throw error
+ }
+
+ func getVisibleRegion() throws -> Promise {
+ Promise.rejected(withError: error)
+ }
+
+ func fitToCoordinates(
+ coordinates: [Coordinate],
+ padding: EdgePadding?,
+ animated: Bool?
+ ) throws {
+ throw error
+ }
+
+ func prepareForRecycle() {}
+}
diff --git a/package/ios/OverlayEnteringAnimation.swift b/package/ios/OverlayEnteringAnimation.swift
index 73f93c7..80a3ca7 100644
--- a/package/ios/OverlayEnteringAnimation.swift
+++ b/package/ios/OverlayEnteringAnimation.swift
@@ -2,7 +2,7 @@ import GoogleMaps
import QuartzCore
import UIKit
-enum ResolvedOverlayEnteringAnimationKind {
+enum ResolvedOverlayEnteringAnimationKind: Equatable {
case none
case system
case fade
@@ -81,44 +81,97 @@ enum OverlayEnteringAnimationResolver {
static func prepareGoogleMarker(_ marker: GMSMarker, animation: ResolvedOverlayEnteringAnimation) {
marker.appearAnimation = .none
- let markerAnimation = googleMarkerAnimation(animation)
- guard shouldRun(markerAnimation) else {
+ marker.iconView = nil
+ marker.tracksViewChanges = false
+
+ guard shouldRun(animation) else {
return
}
- guard markerAnimation.kind != .system else {
- marker.appearAnimation = .pop
+
+ switch animation.kind {
+ case .none:
return
+ case .system:
+ marker.appearAnimation = .pop
+ case .fade:
+ marker.appearAnimation = .fadeIn
+ case .fadeScale:
+ prepareGoogleFadeScaleMarker(marker)
}
- marker.opacity = 0
}
- static func animateGoogleMarker(_ marker: GMSMarker, animation: ResolvedOverlayEnteringAnimation) {
- let markerAnimation = googleMarkerAnimation(animation)
- guard shouldRun(markerAnimation), markerAnimation.kind != .system else {
+ static func canAnimateGoogleMarker(_ animation: ResolvedOverlayEnteringAnimation) -> Bool {
+ shouldRun(animation)
+ }
+
+ static func usesBatchedGoogleMarkerAnimation(_ animation: ResolvedOverlayEnteringAnimation) -> Bool {
+ shouldRun(animation) && animation.kind == .fadeScale
+ }
+
+ static func showGoogleMarkerWithoutAnimation(_ marker: GMSMarker) {
+ marker.appearAnimation = .none
+ marker.iconView = nil
+ marker.tracksViewChanges = false
+ marker.opacity = 1
+ }
+
+ static func animateGoogleMarkers(
+ _ markers: [GMSMarker],
+ animation: ResolvedOverlayEnteringAnimation
+ ) {
+ guard shouldRun(animation), animation.kind == .fadeScale else {
+ return
+ }
+ guard !markers.isEmpty else {
return
}
- DispatchQueue.main.asyncAfter(deadline: .now() + markerAnimation.delay) {
- CATransaction.begin()
- CATransaction.setAnimationDuration(markerAnimation.duration)
- CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeOut))
- marker.opacity = 1
- CATransaction.commit()
+ let runAnimations = {
+ for marker in markers where marker.map != nil {
+ animateGoogleFadeScaleMarker(marker, duration: animation.duration)
+ }
}
- }
- private static func googleMarkerAnimation(
- _ animation: ResolvedOverlayEnteringAnimation
- ) -> ResolvedOverlayEnteringAnimation {
- guard animation.kind == .fadeScale else {
- return animation
+ if animation.delay > 0 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + animation.delay) {
+ runAnimations()
+ }
+ } else {
+ runAnimations()
}
- return ResolvedOverlayEnteringAnimation(
- kind: .fade,
- duration: animation.duration,
- delay: animation.delay,
- reduceMotion: animation.reduceMotion
+ }
+
+ private static func prepareGoogleFadeScaleMarker(_ marker: GMSMarker) {
+ let image = marker.icon ?? GMSMarker.markerImage(with: nil)
+ let imageView = UIImageView(
+ frame: CGRect(origin: .zero, size: image.size)
)
+ imageView.image = image
+ imageView.contentMode = .scaleAspectFit
+ imageView.alpha = 0
+ imageView.transform = CGAffineTransform(scaleX: 0.7, y: 0.7)
+
+ marker.iconView = imageView
+ marker.tracksViewChanges = true
+ }
+
+ private static func animateGoogleFadeScaleMarker(_ marker: GMSMarker, duration: TimeInterval) {
+ guard let iconView = marker.iconView else {
+ return
+ }
+
+ marker.tracksViewChanges = true
+ UIView.animate(
+ withDuration: duration,
+ delay: 0,
+ options: [.allowUserInteraction, .beginFromCurrentState, .curveEaseOut]
+ ) {
+ iconView.alpha = 1
+ iconView.transform = .identity
+ } completion: { _ in
+ marker.tracksViewChanges = false
+ marker.iconView = nil
+ }
}
private static func seconds(