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..5e1271a --- /dev/null +++ b/android/src/main/java/com/luggmaps/LuggCalloutView.kt @@ -0,0 +1,74 @@ +package com.luggmaps + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.view.View +import androidx.core.graphics.createBitmap +import androidx.core.view.isNotEmpty +import com.facebook.react.views.view.ReactViewGroup + +class LuggCalloutView(context: Context) : ReactViewGroup(context) { + val contentView: ReactViewGroup = ReactViewGroup(context) + var bubbled: Boolean = true + var anchorX: Float = 0.5f + set(value) { + field = value + onUpdate?.invoke() + } + var anchorY: Float = 1.0f + set(value) { + field = value + onUpdate?.invoke() + } + var onUpdate: (() -> Unit)? = null + + val hasCustomContent: Boolean + get() = contentView.isNotEmpty() + + init { + visibility = GONE + } + + override fun addView(child: View, index: Int) { + contentView.addView(child, index) + child.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + onUpdate?.invoke() + } + } + + 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 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..5326fe7 --- /dev/null +++ b/android/src/main/java/com/luggmaps/LuggCalloutViewManager.kt @@ -0,0 +1,38 @@ +package com.luggmaps + +import com.facebook.react.bridge.ReadableMap +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 setAnchor(view: LuggCalloutView, value: ReadableMap?) { + view.anchorX = value?.getDouble("x")?.toFloat() ?: 0.5f + view.anchorY = value?.getDouble("y")?.toFloat() ?: 1.0f + } + + 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/LuggMapWrapperView.kt b/android/src/main/java/com/luggmaps/LuggMapWrapperView.kt index 3e63dcb..8139e44 100644 --- a/android/src/main/java/com/luggmaps/LuggMapWrapperView.kt +++ b/android/src/main/java/com/luggmaps/LuggMapWrapperView.kt @@ -15,17 +15,6 @@ class LuggMapWrapperView(context: ThemedReactContext) : ReactViewGroup(context) return super.dispatchTouchEvent(event) } - override fun requestLayout() { - super.requestLayout() - getChildAt(0)?.let { - it.measure( - MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) - ) - it.layout(0, 0, width, height) - } - } - override fun onLayout( changed: Boolean, left: Int, diff --git a/android/src/main/java/com/luggmaps/LuggMarkerView.kt b/android/src/main/java/com/luggmaps/LuggMarkerView.kt index dc71480..df9bf44 100644 --- a/android/src/main/java/com/luggmaps/LuggMarkerView.kt +++ b/android/src/main/java/com/luggmaps/LuggMarkerView.kt @@ -1,10 +1,8 @@ package com.luggmaps import android.content.Context -import android.graphics.Bitmap import android.graphics.Canvas import android.view.View -import android.view.ViewGroup import androidx.core.graphics.createBitmap import androidx.core.view.isNotEmpty import com.facebook.react.views.view.ReactViewGroup @@ -68,15 +66,20 @@ class LuggMarkerView(context: Context) : ReactViewGroup(context) { private set val hasCustomView: Boolean - get() = iconView.isNotEmpty() + get() = contentView.isNotEmpty() - val iconView: ReactViewGroup = ReactViewGroup(context) + val contentView: ReactViewGroup = ReactViewGroup(context) - private fun measureIconViewBounds(): Pair { + var onUpdate: (() -> Unit)? = null + + var calloutView: LuggCalloutView? = null + private set + + private fun measureContentBounds(): Pair { var maxWidth = 0 var maxHeight = 0 - for (i in 0 until iconView.childCount) { - val child = iconView.getChildAt(i) + 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 @@ -85,8 +88,8 @@ class LuggMarkerView(context: Context) : ReactViewGroup(context) { return Pair(maxWidth, maxHeight) } - private fun createIconBitmap(): BitmapDescriptor? { - val (width, height) = measureIconViewBounds() + private fun createContentBitmap(): BitmapDescriptor? { + val (width, height) = measureContentBounds() if (width <= 0 || height <= 0) return null val scaledWidth = (width * scale).toInt() @@ -95,41 +98,31 @@ class LuggMarkerView(context: Context) : ReactViewGroup(context) { val bitmap = createBitmap(scaledWidth, scaledHeight) val canvas = Canvas(bitmap) canvas.scale(scale, scale) - iconView.draw(canvas) + contentView.draw(canvas) return BitmapDescriptorFactory.fromBitmap(bitmap) } - private fun createIconViewWrapper(): View { - val (width, height) = measureIconViewBounds() + fun layoutContentView() { + val (width, height) = measureContentBounds() val scaledWidth = (width * scale).toInt() val scaledHeight = (height * scale).toInt() - (iconView.parent as? ViewGroup)?.removeView(iconView) - iconView.scaleX = scale - iconView.scaleY = scale - iconView.pivotX = 0f - iconView.pivotY = 0f + contentView.scaleX = scale + contentView.scaleY = scale + contentView.pivotX = 0f + contentView.pivotY = 0f - return object : ReactViewGroup(context) { - init { - addView(iconView) - } - - override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { - setMeasuredDimension(scaledWidth, scaledHeight) - } - } + contentView.measure( + View.MeasureSpec.makeMeasureSpec(scaledWidth, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(scaledHeight, View.MeasureSpec.EXACTLY) + ) + contentView.layout(0, 0, scaledWidth, scaledHeight) } fun applyIconToMarker() { val m = marker ?: return if (!hasCustomView) return - - if (rasterize) { - createIconBitmap()?.let { m.setIcon(it) } - } else { - m.iconView = createIconViewWrapper() - } + createContentBitmap()?.let { m.setIcon(it) } } fun applyScaleToMarker() { @@ -139,9 +132,10 @@ class LuggMarkerView(context: Context) : ReactViewGroup(context) { scaleUpdateRunnable?.let { removeCallbacks(it) } scaleUpdateRunnable = Runnable { if (rasterize) { - createIconBitmap()?.let { m.setIcon(it) } + createContentBitmap()?.let { m.setIcon(it) } } else { - m.iconView = createIconViewWrapper() + layoutContentView() + onUpdate?.invoke() } } post(scaleUpdateRunnable) @@ -149,18 +143,15 @@ class LuggMarkerView(context: Context) : ReactViewGroup(context) { fun updateIcon(onAddMarker: () -> Unit) { if (!hasCustomView) return - if (rasterize) { - post { - if (marker == null) { - onAddMarker() - } else { - applyIconToMarker() - } + post { + if (marker == null) { + onAddMarker() + } else if (rasterize) { + applyIconToMarker() + } else { + layoutContentView() + onUpdate?.invoke() } - } else { - marker?.remove() - marker = null - post { onAddMarker() } } } @@ -169,23 +160,52 @@ class LuggMarkerView(context: Context) : ReactViewGroup(context) { } override fun addView(child: View, index: Int) { - iconView.addView(child, index) + if (child is LuggCalloutView) { + calloutView = child + } else { + contentView.addView(child, index) + } didLayout = false } override fun removeView(child: View) { - iconView.removeView(child) + if (child is LuggCalloutView) { + calloutView = null + } else { + contentView.removeView(child) + } didLayout = false } override fun removeViewAt(index: Int) { - iconView.removeViewAt(index) + val child = getChildAt(index) + if (child is LuggCalloutView) { + calloutView = null + } else { + contentView.removeViewAt(index) + } didLayout = false } - override fun getChildCount(): Int = iconView.childCount + override fun removeViews(start: Int, count: Int) { + for (i in (start until start + count).reversed()) { + val child = getChildAt(i) + if (child is LuggCalloutView) { + calloutView = null + } else if (i < contentView.childCount) { + contentView.removeViewAt(i) + } + } + didLayout = false + } + + override fun getChildCount(): Int = contentView.childCount + if (calloutView != null) 1 else 0 - override fun getChildAt(index: Int): View? = iconView.getChildAt(index) + override fun getChildAt(index: Int): View? { + if (index < contentView.childCount) return contentView.getChildAt(index) + if (index == contentView.childCount && calloutView != null) return calloutView + return null + } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) @@ -277,8 +297,10 @@ class LuggMarkerView(context: Context) : ReactViewGroup(context) { fun onDropViewInstance() { scaleUpdateRunnable?.let { removeCallbacks(it) } scaleUpdateRunnable = null + onUpdate = null didLayout = false + calloutView = null delegate = null - iconView.removeAllViews() + contentView.removeAllViews() } } diff --git a/android/src/main/java/com/luggmaps/LuggPackage.kt b/android/src/main/java/com/luggmaps/LuggPackage.kt index 4598a42..d85b123 100644 --- a/android/src/main/java/com/luggmaps/LuggPackage.kt +++ b/android/src/main/java/com/luggmaps/LuggPackage.kt @@ -6,5 +6,12 @@ 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..15d648d 100644 --- a/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt +++ b/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt @@ -3,6 +3,8 @@ package com.luggmaps.core import android.annotation.SuppressLint import android.content.Context import android.view.View +import android.widget.ImageView +import androidx.core.graphics.createBitmap import com.facebook.react.uimanager.PixelUtil.dpToPx import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.gms.maps.GoogleMap @@ -11,12 +13,14 @@ import com.google.android.gms.maps.MapView import com.google.android.gms.maps.OnMapReadyCallback import com.google.android.gms.maps.model.AdvancedMarker import com.google.android.gms.maps.model.AdvancedMarkerOptions +import com.google.android.gms.maps.model.BitmapDescriptorFactory import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.MapColorScheme 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 +42,8 @@ class GoogleMapProvider(private val context: Context) : GoogleMap.OnMapLongClickListener, GoogleMap.OnPolygonClickListener, GoogleMap.OnMarkerClickListener, - GoogleMap.OnMarkerDragListener { + GoogleMap.OnMarkerDragListener, + GoogleMap.InfoWindowAdapter { override var delegate: MapProviderDelegate? = null override val isMapReady: Boolean get() = _isMapReady @@ -56,6 +61,8 @@ class GoogleMapProvider(private val context: Context) : private val polylineAnimators = mutableMapOf() private val polygonToViewMap = mutableMapOf() private val markerToViewMap = mutableMapOf() + private val liveMarkerViews = mutableSetOf() + private var activeNonBubbledMarker: Marker? = null private var tapLocation: LatLng? = null // Initial camera settings @@ -103,6 +110,11 @@ class GoogleMapProvider(private val context: Context) : } override fun destroy() { + dismissNonBubbledCallout() + for (markerView in liveMarkerViews) { + markerView.onUpdate = null + } + liveMarkerViews.clear() pendingMarkerViews.clear() pendingPolylineViews.clear() pendingPolygonViews.clear() @@ -120,6 +132,7 @@ class GoogleMapProvider(private val context: Context) : googleMap?.setOnPolygonClickListener(null) googleMap?.setOnMarkerClickListener(null) googleMap?.setOnMarkerDragListener(null) + googleMap?.setInfoWindowAdapter(null) googleMap?.clear() googleMap = null _isMapReady = false @@ -143,6 +156,7 @@ class GoogleMapProvider(private val context: Context) : map.setOnPolygonClickListener(this) map.setOnMarkerClickListener(this) map.setOnMarkerDragListener(this) + map.setInfoWindowAdapter(this) wrapperView?.touchEventHandler = { event -> if (event.action == android.view.MotionEvent.ACTION_DOWN) { @@ -177,6 +191,8 @@ 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) + positionLiveMarkers() + positionNonBubbledCallout() } override fun onCameraIdle() { @@ -190,6 +206,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 +228,18 @@ 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) { + googleMap?.animateCamera(CameraUpdateFactory.newLatLng(marker.position)) + showNonBubbledCallout(marker, calloutView) + return true + } } return false } @@ -224,6 +250,7 @@ class GoogleMapProvider(private val context: Context) : view.setCoordinate(marker.position.latitude, marker.position.longitude) val point = googleMap?.projection?.toScreenLocation(marker.position) view.emitDragStartEvent(point?.x?.toFloat() ?: 0f, point?.y?.toFloat() ?: 0f) + if (!view.rasterize) positionLiveMarker(view) } } @@ -232,6 +259,7 @@ class GoogleMapProvider(private val context: Context) : view.setCoordinate(marker.position.latitude, marker.position.longitude) val point = googleMap?.projection?.toScreenLocation(marker.position) view.emitDragChangeEvent(point?.x?.toFloat() ?: 0f, point?.y?.toFloat() ?: 0f) + if (!view.rasterize) positionLiveMarker(view) } } @@ -241,7 +269,92 @@ class GoogleMapProvider(private val context: Context) : view.setCoordinate(marker.position.latitude, marker.position.longitude) val point = googleMap?.projection?.toScreenLocation(marker.position) view.emitDragEndEvent(point?.x?.toFloat() ?: 0f, point?.y?.toFloat() ?: 0f) + if (!view.rasterize) positionLiveMarker(view) + } + } + + 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) } + } + + private fun showNonBubbledCallout(marker: Marker, calloutView: LuggCalloutView) { + val wrapper = wrapperView ?: return + val contentView = calloutView.contentView + + calloutView.onUpdate = { + layoutNonBubbledCallout() + positionNonBubbledCallout() } + + dismissInfoWindows() + wrapper.addView(contentView) + activeNonBubbledMarker = marker + layoutNonBubbledCallout() + positionNonBubbledCallout() + } + + private fun dismissInfoWindows() { + for ((marker, _) in markerToViewMap) { + if (marker.isInfoWindowShown) { + marker.hideInfoWindow() + } + } + } + + private fun dismissNonBubbledCallout() { + val marker = activeNonBubbledMarker ?: return + val markerView = markerToViewMap[marker] ?: return + val calloutView = markerView.calloutView ?: return + val contentView = calloutView.contentView + + calloutView.onUpdate = null + (contentView.parent as? android.view.ViewGroup)?.removeView(contentView) + activeNonBubbledMarker = null + } + + private fun layoutNonBubbledCallout() { + val marker = activeNonBubbledMarker ?: return + val markerView = markerToViewMap[marker] ?: return + val calloutView = markerView.calloutView ?: return + val contentView = calloutView.contentView + + var contentWidth = 0 + var contentHeight = 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 > contentWidth) contentWidth = childRight + if (childBottom > contentHeight) contentHeight = childBottom + } + + contentView.measure( + View.MeasureSpec.makeMeasureSpec(contentWidth, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(contentHeight, View.MeasureSpec.EXACTLY) + ) + contentView.layout(0, 0, contentWidth, contentHeight) + } + + 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.translationX = point.x - contentView.width * calloutView.anchorX + contentView.translationY = point.y - contentView.height * calloutView.anchorY } // endregion @@ -382,6 +495,7 @@ class GoogleMapProvider(private val context: Context) : } override fun removeMarkerView(markerView: LuggMarkerView) { + removeLiveMarker(markerView) markerView.marker?.let { markerToViewMap.remove(it) } markerView.marker?.remove() markerView.marker = null @@ -411,9 +525,14 @@ class GoogleMapProvider(private val context: Context) : isDraggable = markerView.draggable } - if (markerView.hasCustomView && markerView.scaleChanged) { - markerView.applyScaleToMarker() - markerView.clearScaleChanged() + if (markerView.hasCustomView) { + if (markerView.scaleChanged) { + markerView.applyScaleToMarker() + markerView.clearScaleChanged() + } + if (!markerView.rasterize) { + positionLiveMarker(markerView) + } } } @@ -440,7 +559,64 @@ class GoogleMapProvider(private val context: Context) : markerView.marker = marker markerToViewMap[marker] = markerView - markerView.applyIconToMarker() + + if (markerView.hasCustomView) { + if (markerView.rasterize) { + markerView.applyIconToMarker() + } else { + showLiveMarker(markerView) + } + } + } + + // 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. + private fun showLiveMarker(markerView: LuggMarkerView) { + val wrapper = wrapperView ?: return + + markerView.onUpdate = { + updateLiveMarkerHitArea(markerView) + positionLiveMarker(markerView) + } + + val contentView = markerView.contentView + contentView.pointerEvents = com.facebook.react.uimanager.PointerEvents.NONE + (contentView.parent as? android.view.ViewGroup)?.removeView(contentView) + wrapper.addView(contentView) + liveMarkerViews.add(markerView) + markerView.layoutContentView() + updateLiveMarkerHitArea(markerView) + positionLiveMarker(markerView) + } + + private fun updateLiveMarkerHitArea(markerView: LuggMarkerView) { + val marker = markerView.marker ?: return + val contentView = markerView.contentView + val w = contentView.width.coerceAtLeast(1) + val h = contentView.height.coerceAtLeast(1) + marker.setIcon(BitmapDescriptorFactory.fromBitmap(createBitmap(w, h))) + } + + private fun removeLiveMarker(markerView: LuggMarkerView) { + markerView.onUpdate = null + val contentView = markerView.contentView + (contentView.parent as? android.view.ViewGroup)?.removeView(contentView) + liveMarkerViews.remove(markerView) + } + + private fun positionLiveMarkers() { + for (markerView in liveMarkerViews) { + positionLiveMarker(markerView) + } + } + + private fun positionLiveMarker(markerView: LuggMarkerView) { + val map = googleMap ?: return + val contentView = markerView.contentView + val point = map.projection.toScreenLocation(LatLng(markerView.latitude, markerView.longitude)) + contentView.translationX = point.x - contentView.width * markerView.anchorX + contentView.translationY = point.y - contentView.height * markerView.anchorY } // endregion diff --git a/docs/MARKER.md b/docs/MARKER.md index ce3b5ac..48e790f 100644 --- a/docs/MARKER.md +++ b/docs/MARKER.md @@ -44,6 +44,8 @@ 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 | +| `calloutOptions` | `CalloutOptions` | - | Callout config. Supports `bubbled` and `anchor` (native only) | | `children` | `ReactNode` | - | Custom marker view | ## Draggable Markers @@ -82,3 +84,50 @@ 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 */} + + +{/* Custom callout content */} + + Custom Callout + With React content + + } +/> + +{/* Non-bubbled callout (no native chrome) */} + + Custom Tooltip + Rendered without native bubble + + } +/> +``` + +### Callout Options + +- `bubbled` - Whether to wrap the callout in the native platform bubble. Defaults to `true`. +- `anchor` - Anchor point for non-bubbled callout positioning relative to the marker. Defaults to `{x: 0.5, y: 1}`. + +### Platform Behavior + +- **Apple Maps (iOS)**: Custom callout content is rendered as a live interactive view inside the native callout bubble. With `calloutOptions={{ bubbled: 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 `calloutOptions={{ bubbled: false }}`, content is rendered as a live interactive view positioned above the marker (not rasterized), allowing interactive elements like buttons. Opening a non-bubbled callout hides any visible native info window first. On iOS, tapping the marker still moves the camera to the marker before showing the callout. +- **Web**: Uses Google Maps `InfoWindow`. With `calloutOptions={{ bubbled: 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/components/Map.tsx b/example/shared/src/components/Map.tsx index 79fa229..13d435b 100644 --- a/example/shared/src/components/Map.tsx +++ b/example/shared/src/components/Map.tsx @@ -1,5 +1,11 @@ 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 +26,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'; @@ -97,6 +104,13 @@ const renderMarker = ( ? (e: MarkerDragEvent) => onDragEnd(e, marker) : undefined; + const calloutEl = (label: string, desc: string) => ( + + {label} + {desc} + + ); + switch (type) { case 'icon': return ( @@ -109,6 +123,7 @@ const renderMarker = ( onDragStart={handleDragStart} onDragChange={handleDragChange} onDragEnd={handleDragEnd} + callout={calloutEl('Icon Marker', 'A pin-style marker')} /> ); case 'text': @@ -124,6 +139,7 @@ const renderMarker = ( onDragStart={handleDragStart} onDragChange={handleDragChange} onDragEnd={handleDragEnd} + callout={calloutEl(`Text Marker ${text}`, 'A text badge marker')} /> ); case 'image': @@ -138,6 +154,7 @@ const renderMarker = ( onDragStart={handleDragStart} onDragChange={handleDragChange} onDragEnd={handleDragEnd} + callout={calloutEl('Image Marker', 'An avatar marker')} /> ); case 'custom': @@ -152,6 +169,18 @@ const renderMarker = ( onDragStart={handleDragStart} onDragChange={handleDragChange} onDragEnd={handleDragEnd} + callout={ + + + Custom Marker + + Non-bubbled callout + + +