Skip to content

Commit 030956a

Browse files
committed
refactor: move callout to Marker props with bubbled support
Remove standalone Callout component. Callout is now configured via Marker props: callout, onCalloutPress, calloutBubbled. When calloutBubbled={false}, render live interactive views above the marker instead of rasterized info windows, enabling buttons and other interactive content inside callouts on all platforms.
1 parent c07c43f commit 030956a

27 files changed

Lines changed: 484 additions & 221 deletions

README.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,8 +113,7 @@ import { MapView, Marker, Polyline, Polygon } from '@lugg/maps';
113113
## Components
114114

115115
- [MapView](docs/MAPVIEW.md) - Main map component
116-
- [Marker](docs/MARKER.md) - Map markers
117-
- [Callout](docs/CALLOUT.md) - Marker callouts
116+
- [Marker](docs/MARKER.md) - Map markers with callout support
118117
- [Polyline](docs/POLYLINE.md) - Draw lines on the map
119118
- [Polygon](docs/POLYGON.md) - Draw filled shapes on the map
120119
- [GeoJson](docs/GEOJSON.md) - Render GeoJSON data on the map

android/src/main/java/com/luggmaps/LuggCalloutView.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import com.luggmaps.extensions.dispatchEvent
1313

1414
class LuggCalloutView(context: Context) : ReactViewGroup(context) {
1515
val contentView: ReactViewGroup = ReactViewGroup(context)
16+
var bubbled: Boolean = true
1617

1718
val hasCustomContent: Boolean
1819
get() = contentView.isNotEmpty()

android/src/main/java/com/luggmaps/LuggCalloutViewManager.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ class LuggCalloutViewManager :
1717
override fun getName(): String = NAME
1818
override fun createViewInstance(context: ThemedReactContext): LuggCalloutView = LuggCalloutView(context)
1919

20+
override fun setBubbled(view: LuggCalloutView, value: Boolean) {
21+
view.bubbled = value
22+
}
23+
2024
override fun getExportedCustomDirectEventTypeConstants(): Map<String, Any> =
2125
mapOf(
2226
"topCalloutPress" to mapOf("registrationName" to "onCalloutPress")

android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import com.google.android.gms.maps.model.Marker
1818
import com.google.android.gms.maps.model.Polygon
1919
import com.google.android.gms.maps.model.PolygonOptions
2020
import com.google.android.gms.maps.model.PolylineOptions
21+
import com.luggmaps.LuggCalloutView
2122
import com.luggmaps.LuggMapWrapperView
2223
import com.luggmaps.LuggMarkerView
2324
import com.luggmaps.LuggMarkerViewDelegate
@@ -59,6 +60,7 @@ class GoogleMapProvider(private val context: Context) :
5960
private val polylineAnimators = mutableMapOf<LuggPolylineView, PolylineAnimator>()
6061
private val polygonToViewMap = mutableMapOf<Polygon, LuggPolygonView>()
6162
private val markerToViewMap = mutableMapOf<Marker, LuggMarkerView>()
63+
private var activeNonBubbledMarker: Marker? = null
6264
private var tapLocation: LatLng? = null
6365

6466
// Initial camera settings
@@ -106,6 +108,7 @@ class GoogleMapProvider(private val context: Context) :
106108
}
107109

108110
override fun destroy() {
111+
dismissNonBubbledCallout()
109112
pendingMarkerViews.clear()
110113
pendingPolylineViews.clear()
111114
pendingPolygonViews.clear()
@@ -184,6 +187,7 @@ class GoogleMapProvider(private val context: Context) :
184187
val map = googleMap ?: return
185188
val position = map.cameraPosition
186189
delegate?.mapProviderDidMoveCamera(position.target.latitude, position.target.longitude, position.zoom, isDragging)
190+
positionNonBubbledCallout()
187191
}
188192

189193
override fun onCameraIdle() {
@@ -197,6 +201,7 @@ class GoogleMapProvider(private val context: Context) :
197201
}
198202

199203
override fun onMapClick(latLng: LatLng) {
204+
dismissNonBubbledCallout()
200205
val map = googleMap ?: return
201206
val point = map.projection.toScreenLocation(latLng)
202207
delegate?.mapProviderDidPress(latLng.latitude, latLng.longitude, point.x.toFloat(), point.y.toFloat())
@@ -218,9 +223,17 @@ class GoogleMapProvider(private val context: Context) :
218223
}
219224

220225
override fun onMarkerClick(marker: Marker): Boolean {
226+
dismissNonBubbledCallout()
227+
221228
markerToViewMap[marker]?.let { view ->
222229
val point = googleMap?.projection?.toScreenLocation(marker.position)
223230
view.emitPressEvent(point?.x?.toFloat() ?: 0f, point?.y?.toFloat() ?: 0f)
231+
232+
val calloutView = view.calloutView
233+
if (calloutView != null && !calloutView.bubbled && calloutView.hasCustomContent) {
234+
showNonBubbledCallout(marker, calloutView)
235+
return true
236+
}
224237
}
225238
return false
226239
}
@@ -251,12 +264,15 @@ class GoogleMapProvider(private val context: Context) :
251264
}
252265
}
253266

254-
override fun getInfoWindow(marker: Marker): View? = null
267+
override fun getInfoWindow(marker: Marker): View? {
268+
// Non-bubbled callouts are rendered as live views, not info windows
269+
return null
270+
}
255271

