From 82ddacf54c597e68997377ebe80d5dfe544eb593 Mon Sep 17 00:00:00 2001 From: lodev09 Date: Fri, 13 Mar 2026 17:34:37 +0800 Subject: [PATCH 1/2] fix(ios): callout content not visible on Apple Maps Use custom LuggCalloutContentView with intrinsicContentSize override so Auto Layout can size detailCalloutAccessoryView properly. --- ios/LuggCalloutView.mm | 115 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 ios/LuggCalloutView.mm diff --git a/ios/LuggCalloutView.mm b/ios/LuggCalloutView.mm new file mode 100644 index 0000000..0227233 --- /dev/null +++ b/ios/LuggCalloutView.mm @@ -0,0 +1,115 @@ +#import "LuggCalloutView.h" + +#import +#import +#import +#import + +#import "RCTFabricComponentsPlugins.h" + +using namespace facebook::react; + +@interface LuggCalloutContentView : UIView +- (void)updateContentSize; +@end + +@implementation LuggCalloutContentView + +- (CGSize)intrinsicContentSize { + CGFloat width = 0; + CGFloat height = 0; + for (UIView *subview in self.subviews) { + width = MAX(width, CGRectGetMaxX(subview.frame)); + height = MAX(height, CGRectGetMaxY(subview.frame)); + } + if (width > 0 && height > 0) { + return CGSizeMake(width, height); + } + return CGSizeMake(UIViewNoIntrinsicMetric, UIViewNoIntrinsicMetric); +} + +- (void)updateContentSize { + CGSize size = [self intrinsicContentSize]; + if (size.width > 0 && size.height > 0) { + self.frame = CGRectMake(0, 0, size.width, size.height); + } + [self invalidateIntrinsicContentSize]; +} + +@end + +@interface LuggCalloutView () +@end + +@implementation LuggCalloutView { + LuggCalloutContentView *_contentView; +} + ++ (ComponentDescriptorProvider)componentDescriptorProvider { + return concreteComponentDescriptorProvider< + LuggCalloutViewComponentDescriptor>(); +} + +- (instancetype)initWithFrame:(CGRect)frame { + if (self = [super initWithFrame:frame]) { + static const auto defaultProps = + std::make_shared(); + _props = defaultProps; + + _contentView = [[LuggCalloutContentView alloc] init]; + _contentView.backgroundColor = [UIColor clearColor]; + + self.backgroundColor = [UIColor clearColor]; + self.hidden = YES; + } + + return self; +} + +- (void)mountChildComponentView: + (UIView *)childComponentView + index:(NSInteger)index { + [_contentView insertSubview:childComponentView atIndex:index]; + [_contentView updateContentSize]; +} + +- (void)unmountChildComponentView: + (UIView *)childComponentView + index:(NSInteger)index { + [childComponentView removeFromSuperview]; + [_contentView updateContentSize]; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + [_contentView updateContentSize]; +} + +- (BOOL)hasCustomContent { + return _contentView.subviews.count > 0; +} + +- (UIView *)contentView { + return _contentView; +} + +- (void)emitPressEvent { + if (!_eventEmitter) return; + + auto emitter = + std::static_pointer_cast(_eventEmitter); + emitter->onCalloutPress({}); +} + +- (void)prepareForRecycle { + [super prepareForRecycle]; + for (UIView *subview in _contentView.subviews) { + [subview removeFromSuperview]; + } +} + +Class LuggCalloutViewCls(void) { + return LuggCalloutView.class; +} + +@end From 2b0b9d0a7fc35095f570a3f5382ab7c336ee3c8b Mon Sep 17 00:00:00 2001 From: lodev09 Date: Fri, 13 Mar 2026 17:53:56 +0800 Subject: [PATCH 2/2] feat: add Callout component Marker callout support for iOS (Apple Maps + Google Maps), Android (Google Maps), and Web. --- README.md | 1 + .../main/java/com/luggmaps/LuggCalloutView.kt | 66 +++++++++++++ .../com/luggmaps/LuggCalloutViewManager.kt | 33 +++++++ .../main/java/com/luggmaps/LuggMarkerView.kt | 32 ++++++- .../src/main/java/com/luggmaps/LuggPackage.kt | 2 +- .../com/luggmaps/core/GoogleMapProvider.kt | 24 ++++- .../com/luggmaps/events/CalloutPressEvent.kt | 14 +++ docs/CALLOUT.md | 51 ++++++++++ docs/MARKER.md | 14 +++ example/bare/ios/Podfile.lock | 4 +- example/shared/src/components/Map.tsx | 66 ++++++++++++- example/shared/src/components/MarkerIcon.tsx | 2 + example/shared/src/components/MarkerImage.tsx | 2 + example/shared/src/components/MarkerText.tsx | 2 + ios/LuggCalloutView.h | 15 +++ ios/LuggMarkerView.h | 2 + ios/LuggMarkerView.mm | 19 +++- ios/core/AppleMapProvider.mm | 35 +++++++ ios/core/GoogleMapProvider.mm | 37 ++++++++ package.json | 1 + src/components/Callout.tsx | 28 ++++++ src/components/Callout.types.ts | 12 +++ src/components/Callout.web.tsx | 3 + src/components/Marker.web.tsx | 93 ++++++++++++++----- src/components/index.ts | 2 + src/components/index.web.ts | 2 + src/fabric/LuggCalloutViewNativeComponent.ts | 11 +++ src/index.ts | 2 + src/index.web.ts | 2 + 29 files changed, 540 insertions(+), 37 deletions(-) create mode 100644 android/src/main/java/com/luggmaps/LuggCalloutView.kt create mode 100644 android/src/main/java/com/luggmaps/LuggCalloutViewManager.kt create mode 100644 android/src/main/java/com/luggmaps/events/CalloutPressEvent.kt create mode 100644 docs/CALLOUT.md create mode 100644 ios/LuggCalloutView.h create mode 100644 src/components/Callout.tsx create mode 100644 src/components/Callout.types.ts create mode 100644 src/components/Callout.web.tsx create mode 100644 src/fabric/LuggCalloutViewNativeComponent.ts diff --git a/README.md b/README.md index 4b1ca2a..bd8e866 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ import { MapView, Marker, Polyline, Polygon } from '@lugg/maps'; - [MapView](docs/MAPVIEW.md) - Main map component - [Marker](docs/MARKER.md) - Map markers +- [Callout](docs/CALLOUT.md) - Marker callouts - [Polyline](docs/POLYLINE.md) - Draw lines on the map - [Polygon](docs/POLYGON.md) - Draw filled shapes on the map - [GeoJson](docs/GEOJSON.md) - Render GeoJSON data on the map diff --git a/android/src/main/java/com/luggmaps/LuggCalloutView.kt b/android/src/main/java/com/luggmaps/LuggCalloutView.kt new file mode 100644 index 0000000..1fb3bdc --- /dev/null +++ b/android/src/main/java/com/luggmaps/LuggCalloutView.kt @@ -0,0 +1,66 @@ +package com.luggmaps + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import android.widget.ImageView +import androidx.core.graphics.createBitmap +import androidx.core.view.isNotEmpty +import com.facebook.react.views.view.ReactViewGroup +import com.luggmaps.events.CalloutPressEvent +import com.luggmaps.extensions.dispatchEvent + +class LuggCalloutView(context: Context) : ReactViewGroup(context) { + val contentView: ReactViewGroup = ReactViewGroup(context) + + val hasCustomContent: Boolean + get() = contentView.isNotEmpty() + + init { + visibility = GONE + } + + override fun addView(child: View, index: Int) { + contentView.addView(child, index) + } + + override fun removeView(child: View) { + contentView.removeView(child) + } + + override fun removeViewAt(index: Int) { + contentView.removeViewAt(index) + } + + override fun getChildCount(): Int = contentView.childCount + + override fun getChildAt(index: Int): View? = contentView.getChildAt(index) + + fun createContentBitmap(): Bitmap? { + var maxWidth = 0 + var maxHeight = 0 + for (i in 0 until contentView.childCount) { + val child = contentView.getChildAt(i) + val childRight = child.left + child.width + val childBottom = child.top + child.height + if (childRight > maxWidth) maxWidth = childRight + if (childBottom > maxHeight) maxHeight = childBottom + } + + if (maxWidth <= 0 || maxHeight <= 0) return null + + val bitmap = createBitmap(maxWidth, maxHeight) + val canvas = Canvas(bitmap) + contentView.draw(canvas) + return bitmap + } + + fun emitPressEvent() { + dispatchEvent(CalloutPressEvent(this)) + } + + fun onDropViewInstance() { + contentView.removeAllViews() + } +} diff --git a/android/src/main/java/com/luggmaps/LuggCalloutViewManager.kt b/android/src/main/java/com/luggmaps/LuggCalloutViewManager.kt new file mode 100644 index 0000000..23d6908 --- /dev/null +++ b/android/src/main/java/com/luggmaps/LuggCalloutViewManager.kt @@ -0,0 +1,33 @@ +package com.luggmaps + +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.ViewGroupManager +import com.facebook.react.uimanager.ViewManagerDelegate +import com.facebook.react.viewmanagers.LuggCalloutViewManagerDelegate +import com.facebook.react.viewmanagers.LuggCalloutViewManagerInterface + +@ReactModule(name = LuggCalloutViewManager.NAME) +class LuggCalloutViewManager : + ViewGroupManager(), + LuggCalloutViewManagerInterface { + private val delegate: ViewManagerDelegate = LuggCalloutViewManagerDelegate(this) + + override fun getDelegate(): ViewManagerDelegate = delegate + override fun getName(): String = NAME + override fun createViewInstance(context: ThemedReactContext): LuggCalloutView = LuggCalloutView(context) + + override fun getExportedCustomDirectEventTypeConstants(): Map = + mapOf( + "topCalloutPress" to mapOf("registrationName" to "onCalloutPress") + ) + + override fun onDropViewInstance(view: LuggCalloutView) { + super.onDropViewInstance(view) + view.onDropViewInstance() + } + + companion object { + const val NAME = "LuggCalloutView" + } +} diff --git a/android/src/main/java/com/luggmaps/LuggMarkerView.kt b/android/src/main/java/com/luggmaps/LuggMarkerView.kt index dc71480..4ee6d35 100644 --- a/android/src/main/java/com/luggmaps/LuggMarkerView.kt +++ b/android/src/main/java/com/luggmaps/LuggMarkerView.kt @@ -72,6 +72,9 @@ class LuggMarkerView(context: Context) : ReactViewGroup(context) { val iconView: ReactViewGroup = ReactViewGroup(context) + var calloutView: LuggCalloutView? = null + private set + private fun measureIconViewBounds(): Pair { var maxWidth = 0 var maxHeight = 0 @@ -169,23 +172,41 @@ class LuggMarkerView(context: Context) : ReactViewGroup(context) { } override fun addView(child: View, index: Int) { - iconView.addView(child, index) + if (child is LuggCalloutView) { + calloutView = child + } else { + iconView.addView(child, index) + } didLayout = false } override fun removeView(child: View) { - iconView.removeView(child) + if (child is LuggCalloutView) { + calloutView = null + } else { + iconView.removeView(child) + } didLayout = false } override fun removeViewAt(index: Int) { - iconView.removeViewAt(index) + val child = getChildAt(index) + if (child is LuggCalloutView) { + calloutView = null + } else { + iconView.removeViewAt(index) + } didLayout = false } - override fun getChildCount(): Int = iconView.childCount + override fun getChildCount(): Int = + iconView.childCount + if (calloutView != null) 1 else 0 - override fun getChildAt(index: Int): View? = iconView.getChildAt(index) + override fun getChildAt(index: Int): View? { + if (index < iconView.childCount) return iconView.getChildAt(index) + if (index == iconView.childCount && calloutView != null) return calloutView + return null + } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) @@ -278,6 +299,7 @@ class LuggMarkerView(context: Context) : ReactViewGroup(context) { scaleUpdateRunnable?.let { removeCallbacks(it) } scaleUpdateRunnable = null didLayout = false + calloutView = null delegate = null iconView.removeAllViews() } diff --git a/android/src/main/java/com/luggmaps/LuggPackage.kt b/android/src/main/java/com/luggmaps/LuggPackage.kt index 4598a42..2c8d46b 100644 --- a/android/src/main/java/com/luggmaps/LuggPackage.kt +++ b/android/src/main/java/com/luggmaps/LuggPackage.kt @@ -6,5 +6,5 @@ import com.facebook.react.uimanager.ViewManager class LuggPackage : ReactPackage { override fun createViewManagers(reactContext: ReactApplicationContext): List> = - listOf(LuggMapViewManager(), LuggMarkerViewManager(), LuggMapWrapperViewManager(), LuggPolylineViewManager(), LuggPolygonViewManager()) + listOf(LuggMapViewManager(), LuggMarkerViewManager(), LuggCalloutViewManager(), LuggMapWrapperViewManager(), LuggPolylineViewManager(), LuggPolygonViewManager()) } diff --git a/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt b/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt index 9fc5486..a7ca7fb 100644 --- a/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt +++ b/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt @@ -3,6 +3,7 @@ package com.luggmaps.core import android.annotation.SuppressLint import android.content.Context import android.view.View +import android.widget.ImageView import com.facebook.react.uimanager.PixelUtil.dpToPx import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.gms.maps.GoogleMap @@ -38,7 +39,9 @@ class GoogleMapProvider(private val context: Context) : GoogleMap.OnMapLongClickListener, GoogleMap.OnPolygonClickListener, GoogleMap.OnMarkerClickListener, - GoogleMap.OnMarkerDragListener { + GoogleMap.OnMarkerDragListener, + GoogleMap.InfoWindowAdapter, + GoogleMap.OnInfoWindowClickListener { override var delegate: MapProviderDelegate? = null override val isMapReady: Boolean get() = _isMapReady @@ -120,6 +123,8 @@ class GoogleMapProvider(private val context: Context) : googleMap?.setOnPolygonClickListener(null) googleMap?.setOnMarkerClickListener(null) googleMap?.setOnMarkerDragListener(null) + googleMap?.setInfoWindowAdapter(null) + googleMap?.setOnInfoWindowClickListener(null) googleMap?.clear() googleMap = null _isMapReady = false @@ -143,6 +148,8 @@ class GoogleMapProvider(private val context: Context) : map.setOnPolygonClickListener(this) map.setOnMarkerClickListener(this) map.setOnMarkerDragListener(this) + map.setInfoWindowAdapter(this) + map.setOnInfoWindowClickListener(this) wrapperView?.touchEventHandler = { event -> if (event.action == android.view.MotionEvent.ACTION_DOWN) { @@ -244,6 +251,21 @@ class GoogleMapProvider(private val context: Context) : } } + override fun getInfoWindow(marker: Marker): View? = null + + override fun getInfoContents(marker: Marker): View? { + val markerView = markerToViewMap[marker] ?: return null + val calloutView = markerView.calloutView ?: return null + if (!calloutView.hasCustomContent) return null + + val bitmap = calloutView.createContentBitmap() ?: return null + return ImageView(context).apply { setImageBitmap(bitmap) } + } + + override fun onInfoWindowClick(marker: Marker) { + markerToViewMap[marker]?.calloutView?.emitPressEvent() + } + // endregion // region Props diff --git a/android/src/main/java/com/luggmaps/events/CalloutPressEvent.kt b/android/src/main/java/com/luggmaps/events/CalloutPressEvent.kt new file mode 100644 index 0000000..39f9f78 --- /dev/null +++ b/android/src/main/java/com/luggmaps/events/CalloutPressEvent.kt @@ -0,0 +1,14 @@ +package com.luggmaps.events + +import android.view.View +import com.facebook.react.bridge.Arguments +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.Event + +class CalloutPressEvent( + view: View +) : Event(UIManagerHelper.getSurfaceId(view), view.id) { + override fun getEventName() = "topCalloutPress" + + override fun getEventData() = Arguments.createMap() +} diff --git a/docs/CALLOUT.md b/docs/CALLOUT.md new file mode 100644 index 0000000..2ed2b89 --- /dev/null +++ b/docs/CALLOUT.md @@ -0,0 +1,51 @@ +# Callout + +Callout component displayed when a marker is tapped. Use as a child of `Marker`. + +## Usage + +```tsx +import { MapView, Marker, Callout } from '@lugg/maps'; + + + {/* Native callout with press handler */} + + console.log('Callout pressed')} /> + + + {/* Custom callout content */} + + console.log('Callout pressed')}> + + Custom Callout + With React content + + + + +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `onPress` | `() => void` | - | Called when the callout is pressed | +| `children` | `ReactNode` | - | Custom callout content. If not provided, the native callout is used | + +## Platform Behavior + +### Apple Maps (iOS) + +Custom callout content is rendered as a live interactive view inside the native callout bubble using `detailCalloutAccessoryView`. Content is fully interactive. + +### Google Maps (iOS & Android) + +Custom callout content is rasterized into the info window. Individual elements inside the callout are **not interactive** — only the entire callout is tappable via `onPress`. This is a Google Maps platform limitation. + +### Web + +Uses Google Maps `InfoWindow`. The callout opens on marker tap and closes when the close button is clicked. Content is rendered as live HTML. diff --git a/docs/MARKER.md b/docs/MARKER.md index ce3b5ac..d807afe 100644 --- a/docs/MARKER.md +++ b/docs/MARKER.md @@ -82,3 +82,17 @@ Use the `children` prop to render a custom marker view. The `anchor` prop contro - `{ x: 1, y: 0 }` - top right - `{ x: 0.5, y: 0.5 }` - center - `{ x: 0.5, y: 1 }` - bottom center (default for pins) + +## Callout + +Use the [`Callout`](./CALLOUT.md) component as a child to display a callout when the marker is tapped. + +```tsx + + console.log('Callout pressed')} /> + +``` diff --git a/example/bare/ios/Podfile.lock b/example/bare/ios/Podfile.lock index 9315d4a..d0824fa 100644 --- a/example/bare/ios/Podfile.lock +++ b/example/bare/ios/Podfile.lock @@ -11,7 +11,7 @@ PODS: - hermes-engine (0.14.0): - hermes-engine/Pre-built (= 0.14.0) - hermes-engine/Pre-built (0.14.0) - - LuggMaps (0.2.0-alpha.30): + - LuggMaps (1.0.0-beta.0): - boost - DoubleConversion - fast_float @@ -3050,7 +3050,7 @@ SPEC CHECKSUMS: glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 GoogleMaps: 0608099d4870cac8754bdba9b6953db543432438 hermes-engine: 3515eff1a2de44b79dfa94a03d1adeed40f0dafe - LuggMaps: 9fadfbfebeead30c7c578722bbb306e433fce8ed + LuggMaps: 6ee60e80f6dadc777bdd7619d9f067cefa053cb0 RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: 2b70c6e3abe00396cefd8913efbf6a2db01a2b36 RCTRequired: f3540eee8094231581d40c5c6d41b0f170237a81 diff --git a/example/shared/src/components/Map.tsx b/example/shared/src/components/Map.tsx index 79fa229..f0af5c5 100644 --- a/example/shared/src/components/Map.tsx +++ b/example/shared/src/components/Map.tsx @@ -1,8 +1,9 @@ import { forwardRef, useMemo, useState } from 'react'; -import { StyleSheet, View, useWindowDimensions } from 'react-native'; +import { StyleSheet, Text, View, useWindowDimensions } from 'react-native'; import { MapView, Marker, + Callout, GeoJson, Polygon, type MapViewProps, @@ -109,7 +110,14 @@ const renderMarker = ( onDragStart={handleDragStart} onDragChange={handleDragChange} onDragEnd={handleDragEnd} - /> + > + console.log('Icon callout pressed')}> + + Icon Marker + A pin-style marker + + + ); case 'text': return ( @@ -124,7 +132,14 @@ const renderMarker = ( onDragStart={handleDragStart} onDragChange={handleDragChange} onDragEnd={handleDragEnd} - /> + > + console.log('Text callout pressed:', text)}> + + Text Marker {text} + A text badge marker + + + ); case 'image': return ( @@ -138,7 +153,14 @@ const renderMarker = ( onDragStart={handleDragStart} onDragChange={handleDragChange} onDragEnd={handleDragEnd} - /> + > + console.log('Image callout pressed')}> + + Image Marker + An avatar marker + + + ); case 'custom': return ( @@ -156,6 +178,14 @@ const renderMarker = ( + console.log('Custom callout pressed')}> + + Custom Marker + + This is a custom callout with React content + + + ); default: @@ -171,7 +201,20 @@ const renderMarker = ( onDragStart={handleDragStart} onDragChange={handleDragChange} onDragEnd={handleDragEnd} - /> + > + {title ? ( + console.log('Callout pressed:', title)} /> + ) : ( + console.log('Basic callout pressed:', name)} + > + + Basic Marker + {name} + + + )} + ); } }; @@ -316,4 +359,17 @@ const styles = StyleSheet.create({ width: 30, borderRadius: 15, }, + callout: { + padding: 8, + minWidth: 140, + }, + calloutTitle: { + fontWeight: 'bold', + fontSize: 14, + marginBottom: 2, + }, + calloutDescription: { + fontSize: 12, + color: '#666', + }, }); diff --git a/example/shared/src/components/MarkerIcon.tsx b/example/shared/src/components/MarkerIcon.tsx index 8398b17..dd97de5 100644 --- a/example/shared/src/components/MarkerIcon.tsx +++ b/example/shared/src/components/MarkerIcon.tsx @@ -5,6 +5,7 @@ interface MarkerIconProps extends MarkerProps {} export const MarkerIcon = ({ anchor = { x: 0.5, y: 1 }, + children, ...rest }: MarkerIconProps) => { return ( @@ -16,6 +17,7 @@ export const MarkerIcon = ({ /> + {children} ); }; diff --git a/example/shared/src/components/MarkerImage.tsx b/example/shared/src/components/MarkerImage.tsx index 5c2a1f8..8ddfa9f 100644 --- a/example/shared/src/components/MarkerImage.tsx +++ b/example/shared/src/components/MarkerImage.tsx @@ -10,6 +10,7 @@ export const MarkerImage = ({ source, size = 40, anchor = { x: 0.5, y: 0.5 }, + children, ...rest }: MarkerImageProps) => { return ( @@ -21,6 +22,7 @@ export const MarkerImage = ({ { width: size, height: size, borderRadius: size / 2 }, ]} /> + {children} ); }; diff --git a/example/shared/src/components/MarkerText.tsx b/example/shared/src/components/MarkerText.tsx index a03c8e9..50f52ff 100644 --- a/example/shared/src/components/MarkerText.tsx +++ b/example/shared/src/components/MarkerText.tsx @@ -10,6 +10,7 @@ export const MarkerText = ({ text, color = '#EA4335', anchor = { x: 0.5, y: 0.5 }, + children, ...rest }: MarkerTextProps) => { return ( @@ -17,6 +18,7 @@ export const MarkerText = ({ {text} + {children} ); }; diff --git a/ios/LuggCalloutView.h b/ios/LuggCalloutView.h new file mode 100644 index 0000000..8dbe5e4 --- /dev/null +++ b/ios/LuggCalloutView.h @@ -0,0 +1,15 @@ +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface LuggCalloutView : RCTViewComponentView + +@property(nonatomic, readonly) BOOL hasCustomContent; +@property(nonatomic, readonly) UIView *contentView; + +- (void)emitPressEvent; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/LuggMarkerView.h b/ios/LuggMarkerView.h index f9def03..efc31ea 100644 --- a/ios/LuggMarkerView.h +++ b/ios/LuggMarkerView.h @@ -5,6 +5,7 @@ NS_ASSUME_NONNULL_BEGIN @class LuggMarkerView; +@class LuggCalloutView; @protocol LuggMarkerViewDelegate @optional @@ -27,6 +28,7 @@ NS_ASSUME_NONNULL_BEGIN @property(nonatomic, readonly) BOOL hasCustomView; @property(nonatomic, readonly) BOOL didLayout; @property(nonatomic, readonly) UIView *iconView; +@property(nonatomic, readonly, nullable) LuggCalloutView *calloutView; @property(nonatomic, weak, nullable) id delegate; @property(nonatomic, strong, nullable) NSObject *marker; diff --git a/ios/LuggMarkerView.mm b/ios/LuggMarkerView.mm index 6c84597..1c9b0cb 100644 --- a/ios/LuggMarkerView.mm +++ b/ios/LuggMarkerView.mm @@ -1,4 +1,5 @@ #import "LuggMarkerView.h" +#import "LuggCalloutView.h" #import "events/MarkerDragEvent.h" #import "events/MarkerPressEvent.h" @@ -28,6 +29,7 @@ @implementation LuggMarkerView { BOOL _draggable; BOOL _didLayout; UIView *_iconView; + LuggCalloutView *_calloutView; } + (ComponentDescriptorProvider)componentDescriptorProvider { @@ -92,14 +94,22 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask { - (void)mountChildComponentView: (UIView *)childComponentView index:(NSInteger)index { - [_iconView insertSubview:childComponentView atIndex:index]; + if ([childComponentView isKindOfClass:[LuggCalloutView class]]) { + _calloutView = (LuggCalloutView *)childComponentView; + } else { + [_iconView insertSubview:childComponentView atIndex:index]; + } _didLayout = NO; } - (void)unmountChildComponentView: (UIView *)childComponentView index:(NSInteger)index { - [childComponentView removeFromSuperview]; + if ([childComponentView isKindOfClass:[LuggCalloutView class]]) { + _calloutView = nil; + } else { + [childComponentView removeFromSuperview]; + } _didLayout = NO; } @@ -180,6 +190,10 @@ - (UIView *)iconView { return _iconView; } +- (LuggCalloutView *)calloutView { + return _calloutView; +} + - (UIImage *)createIconImage { CGSize size = _iconView.bounds.size; if (size.width <= 0 || size.height <= 0) { @@ -271,6 +285,7 @@ - (void)resetIconViewTransform { - (void)prepareForRecycle { [super prepareForRecycle]; _didLayout = NO; + _calloutView = nil; self.marker = nil; self.delegate = nil; [self resetIconViewTransform]; diff --git a/ios/core/AppleMapProvider.mm b/ios/core/AppleMapProvider.mm index 8e0b2ae..dd5a103 100644 --- a/ios/core/AppleMapProvider.mm +++ b/ios/core/AppleMapProvider.mm @@ -1,4 +1,5 @@ #import "AppleMapProvider.h" +#import "../LuggCalloutView.h" #import "../LuggMarkerView.h" #import "../LuggPolygonView.h" #import "../LuggPolylineView.h" @@ -431,6 +432,7 @@ - (MKAnnotationView *)mapView:(MKMapView *)mapView markerAnnotationView.layer.zPosition = markerView.zIndex; markerAnnotationView.zPriority = markerView.zIndex; markerAnnotationView.draggable = markerView.draggable; + [self applyCalloutView:markerView annotationView:markerAnnotationView]; [self addCenterTapGesture:markerAnnotationView]; markerAnnotation.annotationView = markerAnnotationView; return markerAnnotationView; @@ -446,6 +448,8 @@ - (MKAnnotationView *)mapView:(MKMapView *)mapView annotationView.draggable = markerView.draggable; [self addCenterTapGesture:annotationView]; + [self applyCalloutView:markerView annotationView:annotationView]; + if (!markerView.rasterize) { UIView *iconView = markerView.iconView; [iconView removeFromSuperview]; @@ -574,6 +578,36 @@ - (void)handleAnnotationTap:(UITapGestureRecognizer *)gesture { } } +- (void)mapView:(MKMapView *)mapView + annotationView:(MKAnnotationView *)view + calloutAccessoryControlTapped:(UIControl *)control { + if (![view.annotation isKindOfClass:[AppleMarkerAnnotation class]]) + return; + + AppleMarkerAnnotation *annotation = (AppleMarkerAnnotation *)view.annotation; + LuggMarkerView *markerView = annotation.markerView; + + if (markerView.calloutView) { + [markerView.calloutView emitPressEvent]; + } +} + +- (void)applyCalloutView:(LuggMarkerView *)markerView + annotationView:(MKAnnotationView *)annotationView { + LuggCalloutView *calloutView = markerView.calloutView; + if (!calloutView) + return; + + if (calloutView.hasCustomContent) { + annotationView.detailCalloutAccessoryView = calloutView.contentView; + } + + // Add info button to enable calloutAccessoryControlTapped + UIButton *infoButton = + [UIButton buttonWithType:UIButtonTypeDetailDisclosure]; + annotationView.rightCalloutAccessoryView = infoButton; +} + #pragma mark - MarkerViewDelegate - (void)markerViewDidLayout:(LuggMarkerView *)markerView { @@ -600,6 +634,7 @@ - (void)markerViewDidUpdate:(LuggMarkerView *)markerView { annotationView.draggable = markerView.draggable; annotationView.layer.zPosition = markerView.zIndex; annotationView.zPriority = markerView.zIndex; + [self applyCalloutView:markerView annotationView:annotationView]; } [self updateAnnotationViewFrame:annotation]; diff --git a/ios/core/GoogleMapProvider.mm b/ios/core/GoogleMapProvider.mm index dc401e8..8db9aa9 100644 --- a/ios/core/GoogleMapProvider.mm +++ b/ios/core/GoogleMapProvider.mm @@ -1,4 +1,5 @@ #import "GoogleMapProvider.h" +#import "../LuggCalloutView.h" #import "../LuggMarkerView.h" #import "../LuggPolygonView.h" #import "../LuggPolylineView.h" @@ -332,6 +333,42 @@ - (void)mapView:(GMSMapView *)mapView didEndDraggingMarker:(GMSMarker *)marker { } } +- (UIView *)mapView:(GMSMapView *)mapView + markerInfoContents:(GMSMarker *)marker { + LuggMarkerView *markerView = [_markerToViewMap objectForKey:marker]; + if (!markerView || !markerView.calloutView || + !markerView.calloutView.hasCustomContent) + return nil; + + UIView *contentView = markerView.calloutView.contentView; + [contentView layoutIfNeeded]; + + CGSize size = contentView.bounds.size; + if (size.width <= 0 || size.height <= 0) + return nil; + + UIGraphicsImageRendererFormat *format = + [UIGraphicsImageRendererFormat defaultFormat]; + format.scale = [UIScreen mainScreen].scale; + UIGraphicsImageRenderer *renderer = + [[UIGraphicsImageRenderer alloc] initWithSize:size format:format]; + UIImage *image = + [renderer imageWithActions:^(UIGraphicsImageRendererContext *context) { + [contentView.layer renderInContext:context.CGContext]; + }]; + + UIImageView *imageView = [[UIImageView alloc] initWithImage:image]; + return imageView; +} + +- (void)mapView:(GMSMapView *)mapView + didTapInfoWindowOfMarker:(GMSMarker *)marker { + LuggMarkerView *markerView = [_markerToViewMap objectForKey:marker]; + if (markerView && markerView.calloutView) { + [markerView.calloutView emitPressEvent]; + } +} + #pragma mark - MarkerViewDelegate - (void)markerViewDidLayout:(LuggMarkerView *)markerView { diff --git a/package.json b/package.json index 27c84aa..f873217 100644 --- a/package.json +++ b/package.json @@ -147,6 +147,7 @@ "componentProvider": { "LuggMapView": "LuggMapView", "LuggMarkerView": "LuggMarkerView", + "LuggCalloutView": "LuggCalloutView", "LuggMapWrapperView": "LuggMapWrapperView", "LuggPolylineView": "LuggPolylineView", "LuggPolygonView": "LuggPolygonView" diff --git a/src/components/Callout.tsx b/src/components/Callout.tsx new file mode 100644 index 0000000..36471b8 --- /dev/null +++ b/src/components/Callout.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { StyleSheet } from 'react-native'; +import LuggCalloutViewNativeComponent from '../fabric/LuggCalloutViewNativeComponent'; +import type { CalloutProps } from './Callout.types'; + +export type { CalloutProps } from './Callout.types'; + +export class Callout extends React.PureComponent { + render() { + const { onPress, children } = this.props; + + return ( + onPress() : undefined} + > + {children} + + ); + } +} + +const styles = StyleSheet.create({ + callout: { + position: 'absolute', + pointerEvents: 'box-none', + }, +}); diff --git a/src/components/Callout.types.ts b/src/components/Callout.types.ts new file mode 100644 index 0000000..5a8fb42 --- /dev/null +++ b/src/components/Callout.types.ts @@ -0,0 +1,12 @@ +import type { ReactNode } from 'react'; + +export interface CalloutProps { + /** + * Called when the callout is pressed + */ + onPress?: () => void; + /** + * Custom callout content. If not provided, the native callout is used. + */ + children?: ReactNode; +} diff --git a/src/components/Callout.web.tsx b/src/components/Callout.web.tsx new file mode 100644 index 0000000..a06c929 --- /dev/null +++ b/src/components/Callout.web.tsx @@ -0,0 +1,3 @@ +import type { CalloutProps } from './Callout.types'; + +export const Callout = (_props: CalloutProps) => null; diff --git a/src/components/Marker.web.tsx b/src/components/Marker.web.tsx index 3cc6bc0..12700b5 100644 --- a/src/components/Marker.web.tsx +++ b/src/components/Marker.web.tsx @@ -1,6 +1,12 @@ -import { useCallback, useEffect, useRef } from 'react'; -import { AdvancedMarker } from '@vis.gl/react-google-maps'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + AdvancedMarker, + InfoWindow, + useAdvancedMarkerRef, +} from '@vis.gl/react-google-maps'; import { useMapContext } from '../MapProvider.web'; +import { Callout } from './Callout.web'; +import type { CalloutProps } from './Callout.types'; import type { MarkerProps } from './Marker.types'; const toWebAnchor = (value: number) => `-${value * 100}%`; @@ -22,6 +28,23 @@ const createEvent = ( }, } as any); +function extractCallout( + children: React.ReactNode +): { calloutProps: CalloutProps | null; otherChildren: React.ReactNode[] } { + let calloutProps: CalloutProps | null = null; + const otherChildren: React.ReactNode[] = []; + + React.Children.forEach(children, (child) => { + if (React.isValidElement(child) && child.type === Callout) { + calloutProps = child.props as CalloutProps; + } else { + otherChildren.push(child); + } + }); + + return { calloutProps, otherChildren }; +} + export const Marker = ({ coordinate, title, @@ -38,6 +61,10 @@ export const Marker = ({ }: MarkerProps) => { const { moveCamera } = useMapContext(); const dragPositionRef = useRef(null); + const [markerRef, markerElement] = useAdvancedMarkerRef(); + const [infoWindowOpen, setInfoWindowOpen] = useState(false); + + const { calloutProps, otherChildren } = extractCallout(children); const transforms: string[] = []; if (rotate) transforms.push(`rotate(${rotate}deg)`); @@ -51,8 +78,11 @@ export const Marker = ({ : coordinate; moveCamera(coord); onPress?.(createEvent(e, coordinate)); + if (calloutProps) { + setInfoWindowOpen((prev) => !prev); + } }, - [moveCamera, onPress, coordinate] + [moveCamera, onPress, coordinate, calloutProps] ); const handleDragStart = useCallback( @@ -98,23 +128,44 @@ export const Marker = ({ }; return ( - 0 ? { transform: transforms.join(' ') } : undefined - } - > - {children} - + <> + 0 + ? { transform: transforms.join(' ') } + : undefined + } + > + {otherChildren.length > 0 ? otherChildren : undefined} + + {calloutProps && infoWindowOpen && markerElement && ( + setInfoWindowOpen(false)} + > + {calloutProps.children ? ( +
calloutProps?.onPress?.()}> + {calloutProps.children} +
+ ) : ( +
calloutProps?.onPress?.()}> + {title && {title}} +
+ )} +
+ )} + ); }; diff --git a/src/components/index.ts b/src/components/index.ts index 64e062e..028c00e 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -4,6 +4,8 @@ export type { MarkerPressEvent, MarkerDragEvent, } from './Marker.types'; +export { Callout } from './Callout'; +export type { CalloutProps } from './Callout.types'; export { Polygon } from './Polygon'; export type { PolygonProps } from './Polygon.types'; export { Polyline } from './Polyline'; diff --git a/src/components/index.web.ts b/src/components/index.web.ts index b680ca9..0ea5c8c 100644 --- a/src/components/index.web.ts +++ b/src/components/index.web.ts @@ -1,7 +1,9 @@ export { Marker } from './Marker.web'; +export { Callout } from './Callout.web'; export { Polygon } from './Polygon.web'; export { Polyline } from './Polyline.web'; export type { MarkerProps } from './Marker.types'; +export type { CalloutProps } from './Callout.types'; export type { PolygonProps } from './Polygon.types'; export type { PolylineProps } from './Polyline.types'; export { GeoJson } from './GeoJson'; diff --git a/src/fabric/LuggCalloutViewNativeComponent.ts b/src/fabric/LuggCalloutViewNativeComponent.ts new file mode 100644 index 0000000..67e37d6 --- /dev/null +++ b/src/fabric/LuggCalloutViewNativeComponent.ts @@ -0,0 +1,11 @@ +import { codegenNativeComponent } from 'react-native'; +import type { ViewProps, HostComponent } from 'react-native'; +import type { DirectEventHandler } from 'react-native/Libraries/Types/CodegenTypes'; + +export interface NativeProps extends ViewProps { + onCalloutPress?: DirectEventHandler<{}>; +} + +export default codegenNativeComponent( + 'LuggCalloutView' +) as HostComponent; diff --git a/src/index.ts b/src/index.ts index 6f899e1..56ab373 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,8 @@ export type { } from './components'; export { GeoJson } from './components'; export type { GeoJsonProps } from './components'; +export { Callout } from './components'; +export type { CalloutProps } from './components'; export type { MapViewProps, MapViewRef, diff --git a/src/index.web.ts b/src/index.web.ts index 54ecf38..2d614c4 100644 --- a/src/index.web.ts +++ b/src/index.web.ts @@ -9,6 +9,8 @@ export { Polyline } from './components/index.web'; export type { PolylineProps } from './components/index.web'; export { GeoJson } from './components/index.web'; export type { GeoJsonProps } from './components/index.web'; +export { Callout } from './components/index.web'; +export type { CalloutProps } from './components/index.web'; export type { MapViewProps, MapViewRef,