Skip to content
Draft
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions android/src/main/java/com/luggmaps/LuggCalloutView.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}
33 changes: 33 additions & 0 deletions android/src/main/java/com/luggmaps/LuggCalloutViewManager.kt
Original file line number Diff line number Diff line change
@@ -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<LuggCalloutView>(),
LuggCalloutViewManagerInterface<LuggCalloutView> {
private val delegate: ViewManagerDelegate<LuggCalloutView> = LuggCalloutViewManagerDelegate(this)

override fun getDelegate(): ViewManagerDelegate<LuggCalloutView> = delegate
override fun getName(): String = NAME
override fun createViewInstance(context: ThemedReactContext): LuggCalloutView = LuggCalloutView(context)

override fun getExportedCustomDirectEventTypeConstants(): Map<String, Any> =
mapOf(
"topCalloutPress" to mapOf("registrationName" to "onCalloutPress")
)

override fun onDropViewInstance(view: LuggCalloutView) {
super.onDropViewInstance(view)
view.onDropViewInstance()
}

companion object {
const val NAME = "LuggCalloutView"
}
}
32 changes: 27 additions & 5 deletions android/src/main/java/com/luggmaps/LuggMarkerView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int, Int> {
var maxWidth = 0
var maxHeight = 0
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -278,6 +299,7 @@ class LuggMarkerView(context: Context) : ReactViewGroup(context) {
scaleUpdateRunnable?.let { removeCallbacks(it) }
scaleUpdateRunnable = null
didLayout = false
calloutView = null
delegate = null
iconView.removeAllViews()
}
Expand Down
2 changes: 1 addition & 1 deletion android/src/main/java/com/luggmaps/LuggPackage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ import com.facebook.react.uimanager.ViewManager

class LuggPackage : ReactPackage {
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
listOf(LuggMapViewManager(), LuggMarkerViewManager(), LuggMapWrapperViewManager(), LuggPolylineViewManager(), LuggPolygonViewManager())
listOf(LuggMapViewManager(), LuggMarkerViewManager(), LuggCalloutViewManager(), LuggMapWrapperViewManager(), LuggPolylineViewManager(), LuggPolygonViewManager())
}
24 changes: 23 additions & 1 deletion android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions android/src/main/java/com/luggmaps/events/CalloutPressEvent.kt
Original file line number Diff line number Diff line change
@@ -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<CalloutPressEvent>(UIManagerHelper.getSurfaceId(view), view.id) {
override fun getEventName() = "topCalloutPress"

override fun getEventData() = Arguments.createMap()
}
51 changes: 51 additions & 0 deletions docs/CALLOUT.md
Original file line number Diff line number Diff line change
@@ -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';

<MapView style={{ flex: 1 }}>
{/* Native callout with press handler */}
<Marker
coordinate={{ latitude: 37.7749, longitude: -122.4194 }}
title="San Francisco"
description="California, USA"
>
<Callout onPress={() => console.log('Callout pressed')} />
</Marker>

{/* Custom callout content */}
<Marker coordinate={{ latitude: 37.8049, longitude: -122.4094 }}>
<Callout onPress={() => console.log('Callout pressed')}>
<View style={{ padding: 8 }}>
<Text style={{ fontWeight: 'bold' }}>Custom Callout</Text>
<Text>With React content</Text>
</View>
</Callout>
</Marker>
</MapView>
```

## 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.
14 changes: 14 additions & 0 deletions docs/MARKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<Marker
coordinate={{ latitude: 37.7749, longitude: -122.4194 }}
title="San Francisco"
description="California, USA"
>
<Callout onPress={() => console.log('Callout pressed')} />
</Marker>
```
4 changes: 2 additions & 2 deletions example/bare/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -3050,7 +3050,7 @@ SPEC CHECKSUMS:
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
GoogleMaps: 0608099d4870cac8754bdba9b6953db543432438
hermes-engine: 3515eff1a2de44b79dfa94a03d1adeed40f0dafe
LuggMaps: 9fadfbfebeead30c7c578722bbb306e433fce8ed
LuggMaps: 6ee60e80f6dadc777bdd7619d9f067cefa053cb0
RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669
RCTDeprecation: 2b70c6e3abe00396cefd8913efbf6a2db01a2b36
RCTRequired: f3540eee8094231581d40c5c6d41b0f170237a81
Expand Down
Loading
Loading