256272
override fun getInfoContents(marker: Marker): View? {
257273
val markerView = markerToViewMap[marker] ?: return null
258274
val calloutView = markerView.calloutView ?: return null
259-
if (!calloutView.hasCustomContent) return null
275+
if (!calloutView.hasCustomContent || !calloutView.bubbled) return null
260276

261277
val bitmap = calloutView.createContentBitmap() ?: return null
262278
return ImageView(context).apply { setImageBitmap(bitmap) }
@@ -266,6 +282,45 @@ class GoogleMapProvider(private val context: Context) :
266282
markerToViewMap[marker]?.calloutView?.emitPressEvent()
267283
}
268284

285+
private fun showNonBubbledCallout(marker: Marker, calloutView: LuggCalloutView) {
286+
val wrapper = wrapperView ?: return
287+
val contentView = calloutView.contentView
288+
289+
contentView.setOnClickListener {
290+
calloutView.emitPressEvent()
291+
}
292+
293+
wrapper.addView(contentView)
294+
activeNonBubbledMarker = marker
295+
positionNonBubbledCallout()
296+
}
297+
298+
private fun dismissNonBubbledCallout() {
299+
val marker = activeNonBubbledMarker ?: return
300+
val markerView = markerToViewMap[marker] ?: return
301+
val calloutView = markerView.calloutView ?: return
302+
val contentView = calloutView.contentView
303+
304+
(contentView.parent as? android.view.ViewGroup)?.removeView(contentView)
305+
activeNonBubbledMarker = null
306+
}
307+
308+
private fun positionNonBubbledCallout() {
309+
val marker = activeNonBubbledMarker ?: return
310+
val markerView = markerToViewMap[marker] ?: return
311+
val calloutView = markerView.calloutView ?: return
312+
val contentView = calloutView.contentView
313+
val map = googleMap ?: return
314+
315+
val point = map.projection.toScreenLocation(marker.position)
316+
contentView.post {
317+
val x = point.x - contentView.width / 2f
318+
val y = point.y - contentView.height.toFloat()
319+
contentView.translationX = x
320+
contentView.translationY = y
321+
}
322+
}
323+
269324
// endregion
270325

271326
// region Props

docs/CALLOUT.md

Lines changed: 0 additions & 51 deletions
This file was deleted.

docs/MARKER.md

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ import { MapView, Marker } from '@lugg/maps';
4444
| `onDragStart` | `(event: MarkerDragEvent) => void` | - | Called when marker drag starts. Event includes `coordinate` and `point` |
4545
| `onDragChange` | `(event: MarkerDragEvent) => void` | - | Called continuously as the marker is dragged. Event includes `coordinate` and `point` |
4646
| `onDragEnd` | `(event: MarkerDragEvent) => void` | - | Called when marker drag ends. Event includes `coordinate` and `point` |
47+
| `callout` | `ComponentType \| ReactElement` | - | Callout content displayed when marker is tapped |
48+
| `onCalloutPress` | `() => void` | - | Called when the callout is pressed |
49+
| `calloutBubbled` | `boolean` | `true` | Whether to wrap the callout in the native platform bubble |
4750
| `children` | `ReactNode` | - | Custom marker view |
4851

4952
## Draggable Markers
@@ -85,14 +88,45 @@ Use the `children` prop to render a custom marker view. The `anchor` prop contro
8588

8689
## Callout
8790

88-
Use the [`Callout`](./CALLOUT.md) component as a child to display a callout when the marker is tapped.
91+
Use the `callout` prop to display a callout when the marker is tapped.
8992

9093
```tsx
94+
{/* Native callout using title/description */}
9195
<Marker
9296
coordinate={{ latitude: 37.7749, longitude: -122.4194 }}
9397
title="San Francisco"
9498
description="California, USA"
95-
>
96-
<Callout onPress={() => console.log('Callout pressed')} />
97-
</Marker>
99+
onCalloutPress={() => console.log('Callout pressed')}
100+
/>
101+
102+
{/* Custom callout content */}
103+
<Marker
104+
coordinate={{ latitude: 37.8049, longitude: -122.4094 }}
105+
callout={
106+
<View style={{ padding: 8 }}>
107+
<Text style={{ fontWeight: 'bold' }}>Custom Callout</Text>
108+
<Text>With React content</Text>
109+
</View>
110+
}
111+
onCalloutPress={() => console.log('Callout pressed')}
112+
/>
113+
114+
{/* Non-bubbled callout (no native chrome) */}
115+
<Marker
116+
coordinate={{ latitude: 37.7849, longitude: -122.4294 }}
117+
calloutBubbled={false}
118+
callout={
119+
<View style={{ padding: 12, backgroundColor: 'white', borderRadius: 8 }}>
120+
<Text style={{ fontWeight: 'bold' }}>Custom Tooltip</Text>
121+
<Text>Rendered without native bubble</Text>
122+
</View>
123+
}
124+
onCalloutPress={() => console.log('Callout pressed')}
125+
/>
98126
```
127+
128+
### Platform Behavior
129+
130+
- **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.
131+
- **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.
132+
- **Web**: Uses Google Maps `InfoWindow`. With `calloutBubbled={false}`, content is rendered as a positioned element above the marker.

0 commit comments

Comments
 (0)