diff --git a/README.md b/README.md index 1c18d24..4d3fd3c 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ function App() { ## Usage ```tsx -import { MapView, Marker, Polyline } from '@lugg/maps'; +import { MapView, Marker, Polyline, Polygon } from '@lugg/maps'; + ``` @@ -104,6 +115,7 @@ import { MapView, Marker, Polyline } from '@lugg/maps'; - [MapView](docs/MAPVIEW.md) - Main map component - [Marker](docs/MARKER.md) - Map markers - [Polyline](docs/POLYLINE.md) - Draw lines on the map +- [Polygon](docs/POLYGON.md) - Draw filled shapes on the map ## Types diff --git a/android/src/main/java/com/luggmaps/LuggMapView.kt b/android/src/main/java/com/luggmaps/LuggMapView.kt index 6c00049..777c98e 100644 --- a/android/src/main/java/com/luggmaps/LuggMapView.kt +++ b/android/src/main/java/com/luggmaps/LuggMapView.kt @@ -63,6 +63,7 @@ class LuggMapView(private val reactContext: ThemedReactContext) : is LuggMapWrapperView -> mapWrapperView = child is LuggMarkerView -> provider?.addMarkerView(child) is LuggPolylineView -> provider?.addPolylineView(child) + is LuggPolygonView -> provider?.addPolygonView(child) } } @@ -71,6 +72,7 @@ class LuggMapView(private val reactContext: ThemedReactContext) : when (view) { is LuggMarkerView -> provider?.removeMarkerView(view) is LuggPolylineView -> provider?.removePolylineView(view) + is LuggPolygonView -> provider?.removePolygonView(view) } super.removeViewAt(index) } @@ -118,6 +120,7 @@ class LuggMapView(private val reactContext: ThemedReactContext) : when (val child = getChildAt(i)) { is LuggMarkerView -> google.addMarkerView(child) is LuggPolylineView -> google.addPolylineView(child) + is LuggPolygonView -> google.addPolygonView(child) } } } diff --git a/android/src/main/java/com/luggmaps/LuggMapWrapperView.kt b/android/src/main/java/com/luggmaps/LuggMapWrapperView.kt index b01e7cd..3e63dcb 100644 --- a/android/src/main/java/com/luggmaps/LuggMapWrapperView.kt +++ b/android/src/main/java/com/luggmaps/LuggMapWrapperView.kt @@ -1,13 +1,20 @@ package com.luggmaps import android.annotation.SuppressLint -import android.util.Log +import android.view.MotionEvent import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.views.view.ReactViewGroup @SuppressLint("ViewConstructor") class LuggMapWrapperView(context: ThemedReactContext) : ReactViewGroup(context) { + var touchEventHandler: ((MotionEvent) -> Unit)? = null + + override fun dispatchTouchEvent(event: MotionEvent): Boolean { + touchEventHandler?.invoke(event) + return super.dispatchTouchEvent(event) + } + override fun requestLayout() { super.requestLayout() getChildAt(0)?.let { diff --git a/android/src/main/java/com/luggmaps/LuggPackage.kt b/android/src/main/java/com/luggmaps/LuggPackage.kt index 4ecfa3d..4598a42 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()) + listOf(LuggMapViewManager(), LuggMarkerViewManager(), LuggMapWrapperViewManager(), LuggPolylineViewManager(), LuggPolygonViewManager()) } diff --git a/android/src/main/java/com/luggmaps/LuggPolygonView.kt b/android/src/main/java/com/luggmaps/LuggPolygonView.kt new file mode 100644 index 0000000..91569a7 --- /dev/null +++ b/android/src/main/java/com/luggmaps/LuggPolygonView.kt @@ -0,0 +1,78 @@ +package com.luggmaps + +import android.content.Context +import android.graphics.Color +import com.facebook.react.views.view.ReactViewGroup +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.Polygon +import com.luggmaps.events.PolygonPressEvent +import com.luggmaps.extensions.dispatchEvent + +interface LuggPolygonViewDelegate { + fun polygonViewDidUpdate(polygonView: LuggPolygonView) +} + +class LuggPolygonView(context: Context) : ReactViewGroup(context) { + var coordinates: List = emptyList() + private set + + var strokeColor: Int = Color.BLACK + private set + + var fillColor: Int = Color.argb(77, 0, 0, 0) + private set + + var strokeWidth: Float = 1f + private set + + var zIndex: Float = 0f + private set + + var tappable: Boolean = false + private set + + var delegate: LuggPolygonViewDelegate? = null + var polygon: Polygon? = null + + init { + visibility = GONE + } + + fun setCoordinates(coords: List) { + coordinates = coords + } + + fun setStrokeColor(color: Int) { + strokeColor = color + } + + fun setFillColor(color: Int) { + fillColor = color + } + + fun setStrokeWidth(width: Float) { + strokeWidth = width + } + + fun setZIndex(value: Float) { + zIndex = value + } + + fun setTappable(value: Boolean) { + tappable = value + } + + fun emitPressEvent() { + dispatchEvent(PolygonPressEvent(this)) + } + + fun onAfterUpdateTransaction() { + delegate?.polygonViewDidUpdate(this) + } + + fun onDropViewInstance() { + delegate = null + polygon?.remove() + polygon = null + } +} diff --git a/android/src/main/java/com/luggmaps/LuggPolygonViewManager.kt b/android/src/main/java/com/luggmaps/LuggPolygonViewManager.kt new file mode 100644 index 0000000..baf19a6 --- /dev/null +++ b/android/src/main/java/com/luggmaps/LuggPolygonViewManager.kt @@ -0,0 +1,76 @@ +package com.luggmaps + +import com.facebook.react.bridge.ReadableArray +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.uimanager.annotations.ReactProp +import com.facebook.react.viewmanagers.LuggPolygonViewManagerDelegate +import com.facebook.react.viewmanagers.LuggPolygonViewManagerInterface +import com.google.android.gms.maps.model.LatLng + +@ReactModule(name = LuggPolygonViewManager.NAME) +class LuggPolygonViewManager : + ViewGroupManager(), + LuggPolygonViewManagerInterface { + private val delegate: ViewManagerDelegate = LuggPolygonViewManagerDelegate(this) + + override fun getDelegate(): ViewManagerDelegate = delegate + override fun getName(): String = NAME + override fun createViewInstance(context: ThemedReactContext): LuggPolygonView = LuggPolygonView(context) + + override fun onDropViewInstance(view: LuggPolygonView) { + super.onDropViewInstance(view) + view.onDropViewInstance() + } + + override fun onAfterUpdateTransaction(view: LuggPolygonView) { + super.onAfterUpdateTransaction(view) + view.onAfterUpdateTransaction() + } + + @ReactProp(name = "coordinates") + override fun setCoordinates(view: LuggPolygonView, value: ReadableArray?) { + value?.let { array -> + val coords = mutableListOf() + for (i in 0 until array.size()) { + val coord = array.getMap(i) + val lat = coord?.getDouble("latitude") ?: 0.0 + val lng = coord?.getDouble("longitude") ?: 0.0 + coords.add(LatLng(lat, lng)) + } + view.setCoordinates(coords) + } + } + + @ReactProp(name = "strokeColor", customType = "Color") + override fun setStrokeColor(view: LuggPolygonView, value: Int?) { + view.setStrokeColor(value ?: android.graphics.Color.BLACK) + } + + @ReactProp(name = "fillColor", customType = "Color") + override fun setFillColor(view: LuggPolygonView, value: Int?) { + view.setFillColor(value ?: android.graphics.Color.argb(77, 0, 0, 0)) + } + + @ReactProp(name = "strokeWidth", defaultDouble = 1.0) + override fun setStrokeWidth(view: LuggPolygonView, value: Double) { + view.setStrokeWidth(value.toFloat()) + } + + @ReactProp(name = "tappable") + override fun setTappable(view: LuggPolygonView, value: Boolean) { + view.setTappable(value) + } + + @ReactProp(name = "zIndex", defaultFloat = 0f) + override fun setZIndex(view: LuggPolygonView, value: Float) { + super.setZIndex(view, value) + view.setZIndex(value) + } + + companion object { + const val NAME = "LuggPolygonView" + } +} diff --git a/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt b/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt index 14c21e4..f1292a2 100644 --- a/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt +++ b/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt @@ -2,9 +2,10 @@ package com.luggmaps.core import android.annotation.SuppressLint import android.content.Context +import android.graphics.Color +import android.view.MotionEvent import android.view.View import com.facebook.react.uimanager.PixelUtil.dpToPx -import com.facebook.react.util.RNLog import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.GoogleMapOptions @@ -14,9 +15,14 @@ import com.google.android.gms.maps.model.AdvancedMarker import com.google.android.gms.maps.model.AdvancedMarkerOptions import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.MapColorScheme +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.LuggMapWrapperView import com.luggmaps.LuggMarkerView import com.luggmaps.LuggMarkerViewDelegate +import com.luggmaps.LuggPolygonView +import com.luggmaps.LuggPolygonViewDelegate import com.luggmaps.LuggPolylineView import com.luggmaps.LuggPolylineViewDelegate @@ -25,22 +31,29 @@ class GoogleMapProvider(private val context: Context) : OnMapReadyCallback, LuggMarkerViewDelegate, LuggPolylineViewDelegate, + LuggPolygonViewDelegate, GoogleMap.OnCameraMoveStartedListener, GoogleMap.OnCameraMoveListener, - GoogleMap.OnCameraIdleListener { + GoogleMap.OnCameraIdleListener, + GoogleMap.OnPolygonClickListener { override var delegate: MapProviderDelegate? = null override val isMapReady: Boolean get() = _isMapReady var mapId: String = DEMO_MAP_ID + private var wrapperView: LuggMapWrapperView? = null private var mapView: MapView? = null private var googleMap: GoogleMap? = null private var _isMapReady = false private var isDragging = false private val pendingMarkerViews = mutableSetOf() private val pendingPolylineViews = mutableSetOf() + private val pendingPolygonViews = mutableSetOf() private val polylineAnimators = mutableMapOf() + private val polygonToViewMap = mutableMapOf() + private var pressedPolygon: Polygon? = null + private var pressedPolygonView: LuggPolygonView? = null // Initial camera settings private var initialLatitude: Double = 0.0 @@ -74,23 +87,34 @@ class GoogleMapProvider(private val context: Context) : initialLongitude = longitude initialZoom = zoom + val wrapper = wrapperView as LuggMapWrapperView + this.wrapperView = wrapper + wrapper.touchEventHandler = { event -> handleMapTouch(event) } + val options = GoogleMapOptions().mapId(mapId) mapView = MapView(context, options).also { view -> view.onCreate(null) view.onResume() view.getMapAsync(this) - (wrapperView as android.view.ViewGroup).addView(view) + wrapper.addView(view) } } override fun destroy() { pendingMarkerViews.clear() pendingPolylineViews.clear() + pendingPolygonViews.clear() polylineAnimators.values.forEach { it.destroy() } polylineAnimators.clear() + polygonToViewMap.clear() + pressedPolygon = null + pressedPolygonView = null + wrapperView?.touchEventHandler = null + wrapperView = null googleMap?.setOnCameraMoveStartedListener(null) googleMap?.setOnCameraMoveListener(null) googleMap?.setOnCameraIdleListener(null) + googleMap?.setOnPolygonClickListener(null) googleMap?.clear() googleMap = null _isMapReady = false @@ -109,6 +133,7 @@ class GoogleMapProvider(private val context: Context) : map.setOnCameraMoveStartedListener(this) map.setOnCameraMoveListener(this) map.setOnCameraIdleListener(this) + map.setOnPolygonClickListener(this) applyUiSettings() applyZoomLimits() @@ -117,6 +142,7 @@ class GoogleMapProvider(private val context: Context) : applyUserLocation() processPendingMarkers() processPendingPolylines() + processPendingPolygons() delegate?.mapProviderDidReady() } @@ -148,6 +174,77 @@ class GoogleMapProvider(private val context: Context) : isDragging = false } + override fun onPolygonClick(polygon: Polygon) { + val polygonView = polygonToViewMap[polygon] + restorePolygonHighlight() + polygonView?.emitPressEvent() + } + + private fun handleMapTouch(event: MotionEvent) { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + val map = googleMap ?: return + val point = android.graphics.Point(event.x.toInt(), event.y.toInt()) + val latLng = map.projection.fromScreenLocation(point) + + for ((polygon, view) in polygonToViewMap) { + if (!view.tappable) continue + if (containsLocation(latLng, polygon.points)) { + pressedPolygon = polygon + pressedPolygonView = view + applyPolygonHighlight() + return + } + } + } + + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + restorePolygonHighlight() + } + } + } + + private fun containsLocation(point: LatLng, polygon: List): Boolean { + if (polygon.isEmpty()) return false + var inside = false + var j = polygon.size - 1 + for (i in polygon.indices) { + val pi = polygon[i] + val pj = polygon[j] + if ((pi.latitude > point.latitude) != (pj.latitude > point.latitude) && + point.longitude < (pj.longitude - pi.longitude) * + (point.latitude - pi.latitude) / + (pj.latitude - pi.latitude) + + pi.longitude + ) { + inside = !inside + } + j = i + } + return inside + } + + private fun applyPolygonHighlight() { + val polygon = pressedPolygon ?: return + val view = pressedPolygonView ?: return + polygon.fillColor = fadeColor(view.fillColor) + polygon.strokeColor = fadeColor(view.strokeColor) + } + + private fun fadeColor(color: Int): Int { + val alpha = (Color.alpha(color) * 0.5).toInt() + return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)) + } + + private fun restorePolygonHighlight() { + val polygon = pressedPolygon ?: return + val view = pressedPolygonView ?: return + polygon.fillColor = view.fillColor + polygon.strokeColor = view.strokeColor + pressedPolygon = null + pressedPolygonView = null + } + // endregion // region Props @@ -270,6 +367,14 @@ class GoogleMapProvider(private val context: Context) : // endregion + // region PolygonViewDelegate + + override fun polygonViewDidUpdate(polygonView: LuggPolygonView) { + syncPolygonView(polygonView) + } + + // endregion + // region Marker Management override fun addMarkerView(markerView: LuggMarkerView) { @@ -404,6 +509,64 @@ class GoogleMapProvider(private val context: Context) : // endregion + // region Polygon Management + + override fun addPolygonView(polygonView: LuggPolygonView) { + polygonView.delegate = this + syncPolygonView(polygonView) + } + + override fun removePolygonView(polygonView: LuggPolygonView) { + polygonView.polygon?.let { polygonToViewMap.remove(it) } + polygonView.polygon?.remove() + polygonView.polygon = null + } + + private fun syncPolygonView(polygonView: LuggPolygonView) { + if (googleMap == null) { + pendingPolygonViews.add(polygonView) + return + } + + if (polygonView.polygon == null) { + addPolygonViewToMap(polygonView) + return + } + + polygonView.polygon?.apply { + points = polygonView.coordinates + fillColor = polygonView.fillColor + strokeColor = polygonView.strokeColor + strokeWidth = polygonView.strokeWidth.dpToPx() + zIndex = polygonView.zIndex + isClickable = polygonView.tappable + } + } + + private fun processPendingPolygons() { + if (googleMap == null) return + pendingPolygonViews.forEach { addPolygonViewToMap(it) } + pendingPolygonViews.clear() + } + + private fun addPolygonViewToMap(polygonView: LuggPolygonView) { + val map = googleMap ?: return + + val options = PolygonOptions() + .addAll(polygonView.coordinates) + .fillColor(polygonView.fillColor) + .strokeColor(polygonView.strokeColor) + .strokeWidth(polygonView.strokeWidth.dpToPx()) + .zIndex(polygonView.zIndex) + + val polygon = map.addPolygon(options) + polygon.isClickable = polygonView.tappable + polygonView.polygon = polygon + polygonToViewMap[polygon] = polygonView + } + + // endregion + // region Lifecycle override fun pauseAnimations() { diff --git a/android/src/main/java/com/luggmaps/core/MapProviderDelegate.kt b/android/src/main/java/com/luggmaps/core/MapProviderDelegate.kt index 210a45e..17af711 100644 --- a/android/src/main/java/com/luggmaps/core/MapProviderDelegate.kt +++ b/android/src/main/java/com/luggmaps/core/MapProviderDelegate.kt @@ -2,6 +2,7 @@ package com.luggmaps.core import android.view.View import com.luggmaps.LuggMarkerView +import com.luggmaps.LuggPolygonView import com.luggmaps.LuggPolylineView data class EdgeInsets(val top: Int = 0, val left: Int = 0, val bottom: Int = 0, val right: Int = 0) @@ -37,6 +38,8 @@ interface MapProvider { fun removeMarkerView(markerView: LuggMarkerView) fun addPolylineView(polylineView: LuggPolylineView) fun removePolylineView(polylineView: LuggPolylineView) + fun addPolygonView(polygonView: LuggPolygonView) + fun removePolygonView(polygonView: LuggPolygonView) // Lifecycle fun pauseAnimations() diff --git a/android/src/main/java/com/luggmaps/events/PolygonPressEvent.kt b/android/src/main/java/com/luggmaps/events/PolygonPressEvent.kt new file mode 100644 index 0000000..f68882d --- /dev/null +++ b/android/src/main/java/com/luggmaps/events/PolygonPressEvent.kt @@ -0,0 +1,12 @@ +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 PolygonPressEvent(view: View) : Event(UIManagerHelper.getSurfaceId(view), view.id) { + override fun getEventName() = "topPress" + + override fun getEventData() = Arguments.createMap() +} diff --git a/docs/POLYGON.md b/docs/POLYGON.md new file mode 100644 index 0000000..bd7d633 --- /dev/null +++ b/docs/POLYGON.md @@ -0,0 +1,36 @@ +# Polygon + +Polygon component for drawing filled shapes on the map. + +## Usage + +```tsx +import { MapView, Polygon } from '@lugg/maps'; + + + {/* Simple polygon */} + console.log('Polygon pressed')} + /> + +``` + +## Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `coordinates` | `Coordinate[]` | **required** | Array of coordinates forming the polygon boundary | +| `fillColor` | `ColorValue` | - | Fill color of the polygon | +| `strokeColor` | `ColorValue` | - | Stroke (outline) color | +| `strokeWidth` | `number` | - | Stroke width in points | +| `zIndex` | `number` | - | Z-index for layering | +| `onPress` | `() => void` | - | Called when the polygon is tapped | diff --git a/example/bare/ios/Podfile.lock b/example/bare/ios/Podfile.lock index 0c75b36..9315d4a 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.27): + - LuggMaps (0.2.0-alpha.30): - boost - DoubleConversion - fast_float @@ -3050,7 +3050,7 @@ SPEC CHECKSUMS: glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 GoogleMaps: 0608099d4870cac8754bdba9b6953db543432438 hermes-engine: 3515eff1a2de44b79dfa94a03d1adeed40f0dafe - LuggMaps: 52b43077d9f8ca87c7c9c22771aa61fd93df5ae3 + LuggMaps: 9fadfbfebeead30c7c578722bbb306e433fce8ed RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: 2b70c6e3abe00396cefd8913efbf6a2db01a2b36 RCTRequired: f3540eee8094231581d40c5c6d41b0f170237a81 diff --git a/example/shared/src/Home.tsx b/example/shared/src/Home.tsx index 66c1e74..550d6ff 100644 --- a/example/shared/src/Home.tsx +++ b/example/shared/src/Home.tsx @@ -63,8 +63,8 @@ function HomeContent() { const [provider, setProvider] = useState('apple'); const [showMap, setShowMap] = useState(true); const [markers, setMarkers] = useState(INITIAL_MARKERS); - const [cameraPosition, setCameraPosition] = useState(); - const [isIdle, setIsIdle] = useState(true); + const [statusText, setStatusText] = useState('Loading...'); + const lastCoordinate = useRef({ latitude: 37.78, longitude: -122.43 }); const getSheetBottom = useCallback( (event: DetentChangeEvent) => screenHeight - event.nativeEvent.position, @@ -94,17 +94,24 @@ function HomeContent() { [getSheetBottom] ); - const handleCameraEvent = useCallback( + const formatCameraEvent = useCallback( (event: { nativeEvent: CameraEventPayload }, idle: boolean) => { - setCameraPosition(event.nativeEvent); - setIsIdle(idle); + const { coordinate, zoom, gesture } = event.nativeEvent; + lastCoordinate.current = coordinate; + const pos = `${coordinate.latitude.toFixed( + 5 + )}, ${coordinate.longitude.toFixed(5)} (z${zoom.toFixed(1)})`; + const suffix = idle + ? ` (idle${gesture ? ', gesture' : ''})` + : gesture + ? ' (gesture)' + : ''; + setStatusText(pos + suffix); }, [] ); const addMarker = () => { - if (!cameraPosition) return; - const type = randomFrom(MARKER_TYPES); const id = Date.now().toString(); @@ -113,7 +120,7 @@ function HomeContent() { { id, name: `marker-${id}`, - coordinate: cameraPosition.coordinate, + coordinate: lastCoordinate.current, type, anchor: { x: 0.5, y: type === 'icon' ? 1 : 0.5 }, text: randomLetter(), @@ -159,8 +166,9 @@ function HomeContent() { animatedPosition={animatedPosition} userLocationEnabled={locationPermission} onReady={handleMapReady} - onCameraMove={(e) => handleCameraEvent(e, false)} - onCameraIdle={(e) => handleCameraEvent(e, true)} + onCameraMove={(e) => formatCameraEvent(e, false)} + onCameraIdle={(e) => formatCameraEvent(e, true)} + onPolygonPress={() => setStatusText('Polygon pressed!')} /> )} @@ -176,22 +184,7 @@ function HomeContent() { onDidPresent={handleSheetPresent} onDetentChange={handleDetentChange} > - - {cameraPosition ? ( - <> - {cameraPosition.coordinate.latitude.toFixed(5)},{' '} - {cameraPosition.coordinate.longitude.toFixed(5)} (z - {cameraPosition.zoom.toFixed(1)}) - {isIdle - ? ` (idle${cameraPosition.gesture ? ', gesture' : ''})` - : cameraPosition.gesture - ? ' (gesture)' - : ''} - - ) : ( - 'Loading...' - )} - + {statusText}