feat: Add image and icon props to Marker component#65
feat: Add image and icon props to Marker component#65
Conversation
Agent-Logs-Url: https://github.com/dacoto/maps/sessions/b3ab895d-16b6-436b-a8e9-1e4d642c27c6 Co-authored-by: dacoto <16915053+dacoto@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds image and icon props to Marker to support rendering a marker icon from an ImageSourcePropType (matching react-native-maps), across native (Apple/Google), Android Google Maps, and Web Google Maps.
Changes:
- Extend Marker props and Fabric native props with
image/icon. - Implement image/icon rendering paths on Web, iOS (Apple Maps + Google Maps), and Android (Google Maps), including basic caching and scaling.
- Document
image/iconusage indocs/MARKER.md.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/fabric/LuggMarkerViewNativeComponent.ts | Adds image/icon string props to the Fabric native component interface. |
| src/components/Marker.types.ts | Exposes image/icon in the public MarkerProps type. |
| src/components/Marker.tsx | Resolves asset sources to URIs and forwards image/icon to native. |
| src/components/Marker.web.tsx | Resolves an asset URI and renders an <img> inside the Web marker when image/icon is provided. |
| ios/core/GoogleMapProvider.mm | Adds Google Maps marker icon loading/scaling from imageUri/iconUri. |
| ios/core/AppleMapProvider.mm | Adds Apple Maps annotation view path for imageUri markers with scaling/rotation/anchor support. |
| ios/LuggMarkerView.mm | Tracks imageUri/iconUri props and maintains per-view cached images. |
| ios/LuggMarkerView.h | Exposes image/icon URI + cache properties to map providers. |
| android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt | Adds Android Google Maps icon loading/scaling from imageUri/iconUri. |
| android/src/main/java/com/luggmaps/LuggMarkerViewManager.kt | Wires image/icon props from React to the native view. |
| android/src/main/java/com/luggmaps/LuggMarkerView.kt | Stores imageUri/iconUri and bitmap caches, clears caches on URI changes/recycle. |
| docs/MARKER.md | Documents image/icon props and example usage. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| image, | ||
| icon, | ||
| } = this.props; | ||
|
|
||
| const imageUri = image ? Image.resolveAssetSource(image)?.uri ?? '' : ''; | ||
| const iconUri = icon ? Image.resolveAssetSource(icon)?.uri ?? '' : ''; | ||
|
|
There was a problem hiding this comment.
image/icon are intended to replace custom marker children, but this component still renders {children} unconditionally. Because native providers check hasCustomView first, passing children alongside image/icon will cause the custom view to win and the image/icon to be ignored. Consider rendering children only when neither image nor icon is provided (or change native precedence to prefer icon/image).
| /** | ||
| * A custom image to be used as the marker's icon. Only local image resources are allowed. | ||
| * When set, takes the place of custom children as the marker icon. | ||
| */ | ||
| image?: ImageSourcePropType; | ||
| /** | ||
| * Marker icon to render (equivalent to icon property of GMSMarker). | ||
| * Only local image resources are allowed. Takes priority over `image` on Google Maps. | ||
| * @platform google | ||
| */ | ||
| icon?: ImageSourcePropType; |
There was a problem hiding this comment.
The JSDoc says “Only local image resources are allowed”, but the prop types are ImageSourcePropType (which includes { uri: ... }) and the implementation resolves/loads the resulting URI. This effectively allows remote URLs, which contradicts the docs and can trigger network fetches. If local-only is required, consider narrowing the types (e.g. to ImageRequireSource) and/or adding a runtime guard that rejects non-require() sources.
| BOOL isIconUri = markerView.hasIconUri; | ||
| __weak LuggMarkerView *weakMarkerView = markerView; | ||
| __weak GMSAdvancedMarker *weakMarker = marker; | ||
| dispatch_async( | ||
| dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ | ||
| NSData *data = [NSData dataWithContentsOfURL:url]; | ||
| UIImage *image = data ? [UIImage imageWithData:data] : nil; | ||
| dispatch_async(dispatch_get_main_queue(), ^{ | ||
| LuggMarkerView *strongMarkerView = weakMarkerView; | ||
| GMSAdvancedMarker *strongMarker = weakMarker; | ||
| if (!strongMarkerView || !strongMarker || !image) return; | ||
| if (isIconUri) { | ||
| strongMarkerView.cachedIcon = image; | ||
| } else { | ||
| strongMarkerView.cachedImage = image; | ||
| } | ||
| strongMarker.icon = | ||
| [self scaleUIImage:image scale:strongMarkerView.scale]; | ||
| }); |
There was a problem hiding this comment.
loadMarkerImageUri can race with prop updates/recycling: it doesn’t verify that the loaded image still corresponds to the current imageUri/iconUri (or that markerView.marker is still this marker) before caching/applying. This can display stale icons after rapid icon/image changes or view recycling. Capture the expected URI and, on the main thread, bail out unless the markerView’s current URI (and marker instance) still matches before setting cached* and marker.icon.
| - (void)loadMarkerImageUri:(NSString *)uri | ||
| forMarkerView:(LuggMarkerView *)markerView | ||
| annotationView:(MKAnnotationView *)annotationView { | ||
| NSURL *url = [NSURL URLWithString:uri]; | ||
| if (!url) return; | ||
|
|
||
| __weak LuggMarkerView *weakMarkerView = markerView; | ||
| __weak MKAnnotationView *weakAnnotationView = annotationView; | ||
| dispatch_async( | ||
| dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ | ||
| NSData *data = [NSData dataWithContentsOfURL:url]; | ||
| UIImage *image = data ? [UIImage imageWithData:data] : nil; | ||
| dispatch_async(dispatch_get_main_queue(), ^{ | ||
| LuggMarkerView *strongMarkerView = weakMarkerView; | ||
| MKAnnotationView *strongAnnotationView = weakAnnotationView; | ||
| if (!strongMarkerView || !strongAnnotationView || !image) return; | ||
| strongMarkerView.cachedImage = image; | ||
| [self applyImageMarkerStyle:strongMarkerView | ||
| annotationView:strongAnnotationView]; | ||
| }); |
There was a problem hiding this comment.
loadMarkerImageUri can apply a stale image after the marker is reused or imageUri changes because it doesn’t check that markerView.imageUri is still equal to the requested uri before setting cachedImage and updating the annotation view. Add a guard on the main thread to ensure the URI still matches (and the annotationView is still the one associated with this marker) before applying.
| private fun applyImageIconToMarker(markerView: LuggMarkerView, marker: AdvancedMarker) { | ||
| val isIcon = markerView.hasIconUri | ||
| val cached = if (isIcon) markerView.cachedIconBitmap else markerView.cachedImageBitmap | ||
| if (cached != null) { | ||
| val scaled = scaleBitmap(cached, markerView.scale) | ||
| marker.setIcon(BitmapDescriptorFactory.fromBitmap(scaled)) | ||
| return | ||
| } | ||
| val uri = if (isIcon) markerView.iconUri else markerView.imageUri | ||
| Thread { | ||
| try { | ||
| val bitmap = loadBitmapFromUri(uri) | ||
| if (bitmap != null) { | ||
| mapView?.post { | ||
| if (isIcon) { | ||
| markerView.cachedIconBitmap = bitmap | ||
| } else { | ||
| markerView.cachedImageBitmap = bitmap | ||
| } | ||
| val scaled = scaleBitmap(bitmap, markerView.scale) | ||
| marker.setIcon(BitmapDescriptorFactory.fromBitmap(scaled)) | ||
| } | ||
| } | ||
| } catch (_: Exception) {} | ||
| }.start() | ||
| } |
There was a problem hiding this comment.
applyImageIconToMarker spawns a raw Thread and posts a result without verifying the marker is still current or that the URI hasn’t changed. If the marker view is recycled or imageUri/iconUri updates quickly, a stale load can overwrite the newer icon. Capture the requested uri and, before caching/setting the icon on the UI thread, confirm markerView.marker === marker and that the current URI still matches.
| connection.connect() | ||
| return try { | ||
| android.graphics.BitmapFactory.decodeStream(connection.inputStream) |
There was a problem hiding this comment.
loadBitmapFromUri uses HttpURLConnection without connect/read timeouts and doesn’t close the inputStream explicitly. This can hang background threads under poor networks and potentially leak resources. Set reasonable connectTimeout/readTimeout and wrap connection.inputStream in .use { ... } before disconnecting.
| connection.connect() | |
| return try { | |
| android.graphics.BitmapFactory.decodeStream(connection.inputStream) | |
| connection.connectTimeout = 10_000 | |
| connection.readTimeout = 10_000 | |
| connection.connect() | |
| return try { | |
| connection.inputStream.use { stream -> | |
| android.graphics.BitmapFactory.decodeStream(stream) | |
| } |
| if (cached != null) { | ||
| val scaled = scaleBitmap(cached, markerView.scale) | ||
| marker.setIcon(BitmapDescriptorFactory.fromBitmap(scaled)) | ||
| return |
There was a problem hiding this comment.
When cached is present, scaleBitmap(cached, markerView.scale) allocates a new scaled bitmap every sync. Since syncMarkerView can run frequently even when scale hasn’t changed, this can create avoidable allocations and GC pressure. Consider caching the scaled bitmap/descriptor per (uri, scale) or only rescaling when scale changes.
Summary
Adds
imageandiconprops toMarker, matching thereact-native-mapsAPI. Both accept localImageSourcePropTyperesources and display them directly as the marker icon without requiring custom children views.Type of Change
Test Plan
<Marker image={require('./assets/pin.png')}>on iOS (Apple Maps & Google Maps), Android, and Web — icon appeared correctly anchored.icontakes priority overimageon Google Maps (Android & iOS).http://) and production bundle (asset://) URL schemes on Android.anchor,rotation, andscaletogether withimage— transforms applied as expected on all targets.Screenshots / Videos
Checklist