Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions android/src/main/java/com/luggmaps/LuggMarkerView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ class LuggMarkerView(context: Context) : ReactViewGroup(context) {
var draggable: Boolean = false
private set

var imageUri: String = ""
private set

var iconUri: String = ""
private set

var cachedImageBitmap: android.graphics.Bitmap? = null
var cachedIconBitmap: android.graphics.Bitmap? = null

var isDragging: Boolean = false

var didLayout: Boolean = false
Expand All @@ -73,6 +82,12 @@ class LuggMarkerView(context: Context) : ReactViewGroup(context) {
val hasCustomView: Boolean
get() = contentView.isNotEmpty()

val hasImageUri: Boolean
get() = imageUri.isNotEmpty()

val hasIconUri: Boolean
get() = iconUri.isNotEmpty()

val contentView: ReactViewGroup = ReactViewGroup(context)

var onUpdate: (() -> Unit)? = null
Expand Down Expand Up @@ -285,6 +300,20 @@ class LuggMarkerView(context: Context) : ReactViewGroup(context) {
this.draggable = draggable
}

fun setImageUri(uri: String) {
if (imageUri != uri) {
imageUri = uri
cachedImageBitmap = null
}
}

fun setIconUri(uri: String) {
if (iconUri != uri) {
iconUri = uri
cachedIconBitmap = null
}
}

fun emitPressEvent(x: Float, y: Float) {
dispatchEvent(MarkerPressEvent(this, latitude, longitude, x, y))
}
Expand Down Expand Up @@ -324,6 +353,8 @@ class LuggMarkerView(context: Context) : ReactViewGroup(context) {
didLayout = false
calloutView = null
delegate = null
cachedImageBitmap = null
cachedIconBitmap = null
contentView.removeAllViews()
}
}
10 changes: 10 additions & 0 deletions android/src/main/java/com/luggmaps/LuggMarkerViewManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ class LuggMarkerViewManager :
view.setDraggable(value)
}

@ReactProp(name = "image")
override fun setImage(view: LuggMarkerView, value: String?) {
view.setImageUri(value ?: "")
}

@ReactProp(name = "icon")
override fun setIcon(view: LuggMarkerView, value: String?) {
view.setIconUri(value ?: "")
}

override fun showCallout(view: LuggMarkerView) {
view.showCallout()
}
Expand Down
56 changes: 56 additions & 0 deletions android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,7 @@ class GoogleMapProvider(private val context: Context) :
isDraggable = markerView.draggable
}

val marker = markerView.marker ?: return
if (markerView.hasCustomView) {
if (markerView.scaleChanged) {
markerView.applyScaleToMarker()
Expand All @@ -679,6 +680,8 @@ class GoogleMapProvider(private val context: Context) :
if (!markerView.rasterize) {
positionLiveMarker(markerView)
}
} else if (markerView.hasIconUri || markerView.hasImageUri) {
applyImageIconToMarker(markerView, marker)
}
}

Expand Down Expand Up @@ -712,9 +715,62 @@ class GoogleMapProvider(private val context: Context) :
} else {
showLiveMarker(markerView)
}
} else if (markerView.hasIconUri || markerView.hasImageUri) {
applyImageIconToMarker(markerView, marker)
}
}

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
Comment on lines +726 to +729
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
}
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()
}
Comment on lines +723 to +748
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

private fun loadBitmapFromUri(uri: String): android.graphics.Bitmap? {
if (uri.startsWith("asset://")) {
val assetPath = uri.removePrefix("asset://")
return context.assets.open(assetPath).use { stream ->
android.graphics.BitmapFactory.decodeStream(stream)
}
}
val connection = java.net.URL(uri).openConnection() as java.net.HttpURLConnection
connection.instanceFollowRedirects = true
connection.connect()
return try {
android.graphics.BitmapFactory.decodeStream(connection.inputStream)
Comment on lines +759 to +761
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)
}

Copilot uses AI. Check for mistakes.
} finally {
connection.disconnect()
}
}

private fun scaleBitmap(bitmap: android.graphics.Bitmap, scale: Float): android.graphics.Bitmap {
if (scale == 1f) return bitmap
val w = (bitmap.width * scale).toInt().coerceAtLeast(1)
val h = (bitmap.height * scale).toInt().coerceAtLeast(1)
return android.graphics.Bitmap.createScaledBitmap(bitmap, w, h, true)
}

