diff --git a/README.md b/README.md index 4b1ca2a..aa3a3c8 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ import { MapView, Marker, Polyline, Polygon } from '@lugg/maps'; ## Components - [MapView](docs/MAPVIEW.md) - Main map component -- [Marker](docs/MARKER.md) - Map markers +- [Marker](docs/MARKER.md) - Map markers with callout support - [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/build.gradle b/android/build.gradle index 6ce9b4d..a03c042 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -74,5 +74,5 @@ def kotlin_version = getExtOrDefault("kotlinVersion") dependencies { implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation "com.google.android.gms:play-services-maps:19.2.0" + implementation "com.google.android.gms:play-services-maps:20.0.0" } 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..b30e43c --- /dev/null +++ b/android/src/main/java/com/luggmaps/LuggCalloutView.kt @@ -0,0 +1,67 @@ +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) + var bubbled: Boolean = true + + 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..0dc3736 --- /dev/null +++ b/android/src/main/java/com/luggmaps/LuggCalloutViewManager.kt @@ -0,0 +1,37 @@ +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 setBubbled(view: LuggCalloutView, value: Boolean) { + view.bubbled = value + } + + 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..1947631 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 @@ -17,6 +18,7 @@ import com.google.android.gms.maps.model.Marker import com.google.android.gms.maps.model.Polygon import com.google.android.gms.maps.model.PolygonOptions import com.google.android.gms.maps.model.PolylineOptions +import com.luggmaps.LuggCalloutView import com.luggmaps.LuggMapWrapperView import com.luggmaps.LuggMarkerView import com.luggmaps.LuggMarkerViewDelegate @@ -38,7 +40,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 @@ -56,6 +60,7 @@ class GoogleMapProvider(private val context: Context) : private val polylineAnimators = mutableMapOf() private val polygonToViewMap = mutableMapOf() private val markerToViewMap = mutableMapOf() + private var activeNonBubbledMarker: Marker? = null private var tapLocation: LatLng? = null // Initial camera settings @@ -103,6 +108,7 @@ class GoogleMapProvider(private val context: Context) : } override fun destroy() { + dismissNonBubbledCallout() pendingMarkerViews.clear() pendingPolylineViews.clear() pendingPolygonViews.clear() @@ -120,6 +126,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 +151,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) { @@ -177,6 +187,7 @@ class GoogleMapProvider(private val context: Context) : val map = googleMap ?: return val position = map.cameraPosition delegate?.mapProviderDidMoveCamera(position.target.latitude, position.target.longitude, position.zoom, isDragging) + positionNonBubbledCallout() } override fun onCameraIdle() { @@ -190,6 +201,7 @@ class GoogleMapProvider(private val context: Context) : } override fun onMapClick(latLng: LatLng) { + dismissNonBubbledCallout() val map = googleMap ?: return val point = map.projection.toScreenLocation(latLng) delegate?.mapProviderDidPress(latLng.latitude, latLng.longitude, point.x.toFloat(), point.y.toFloat()) @@ -211,9 +223,17 @@ class GoogleMapProvider(private val context: Context) : } override fun onMarkerClick(marker: Marker): Boolean { + dismissNonBubbledCallout() + markerToViewMap[marker]?.let { view -> val point = googleMap?.projection?.toScreenLocation(marker.position) view.emitPressEvent(point?.x?.toFloat() ?: 0f, point?.y?.toFloat() ?: 0f) + + val calloutView = view.calloutView + if (calloutView != null && !calloutView.bubbled && calloutView.hasCustomContent) { + showNonBubbledCallout(marker, calloutView) + return true + } } return false } @@ -244,6 +264,63 @@ class GoogleMapProvider(private val context: Context) : } } + override fun getInfoWindow(marker: Marker): View? { + // Non-bubbled callouts are rendered as live views, not info windows + return null + } + + override fun getInfoContents(marker: Marker): View? { + val markerView = markerToViewMap[marker] ?: return null + val calloutView = markerView.calloutView ?: return null + if (!calloutView.hasCustomContent || !calloutView.bubbled) return null + + val bitmap = calloutView.createContentBitmap() ?: return null + return ImageView(context).apply { setImageBitmap(bitmap) } + } + + override fun onInfoWindowClick(marker: Marker) { + markerToViewMap[marker]?.calloutView?.emitPressEvent() + } + + private fun showNonBubbledCallout(marker: Marker, calloutView: LuggCalloutView) { + val wrapper = wrapperView ?: return + val contentView = calloutView.contentView + + contentView.setOnClickListener { + calloutView.emitPressEvent() + } + + wrapper.addView(contentView) + activeNonBubbledMarker = marker + positionNonBubbledCallout() + } + + private fun dismissNonBubbledCallout() { + val marker = activeNonBubbledMarker ?: return + val markerView = markerToViewMap[marker] ?: return + val calloutView = markerView.calloutView ?: return + val contentView = calloutView.contentView + + (contentView.parent as? android.view.ViewGroup)?.removeView(contentView) + activeNonBubbledMarker = null + } + + private fun positionNonBubbledCallout() { + val marker = activeNonBubbledMarker ?: return + val markerView = markerToViewMap[marker] ?: return + val calloutView = markerView.calloutView ?: return + val contentView = calloutView.contentView + val map = googleMap ?: return + + val point = map.projection.toScreenLocation(marker.position) + contentView.post { + val x = point.x - contentView.width / 2f + val y = point.y - contentView.height.toFloat() + contentView.translationX = x + contentView.translationY = y + } + } + // 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/MARKER.md b/docs/MARKER.md index ce3b5ac..be424d6 100644 --- a/docs/MARKER.md +++ b/docs/MARKER.md @@ -44,6 +44,9 @@ import { MapView, Marker } from '@lugg/maps'; | `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` | | `onDragEnd` | `(event: MarkerDragEvent) => void` | - | Called when marker drag ends. Event includes `coordinate` and `point` | +| `callout` | `ComponentType \| ReactElement` | - | Callout content displayed when marker is tapped | +| `onCalloutPress` | `() => void` | - | Called when the callout is pressed | +| `calloutBubbled` | `boolean` | `true` | Whether to wrap the callout in the native platform bubble | | `children` | `ReactNode` | - | Custom marker view | ## Draggable Markers @@ -82,3 +85,48 @@ 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` prop to display a callout when the marker is tapped. + +```tsx +{/* Native callout using title/description */} + console.log('Callout pressed')} +/> + +{/* Custom callout content */} + + Custom Callout + With React content + + } + onCalloutPress={() => console.log('Callout pressed')} +/> + +{/* Non-bubbled callout (no native chrome) */} + + Custom Tooltip + Rendered without native bubble + + } + onCalloutPress={() => console.log('Callout pressed')} +/> +``` + +### Platform Behavior + +- **Apple Maps (iOS)**: Custom callout content is rendered as a live interactive view inside the native callout bubble. With `calloutBubbled={false}`, content is rendered as a live interactive view positioned above the marker without the native bubble. +- **Google Maps (iOS & Android)**: Custom callout content is rasterized into the info window. With `calloutBubbled={false}`, content is rendered as a live interactive view positioned above the marker (not rasterized), allowing interactive elements like buttons. +- **Web**: Uses Google Maps `InfoWindow`. With `calloutBubbled={false}`, content is rendered as a positioned element above the marker. 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/Home.tsx b/example/shared/src/Home.tsx index 3a1572d..9353c90 100644 --- a/example/shared/src/Home.tsx +++ b/example/shared/src/Home.tsx @@ -244,6 +244,10 @@ const HomeContent = () => { formatPressEvent(e, `Dragging(${m.name})`) } onMarkerDragEnd={(e, m) => formatPressEvent(e, `Drag end(${m.name})`)} + onCalloutPress={(m) => { + lockStatus(); + setStatus({ text: `Callout pressed(${m.name})`, error: false }); + }} onPolygonPress={() => { lockStatus(); setStatus({ text: 'Polygon pressed', error: false }); diff --git a/example/shared/src/components/Map.tsx b/example/shared/src/components/Map.tsx index 79fa229..fc4e548 100644 --- a/example/shared/src/components/Map.tsx +++ b/example/shared/src/components/Map.tsx @@ -1,5 +1,5 @@ import { forwardRef, useMemo, useState } from 'react'; -import { StyleSheet, View, useWindowDimensions } from 'react-native'; +import { Alert, StyleSheet, Text, View, useWindowDimensions } from 'react-native'; import { MapView, Marker, @@ -20,6 +20,7 @@ import { CrewMarker } from './CrewMarker'; import { MarkerIcon } from './MarkerIcon'; import { MarkerText } from './MarkerText'; import { MarkerImage } from './MarkerImage'; +import { Button } from './Button'; import type { MarkerData } from './index'; import { Route, smoothCoordinates } from './Route'; import { SAMPLE_GEOJSON } from '../geojson'; @@ -33,6 +34,7 @@ interface MapProps extends MapViewProps { onMarkerDragStart?: (event: MarkerDragEvent, marker: MarkerData) => void; onMarkerDragChange?: (event: MarkerDragEvent, marker: MarkerData) => void; onMarkerDragEnd?: (event: MarkerDragEvent, marker: MarkerData) => void; + onCalloutPress?: (marker: MarkerData) => void; } const INITIAL_ZOOM = 14; @@ -69,7 +71,8 @@ const renderMarker = ( onPress?: (event: MarkerPressEvent, marker: MarkerData) => void, onDragStart?: (event: MarkerDragEvent, marker: MarkerData) => void, onDragChange?: (event: MarkerDragEvent, marker: MarkerData) => void, - onDragEnd?: (event: MarkerDragEvent, marker: MarkerData) => void + onDragEnd?: (event: MarkerDragEvent, marker: MarkerData) => void, + onCalloutPress?: (marker: MarkerData) => void ) => { const { id, @@ -97,6 +100,17 @@ const renderMarker = ( ? (e: MarkerDragEvent) => onDragEnd(e, marker) : undefined; + const calloutEl = (label: string, desc: string) => ( + + {label} + {desc} + + ); + + const handleCalloutPress = onCalloutPress + ? () => onCalloutPress(marker) + : undefined; + switch (type) { case 'icon': return ( @@ -109,6 +123,8 @@ const renderMarker = ( onDragStart={handleDragStart} onDragChange={handleDragChange} onDragEnd={handleDragEnd} + callout={calloutEl('Icon Marker', 'A pin-style marker')} + onCalloutPress={handleCalloutPress} /> ); case 'text': @@ -124,6 +140,8 @@ const renderMarker = ( onDragStart={handleDragStart} onDragChange={handleDragChange} onDragEnd={handleDragEnd} + callout={calloutEl(`Text Marker ${text}`, 'A text badge marker')} + onCalloutPress={handleCalloutPress} /> ); case 'image': @@ -138,6 +156,8 @@ const renderMarker = ( onDragStart={handleDragStart} onDragChange={handleDragChange} onDragEnd={handleDragEnd} + callout={calloutEl('Image Marker', 'An avatar marker')} + onCalloutPress={handleCalloutPress} /> ); case 'custom': @@ -152,6 +172,19 @@ const renderMarker = ( onDragStart={handleDragStart} onDragChange={handleDragChange} onDragEnd={handleDragEnd} + callout={ + + + Custom Marker + + Non-bubbled callout + + +