Skip to content

Commit d36b56d

Browse files
authored
fix: refresh retained marker visuals across providers (#54)
* fix: refresh retained marker visuals across providers * fix(ios): keep one pending Google marker image load * test(ios): cover Google marker pending image apply * test(ios): assert pending Google marker load applies the icon * fix(ios): reject stale marker image completions
1 parent b9a1783 commit d36b56d

23 files changed

Lines changed: 646 additions & 146 deletions

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,7 @@ Additional props:
347347
| `anchor` | `{ x: 0.5, y: 1 }` | Point on the image aligned to the coordinate |
348348
| `centerOffset` | — | Extra offset in dp (MapKit-style) |
349349
| `rotation` | `0` | Clockwise rotation in degrees |
350-
| `flat` | `false` | Rotate with map plane (Google Maps; limited on iOS) |
350+
| `flat` | `false` | Rotate with map plane (Google Maps; MapKit approximates via view transform) |
351351
| `opacity` | `1` | Marker opacity from 0 to 1 |
352352

353353
Platform notes:

‎docs/adr/0004-custom-view-markers.md‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,8 @@ natively — unlike the null-rendering `<Marker>`.
109109
110110
### Phase 0 — quick win (independent)
111111
112-
- Fix the existing gap where iOS Google (`GoogleMapOverlayController.updateMarker`) sets
113-
`icon = nil` and never applies `MarkerDescriptor.image`, so `<Marker image>` is
114-
consistent across all three backends (MapKit, iOS Google, Android Google).
112+
Done: iOS Google applies `MarkerDescriptor` image, anchor, centerOffset, rotation, flat,
113+
and opacity so `<Marker image>` is consistent across MapKit, iOS Google, and Android Google.
115114
116115
### Phase 1 — MVP custom views (Option A)
117116

‎docs/roadmap.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ Delivered incrementally during Phases 3–5; polished for platform consistency i
9191

9292
## Custom view markers (ADR 0004)
9393

94-
- [ ] Phase 0: apply `MarkerDescriptor.image` on iOS Google provider
94+
- [x] Phase 0: apply `MarkerDescriptor` image, anchor, centerOffset, rotation, flat, and opacity on iOS Google
9595
- [ ] Phase 1: `<Marker>` children via snapshot into existing image pipeline
9696
- [ ] Phase 2: `<MarkerView>` HybridView — live MapKit views, snapshot on Google
9797

‎package/android/build.gradle‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,5 @@ dependencies {
7979
implementation project(':react-native-nitro-modules')
8080
implementation 'com.google.android.gms:play-services-maps:19.0.0'
8181
implementation 'com.google.maps.android:android-maps-utils:3.8.2'
82+
testImplementation 'junit:junit:4.13.2'
8283
}

‎package/android/src/main/java/com/margelo/nitro/nitromaps/MapOverlayController.kt‎

Lines changed: 18 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ class MapOverlayController(
3636
private var onMarkerPress: ((String) -> Unit)? = null
3737
private var onClusterPress: ((List<String>, Coordinate) -> Unit)? = null
3838
private var allMarkerDescriptors: Array<MarkerDescriptor> = emptyArray()
39-
private var markersFingerprint: Int = 0
39+
private var markersFingerprint: Long = 0L
4040
private var spatialIndex: MarkerSpatialIndex? = null
4141
private var refreshGeneration: Int = 0
4242
private var viewWidthPx: Int = 0
@@ -103,7 +103,7 @@ class MapOverlayController(
103103
polygons.clear()
104104
circles.clear()
105105
allMarkerDescriptors = emptyArray()
106-
markersFingerprint = 0
106+
markersFingerprint = 0L
107107
spatialIndex = null
108108
refreshGeneration += 1
109109
computeExecutor.shutdown()
@@ -142,7 +142,6 @@ class MapOverlayController(
142142

143143
fun refreshViewportMarkers(
144144
animateEntering: Boolean = true,
145-
updateRetained: Boolean = true,
146145
maxAnimatedMarkers: Int = MAX_ANIMATED_MARKERS_PER_DIFF,
147146
) {
148147
val map = googleMap ?: return
@@ -172,30 +171,13 @@ class MapOverlayController(
172171
.map { ClusterElement.Single(it) }
173172
}
174173

175-
val nextKeys = HashSet<String>(elements.size)
176-
val added = ArrayList<ClusterElement>()
177-
val retained = ArrayList<ClusterElement>()
178-
for (element in elements) {
179-
val key = element.diffKey
180-
if (!nextKeys.add(key)) {
181-
continue
182-
}
183-
val version = element.renderVersion
184-
if (displayedVersions[key] != null) {
185-
if (updateRetained && displayedVersions[key] != version) {
186-
retained.add(element)
187-
}
188-
} else {
189-
added.add(element)
190-
}
191-
}
192-
val removed = displayedVersions.keys - nextKeys
174+
val diff = computeMarkerRenderDiff(elements, displayedVersions)
193175

194176
mainHandler.post {
195177
if (generation != refreshGeneration) {
196178
return@post
197179
}
198-
applyDiff(removed, added, retained, animateEntering, maxAnimatedMarkers)
180+
applyDiff(diff, animateEntering, maxAnimatedMarkers)
199181
}
200182
}
201183
}
@@ -218,24 +200,22 @@ class MapOverlayController(
218200
}
219201

220202
private fun applyDiff(
221-
removedKeys: Set<String>,
222-
added: List<ClusterElement>,
223-
retained: List<ClusterElement>,
203+
diff: MarkerRenderDiff,
224204
animateEntering: Boolean = true,
225205
maxAnimatedMarkers: Int = MAX_ANIMATED_MARKERS_PER_DIFF,
226206
) {
227207
val map = googleMap ?: return
228208

229-
for (key in removedKeys) {
209+
for (key in diff.removedKeys) {
230210
cancelEnteringAnimation(key)
231211
markers.remove(key)?.remove()
232212
markerVersions.remove(key)
233213
clusterByKey.remove(key)
234214
}
235215

236216
var remainingAnimationBudget = maxAnimatedMarkers.coerceAtLeast(0)
237-
val addedMarkers = ArrayList<AddedMarker>(minOf(added.size, remainingAnimationBudget))
238-
for (element in added) {
217+
val addedMarkers = ArrayList<AddedMarker>(minOf(diff.added.size, remainingAnimationBudget))
218+
for (element in diff.added) {
239219
val key = element.diffKey
240220
when (element) {
241221
is ClusterElement.Single -> {
@@ -253,7 +233,8 @@ class MapOverlayController(
253233
markerIconFactory.applyVisualProps(element.descriptor, marker, key)
254234
markerVersions[key] = element.renderVersion
255235
if (shouldAnimate) {
256-
addedMarkers.add(AddedMarker(key, marker, animation))
236+
val targetAlpha = element.descriptor.opacity?.toFloat() ?: 1f
237+
addedMarkers.add(AddedMarker(key, marker, animation, targetAlpha))
257238
remainingAnimationBudget -= 1
258239
}
259240
}
@@ -276,19 +257,18 @@ class MapOverlayController(
276257
markerVersions[key] = element.renderVersion
277258
clusterByKey[key] = element
278259
if (shouldAnimate) {
279-
addedMarkers.add(AddedMarker(key, marker, animation))
260+
addedMarkers.add(AddedMarker(key, marker, animation, targetAlpha = 1f))
280261
remainingAnimationBudget -= 1
281262
}
282263
}
283264
}
284265
}
285266
}
286267

287-
for (element in retained) {
268+
for (element in diff.retained) {
288269
val key = element.diffKey
289270
val marker = markers[key] ?: continue
290271
cancelEnteringAnimation(key)
291-
marker.alpha = 1f
292272
when (element) {
293273
is ClusterElement.Single -> {
294274
marker.tag = element.descriptor.id
@@ -303,6 +283,7 @@ class MapOverlayController(
303283
clusterByKey.remove(key)
304284
}
305285
is ClusterElement.Cluster -> {
286+
marker.alpha = 1f
306287
marker.position = element.position
307288
marker.setIcon(iconFactory.icon(element.count))
308289
clusterByKey[key] = element
@@ -348,7 +329,7 @@ class MapOverlayController(
348329
val localElapsed = elapsed - (animatedMarker.animation.delayMs - startDelay)
349330
val progress = (localElapsed.toFloat() / animatedMarker.animation.durationMs.toFloat())
350331
.coerceIn(0f, 1f)
351-
animatedMarker.marker.alpha = progress
332+
animatedMarker.marker.alpha = progress * animatedMarker.targetAlpha
352333
}
353334
}
354335
addListener(object : AnimatorListenerAdapter() {
@@ -369,7 +350,7 @@ class MapOverlayController(
369350

370351
private fun revealAnimatedMarkers(animated: List<AddedMarker>) {
371352
animated.forEach { animatedMarker ->
372-
animatedMarker.marker.alpha = 1f
353+
animatedMarker.marker.alpha = animatedMarker.targetAlpha
373354
}
374355
}
375356

@@ -422,14 +403,14 @@ class MapOverlayController(
422403
markers[key] = marker
423404
markerIconFactory.applyVisualProps(descriptor, marker, key)
424405
markerVersions[key] = element.renderVersion
425-
animateEntering(listOf(AddedMarker(key, marker, animation)))
406+
val targetAlpha = descriptor.opacity?.toFloat() ?: 1f
407+
animateEntering(listOf(AddedMarker(key, marker, animation, targetAlpha)))
426408
}
427409
},
428410
update = { marker, descriptor ->
429411
val element = ClusterElement.Single(descriptor)
430412
val key = "s:" + descriptor.id
431413
(marker.tag as? String)?.let { cancelEnteringAnimation(it) }
432-
marker.alpha = 1f
433414
marker.tag = descriptor.id
434415
marker.position = LatLng(
435416
descriptor.coordinate.latitude,
@@ -495,7 +476,6 @@ class MapOverlayController(
495476
if (usesViewportPipeline()) {
496477
refreshViewportMarkers(
497478
animateEntering = true,
498-
updateRetained = true,
499479
maxAnimatedMarkers = MAX_LIVE_ANIMATED_MARKERS_PER_DIFF,
500480
)
501481
}
@@ -640,5 +620,6 @@ class MapOverlayController(
640620
val key: String,
641621
val marker: Marker,
642622
val animation: ResolvedOverlayEnteringAnimation,
623+
val targetAlpha: Float,
643624
)
644625
}

‎package/android/src/main/java/com/margelo/nitro/nitromaps/MarkerClusterEngine.kt‎

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,7 @@ internal sealed interface ClusterElement {
1414

1515
data class Single(val descriptor: MarkerDescriptor) : ClusterElement {
1616
override val diffKey: String get() = "s:" + descriptor.id
17-
override val renderVersion: Long = renderSignature(
18-
"single",
19-
descriptor.id,
20-
descriptor.coordinate.latitude,
21-
descriptor.coordinate.longitude,
22-
descriptor.title,
23-
descriptor.subtitle,
24-
descriptor.draggable,
25-
descriptor.clusterable,
26-
)
17+
override val renderVersion: Long = descriptor.displayedIdentityVersion()
2718
}
2819

2920
data class Cluster(
@@ -49,14 +40,6 @@ internal sealed interface ClusterElement {
4940
}
5041
}
5142

52-
private fun renderSignature(vararg parts: Any?): Long {
53-
var hash = -3750763034362895579L
54-
for (part in parts) {
55-
hash = 1099511628211L * hash + (part?.hashCode()?.toLong() ?: 0L)
56-
}
57-
return hash
58-
}
59-
6043
/**
6144
* Grid-based marker clustering computed in geographic space.
6245
*
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.margelo.nitro.nitromaps
2+
3+
/** Displayed-marker identity. Omits `enteringAnimation`; keep in sync with the Swift hasher. */
4+
internal fun MarkerDescriptor.displayedIdentityVersion(): Long =
5+
renderSignature(
6+
id,
7+
coordinate.latitude,
8+
coordinate.longitude,
9+
title,
10+
subtitle,
11+
draggable,
12+
clusterable,
13+
image?.uri,
14+
image?.width,
15+
image?.height,
16+
image?.scale,
17+
anchor?.x,
18+
anchor?.y,
19+
centerOffset?.x,
20+
centerOffset?.y,
21+
rotation,
22+
flat,
23+
opacity,
24+
)
Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
11
package com.margelo.nitro.nitromaps
22

3-
internal fun Array<MarkerDescriptor>?.markersFingerprint(): Int {
3+
internal fun MarkerDescriptor.fingerprint(): Long =
4+
renderSignature(
5+
displayedIdentityVersion(),
6+
enteringAnimation?.kind,
7+
enteringAnimation?.duration,
8+
enteringAnimation?.delay,
9+
enteringAnimation?.reduceMotion,
10+
)
11+
12+
internal fun Array<MarkerDescriptor>?.markersFingerprint(): Long {
413
if (this.isNullOrEmpty()) {
5-
return 0
14+
return 0L
615
}
716

8-
var hash = size
17+
var hash = size.toLong()
918
for (descriptor in this) {
10-
hash = 31 * hash + descriptor.hashCode()
19+
hash = 31L * hash + descriptor.fingerprint()
1120
}
1221
return hash
1322
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.margelo.nitro.nitromaps
2+
3+
internal data class MarkerRenderDiff(
4+
val removedKeys: Set<String>,
5+
val added: List<ClusterElement>,
6+
val retained: List<ClusterElement>,
7+
)
8+
9+
internal fun computeMarkerRenderDiff(
10+
target: List<ClusterElement>,
11+
displayed: Map<String, Long>,
12+
): MarkerRenderDiff {
13+
val nextKeys = HashSet<String>(target.size)
14+
val added = ArrayList<ClusterElement>()
15+
val retained = ArrayList<ClusterElement>()
16+
17+
for (element in target) {
18+
val key = element.diffKey
19+
if (!nextKeys.add(key)) {
20+
continue
21+
}
22+
val displayedVersion = displayed[key]
23+
if (displayedVersion == null) {
24+
added.add(element)
25+
} else if (displayedVersion != element.renderVersion) {
26+
retained.add(element)
27+
}
28+
}
29+
30+
return MarkerRenderDiff(
31+
removedKeys = displayed.keys - nextKeys,
32+
added = added,
33+
retained = retained,
34+
)
35+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.margelo.nitro.nitromaps
2+
3+
/**
4+
* Absent values hash outside the `Int` range so no present value can collide with them.
5+
* Without this, `null` and `0.0` are indistinguishable, because `0.0.hashCode() == 0`.
6+
*/
7+
private const val ABSENT_HASH = 0x1_0000_0000L
8+
9+
/** Stable fold over the given parts, used to version render state for diffing. */
10+
internal fun renderSignature(vararg parts: Any?): Long {
11+
var hash = -3750763034362895579L
12+
for (part in parts) {
13+
hash = 1099511628211L * hash + (part?.hashCode()?.toLong() ?: ABSENT_HASH)
14+
}
15+
return hash
16+
}

0 commit comments

Comments
 (0)