// Workaround: AdvancedMarker.iconView is buggy on Android, so we manually add the custom
// content view to the wrapper and position it via screen projection instead. The underlying
// marker uses a transparent bitmap matching the content size so taps still trigger onMarkerClick.
Expand Down
25 changes: 25 additions & 0 deletions docs/MARKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import { MapView, Marker } from '@lugg/maps';
| `rasterize` | `boolean` | `true` | Rasterize custom marker view to bitmap (iOS/Android only) |
| `centerOnPress` | `boolean` | `true` | Whether the map centers on the marker when pressed |
| `draggable` | `boolean` | `false` | Whether the marker can be dragged by the user |
| `image` | `ImageSource` | - | A custom image to use as the marker icon. Only local image resources are allowed |
| `icon` | `ImageSource` | - | Marker icon (equivalent to `icon` on `GMSMarker`). Only local image resources are allowed. Takes priority over `image` on Google Maps. **Google Maps only** |
| `onPress` | `(event: MarkerPressEvent) => void` | - | Called when the marker is pressed. Event includes `coordinate` and `point` |
| `onDragStart` | `(event: MarkerDragEvent) => void` | - | Called when marker drag starts. Event includes `coordinate` and `point` |
| `onDragChange` | `(event: MarkerDragEvent) => void` | - | Called continuously as the marker is dragged. Event includes `coordinate` and `point` |
Expand Down Expand Up @@ -91,6 +93,29 @@ Set `draggable` to enable marker dragging. Use the drag event callbacks to track
/>
```

## Image Markers

Use the `image` prop to display a local image as the marker icon instead of the default pin.

```tsx
<Marker
coordinate={{ latitude: 37.7749, longitude: -122.4194 }}
image={require('./assets/my-marker.png')}
anchor={{ x: 0.5, y: 1 }}
/>
```

On Google Maps you can use `icon` instead, which maps directly to the native `GMSMarker.icon` property:

```tsx
<Marker
coordinate={{ latitude: 37.7749, longitude: -122.4194 }}
icon={require('./assets/my-marker.png')}
/>
```

> **Note:** Only local image resources (via `require()`) are supported. Remote URLs are not allowed.

## Custom Markers

Use the `children` prop to render a custom marker view. The `anchor` prop controls the point that is placed at the coordinate.
Expand Down
6 changes: 6 additions & 0 deletions ios/LuggMarkerView.h
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,15 @@ NS_ASSUME_NONNULL_BEGIN
@property(nonatomic, readonly) BOOL centerOnPress;
@property(nonatomic, readonly) BOOL draggable;
@property(nonatomic, readonly) BOOL hasCustomView;
@property(nonatomic, readonly) BOOL hasImageUri;
@property(nonatomic, readonly) BOOL hasIconUri;
@property(nonatomic, readonly) BOOL didLayout;
@property(nonatomic, readonly) UIView *iconView;
@property(nonatomic, readonly, nullable) LuggCalloutView *calloutView;
@property(nonatomic, readonly, nullable) NSString *imageUri;
@property(nonatomic, readonly, nullable) NSString *iconUri;
@property(nonatomic, strong, nullable) UIImage *cachedImage;
@property(nonatomic, strong, nullable) UIImage *cachedIcon;
@property(nonatomic, weak, nullable) id<LuggMarkerViewDelegate> delegate;
@property(nonatomic, strong, nullable) NSObject *marker;

Expand Down
34 changes: 34 additions & 0 deletions ios/LuggMarkerView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ @implementation LuggMarkerView {
BOOL _didLayout;
UIView *_iconView;
LuggCalloutView *_calloutView;
NSString *_imageUri;
NSString *_iconUri;
}

+ (ComponentDescriptorProvider)componentDescriptorProvider {
Expand Down Expand Up @@ -82,6 +84,18 @@ - (void)updateProps:(Props::Shared const &)props
_rasterize = newViewProps.rasterize;
_centerOnPress = newViewProps.centerOnPress;
_draggable = newViewProps.draggable;

NSString *newImageUri = [NSString stringWithUTF8String:newViewProps.image.c_str()];
if (![_imageUri isEqualToString:newImageUri]) {
_imageUri = newImageUri;
self.cachedImage = nil;
}

NSString *newIconUri = [NSString stringWithUTF8String:newViewProps.icon.c_str()];
if (![_iconUri isEqualToString:newIconUri]) {
_iconUri = newIconUri;
self.cachedIcon = nil;
}
}

- (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask {
Expand Down Expand Up @@ -189,6 +203,14 @@ - (BOOL)hasCustomView {
return _iconView.subviews.count > 0;
}

- (BOOL)hasImageUri {
return _imageUri.length > 0;
}

- (BOOL)hasIconUri {
return _iconUri.length > 0;
}

- (BOOL)didLayout {
return _didLayout;
}
Expand All @@ -201,6 +223,14 @@ - (LuggCalloutView *)calloutView {
return _calloutView;
}

- (NSString *)imageUri {
return _imageUri;
}

- (NSString *)iconUri {
return _iconUri;
}

- (UIImage *)createIconImage {
CGSize size = _iconView.bounds.size;
if (size.width <= 0 || size.height <= 0) {
Expand Down Expand Up @@ -311,6 +341,10 @@ - (void)prepareForRecycle {
_calloutView = nil;
self.marker = nil;
self.delegate = nil;
self.cachedImage = nil;
self.cachedIcon = nil;
_imageUri = nil;
_iconUri = nil;
[self resetIconViewTransform];
for (UIView *subview in _iconView.subviews) {
[subview removeFromSuperview];
Expand Down
80 changes: 80 additions & 0 deletions ios/core/AppleMapProvider.mm
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,21 @@ - (MKAnnotationView *)mapView:(MKMapView *)mapView
}

if (!markerView.hasCustomView) {
if (markerView.hasImageUri) {
MKAnnotationView *annotationView =
[[MKAnnotationView alloc] initWithAnnotation:annotation
reuseIdentifier:nil];
annotationView.canShowCallout = YES;
annotationView.displayPriority = MKFeatureDisplayPriorityRequired;
annotationView.layer.zPosition = markerView.zIndex;
annotationView.zPriority = markerView.zIndex;
annotationView.draggable = markerView.draggable;
[self applyCalloutView:markerView annotationView:annotationView];
[self addCenterTapGesture:annotationView];
markerAnnotation.annotationView = annotationView;
[self applyImageMarkerStyle:markerView annotationView:annotationView];
return annotationView;
}
LuggMarkerAnnotationView *markerAnnotationView =
[[LuggMarkerAnnotationView alloc] initWithAnnotation:annotation
reuseIdentifier:nil];
Expand Down Expand Up @@ -1225,6 +1240,66 @@ - (void)syncMarkerView:(LuggMarkerView *)markerView {
[self markerViewDidUpdate:markerView];
}

- (void)applyImageMarkerStyle:(LuggMarkerView *)markerView
annotationView:(MKAnnotationView *)annotationView {
CGFloat scale = markerView.scale;
CGPoint anchor = markerView.anchor;

UIImage *cached = markerView.cachedImage;
if (cached) {
UIImage *image = [self scaleUIImage:cached scale:scale];
annotationView.image = image;
CGFloat w = image.size.width;
CGFloat h = image.size.height;
annotationView.bounds = CGRectMake(0, 0, w, h);
annotationView.centerOffset =
CGPointMake(w * (0.5 - anchor.x), h * (0.5 - anchor.y));
annotationView.transform =
CGAffineTransformMakeRotation(markerView.rotate * M_PI / 180.0);
} else {
[self loadMarkerImageUri:markerView.imageUri
forMarkerView:markerView
annotationView:annotationView];
}
}

- (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];
});
Comment on lines +1266 to +1285
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
});
}

- (UIImage *)scaleUIImage:(UIImage *)image scale:(CGFloat)scale {
if (scale == 1.0) return image;
CGSize size =
CGSizeMake(image.size.width * scale, image.size.height * scale);
UIGraphicsImageRendererFormat *format =
[UIGraphicsImageRendererFormat defaultFormat];
format.scale = image.scale;
UIGraphicsImageRenderer *renderer =
[[UIGraphicsImageRenderer alloc] initWithSize:size format:format];
return [renderer imageWithActions:^(UIGraphicsImageRendererContext *ctx) {
[image drawInRect:CGRectMake(0, 0, size.width, size.height)];
}];
}

- (void)applyMarkerStyle:(LuggMarkerView *)markerView
annotationView:(MKAnnotationView *)annotationView {
annotationView.transform = CGAffineTransformIdentity;
Expand Down Expand Up @@ -1298,6 +1373,11 @@ - (void)updateAnnotationViewFrame:(AppleMarkerAnnotation *)annotation {
if (!annotationView || !markerView)
return;

if (markerView.hasImageUri && !markerView.hasCustomView) {
[self applyImageMarkerStyle:markerView annotationView:annotationView];
return;
}

[self applyMarkerStyle:markerView annotationView:annotationView];
}

Expand Down
Loading
Loading