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(