diff --git a/android/build.gradle b/android/build.gradle index ad78c0e..d48138b 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -28,7 +28,7 @@ apply from: "./fix-prefab.gradle" if (rootProject.name != "rngooglemapsplus.example") { apply plugin: "com.facebook.react" } else { - println("\u001B[33m⚠️ Skipping React Native Gradle plugin in library (example build detected)\u001B[0m") + println("\u001B[33mSkipping React Native Gradle plugin in library (example build detected)\u001B[0m") } def getExtOrIntegerDefault(name) { diff --git a/android/src/main/java/com/rngooglemapsplus/GoogleMapsViewImpl.kt b/android/src/main/java/com/rngooglemapsplus/GoogleMapsViewImpl.kt index ad55a07..1e859b6 100644 --- a/android/src/main/java/com/rngooglemapsplus/GoogleMapsViewImpl.kt +++ b/android/src/main/java/com/rngooglemapsplus/GoogleMapsViewImpl.kt @@ -35,9 +35,11 @@ import com.google.android.gms.maps.model.TileOverlayOptions import com.google.maps.android.data.kml.KmlLayer import com.margelo.nitro.core.Promise import com.rngooglemapsplus.extensions.toGooglePriority +import com.rngooglemapsplus.extensions.toLatLng import com.rngooglemapsplus.extensions.toLocationErrorCode import com.rngooglemapsplus.extensions.toRNIndoorBuilding import com.rngooglemapsplus.extensions.toRNIndoorLevel +import com.rngooglemapsplus.extensions.toRnLatLng import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File @@ -88,11 +90,7 @@ class GoogleMapsViewImpl( reactContext.addLifecycleEventListener(this) } - fun initMapView( - mapId: String?, - liteMode: Boolean?, - cameraPosition: CameraPosition?, - ) { + fun initMapView(googleMapsOptions: GoogleMapOptions) { if (initialized) return initialized = true val result = playServiceHandler.playServicesAvailability() @@ -126,13 +124,7 @@ class GoogleMapsViewImpl( mapView = MapView( reactContext, - GoogleMapOptions().apply { - mapId?.let { mapId(it) } - liteMode?.let { liteMode(it) } - cameraPosition?.let { - camera(it) - } - }, + googleMapsOptions, ) super.addView(mapView) @@ -173,12 +165,12 @@ class GoogleMapsViewImpl( onCameraChangeStart?.invoke( RNRegion( - center = RNLatLng(bounds.center.latitude, bounds.center.longitude), + center = bounds.center.toRnLatLng(), latitudeDelta = latDelta, longitudeDelta = lngDelta, ), RNCamera( - center = RNLatLng(cameraPosition.target.latitude, cameraPosition.target.longitude), + center = cameraPosition.target.toRnLatLng(), zoom = cameraPosition.zoom.toDouble(), bearing = cameraPosition.bearing.toDouble(), tilt = cameraPosition.tilt.toDouble(), @@ -205,12 +197,12 @@ class GoogleMapsViewImpl( onCameraChange?.invoke( RNRegion( - center = RNLatLng(bounds.center.latitude, bounds.center.longitude), + center = bounds.center.toRnLatLng(), latitudeDelta = latDelta, longitudeDelta = lngDelta, ), RNCamera( - center = RNLatLng(cameraPosition.target.latitude, cameraPosition.target.longitude), + center = cameraPosition.target.toRnLatLng(), zoom = cameraPosition.zoom.toDouble(), bearing = cameraPosition.bearing.toDouble(), tilt = cameraPosition.tilt.toDouble(), @@ -233,12 +225,12 @@ class GoogleMapsViewImpl( onCameraChangeComplete?.invoke( RNRegion( - center = RNLatLng(bounds.center.latitude, bounds.center.longitude), + center = bounds.center.toRnLatLng(), latitudeDelta = latDelta, longitudeDelta = lngDelta, ), RNCamera( - center = RNLatLng(cameraPosition.target.latitude, cameraPosition.target.longitude), + center = cameraPosition.target.toRnLatLng(), zoom = cameraPosition.zoom.toDouble(), bearing = cameraPosition.bearing.toDouble(), tilt = cameraPosition.tilt.toDouble(), @@ -544,7 +536,7 @@ class GoogleMapsViewImpl( onUi { val builder = LatLngBounds.Builder() coordinates.forEach { coord -> - builder.include(LatLng(coord.latitude, coord.longitude)) + builder.include(coord.toLatLng()) } val bounds = builder.build() @@ -1069,28 +1061,28 @@ class GoogleMapsViewImpl( override fun onMapClick(coordinates: LatLng) { onMapPress?.invoke( - RNLatLng(coordinates.latitude, coordinates.longitude), + coordinates.toRnLatLng(), ) } override fun onMarkerDragStart(marker: Marker) { onMarkerDragStart?.invoke( marker.tag?.toString(), - RNLatLng(marker.position.latitude, marker.position.longitude), + marker.position.toRnLatLng(), ) } override fun onMarkerDrag(marker: Marker) { onMarkerDrag?.invoke( marker.tag?.toString(), - RNLatLng(marker.position.latitude, marker.position.longitude), + marker.position.toRnLatLng(), ) } override fun onMarkerDragEnd(marker: Marker) { onMarkerDragEnd?.invoke( marker.tag?.toString(), - RNLatLng(marker.position.latitude, marker.position.longitude), + marker.position.toRnLatLng(), ) } diff --git a/android/src/main/java/com/rngooglemapsplus/MapCircleBuilder.kt b/android/src/main/java/com/rngooglemapsplus/MapCircleBuilder.kt index 07a98f5..45e2104 100644 --- a/android/src/main/java/com/rngooglemapsplus/MapCircleBuilder.kt +++ b/android/src/main/java/com/rngooglemapsplus/MapCircleBuilder.kt @@ -4,13 +4,13 @@ import android.graphics.Color import com.facebook.react.uimanager.PixelUtil.dpToPx import com.google.android.gms.maps.model.Circle import com.google.android.gms.maps.model.CircleOptions -import com.google.android.gms.maps.model.LatLng import com.rngooglemapsplus.extensions.toColor +import com.rngooglemapsplus.extensions.toLatLng class MapCircleBuilder { fun build(circle: RNCircle): CircleOptions = CircleOptions().apply { - center(LatLng(circle.center.latitude, circle.center.longitude)) + center(circle.center.toLatLng()) radius(circle.radius) circle.strokeWidth?.let { strokeWidth(it.dpToPx()) } circle.strokeColor?.let { strokeColor(it.toColor()) } @@ -23,11 +23,12 @@ class MapCircleBuilder { circle: Circle, next: RNCircle, ) { - circle.center = LatLng(next.center.latitude, next.center.longitude) + circle.center = next.center.toLatLng() circle.radius = next.radius circle.strokeWidth = next.strokeWidth?.dpToPx() ?: 1f circle.strokeColor = next.strokeColor?.toColor() ?: Color.BLACK circle.fillColor = next.fillColor?.toColor() ?: Color.TRANSPARENT + circle.isClickable = next.pressable ?: false circle.zIndex = next.zIndex?.toFloat() ?: 0f } } diff --git a/android/src/main/java/com/rngooglemapsplus/MapMarkerBuilder.kt b/android/src/main/java/com/rngooglemapsplus/MapMarkerBuilder.kt index 1e2fe56..ddb48dd 100644 --- a/android/src/main/java/com/rngooglemapsplus/MapMarkerBuilder.kt +++ b/android/src/main/java/com/rngooglemapsplus/MapMarkerBuilder.kt @@ -8,11 +8,11 @@ import com.caverock.androidsvg.SVG import com.facebook.react.uimanager.PixelUtil.dpToPx import com.google.android.gms.maps.model.BitmapDescriptor import com.google.android.gms.maps.model.BitmapDescriptorFactory -import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.Marker import com.google.android.gms.maps.model.MarkerOptions import com.rngooglemapsplus.extensions.markerStyleEquals import com.rngooglemapsplus.extensions.styleHash +import com.rngooglemapsplus.extensions.toLatLng import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -40,13 +40,15 @@ class MapMarkerBuilder( icon: BitmapDescriptor?, ): MarkerOptions = MarkerOptions().apply { - position(LatLng(m.coordinate.latitude, m.coordinate.longitude)) + position(m.coordinate.toLatLng()) icon(icon) m.title?.let { title(it) } m.snippet?.let { snippet(it) } m.opacity?.let { alpha(it.toFloat()) } m.flat?.let { flat(it) } m.draggable?.let { draggable(it) } + m.rotation?.let { rotation(it.toFloat()) } + m.infoWindowAnchor?.let { infoWindowAnchor(it.x.toFloat(), it.y.toFloat()) } m.anchor?.let { anchor((m.anchor.x).toFloat(), (m.anchor.y).toFloat()) } m.zIndex?.let { zIndex(it.toFloat()) } } @@ -57,10 +59,7 @@ class MapMarkerBuilder( next: RNMarker, ) { marker.position = - LatLng( - next.coordinate.latitude, - next.coordinate.longitude, - ) + next.coordinate.toLatLng() if (!prev.markerStyleEquals(next)) { buildIconAsync(marker.id, next) { icon -> @@ -69,9 +68,14 @@ class MapMarkerBuilder( } marker.title = next.title marker.snippet = next.snippet - marker.alpha = next.opacity?.toFloat() ?: 0f + marker.alpha = next.opacity?.toFloat() ?: 1f marker.isFlat = next.flat ?: false marker.isDraggable = next.draggable ?: false + marker.rotation = next.rotation?.toFloat() ?: 0f + marker.setInfoWindowAnchor( + (next.infoWindowAnchor?.x ?: 0.5).toFloat(), + (next.infoWindowAnchor?.y ?: 0).toFloat(), + ) marker.setAnchor( (next.anchor?.x ?: 0.5).toFloat(), (next.anchor?.y ?: 1.0).toFloat(), diff --git a/android/src/main/java/com/rngooglemapsplus/MapPolygonBuilder.kt b/android/src/main/java/com/rngooglemapsplus/MapPolygonBuilder.kt index f3173ae..a69e88e 100644 --- a/android/src/main/java/com/rngooglemapsplus/MapPolygonBuilder.kt +++ b/android/src/main/java/com/rngooglemapsplus/MapPolygonBuilder.kt @@ -2,38 +2,46 @@ package com.rngooglemapsplus import android.graphics.Color import com.facebook.react.uimanager.PixelUtil.dpToPx -import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.Polygon import com.google.android.gms.maps.model.PolygonOptions import com.rngooglemapsplus.extensions.toColor +import com.rngooglemapsplus.extensions.toLatLng class MapPolygonBuilder { fun build(poly: RNPolygon): PolygonOptions = PolygonOptions().apply { poly.coordinates.forEach { pt -> add( - com.google.android.gms.maps.model - .LatLng(pt.latitude, pt.longitude), + pt.toLatLng(), ) } poly.fillColor?.let { fillColor(it.toColor()) } poly.strokeColor?.let { strokeColor(it.toColor()) } poly.strokeWidth?.let { strokeWidth(it.dpToPx()) } poly.pressable?.let { clickable(it) } + poly.geodesic?.let { geodesic(it) } + poly.holes?.forEach { hole -> + addHole(hole.coordinates.map { it.toLatLng() }) + } poly.zIndex?.let { zIndex(it.toFloat()) } } fun update( - gmsPoly: Polygon, + poly: Polygon, next: RNPolygon, ) { - gmsPoly.points = + poly.points = next.coordinates.map { - LatLng(it.latitude, it.longitude) + it.toLatLng() } - gmsPoly.fillColor = next.fillColor?.toColor() ?: Color.TRANSPARENT - gmsPoly.strokeColor = next.strokeColor?.toColor() ?: Color.BLACK - gmsPoly.strokeWidth = next.strokeWidth?.dpToPx() ?: 1f - gmsPoly.zIndex = next.zIndex?.toFloat() ?: 0f + poly.fillColor = next.fillColor?.toColor() ?: Color.TRANSPARENT + poly.strokeColor = next.strokeColor?.toColor() ?: Color.BLACK + poly.strokeWidth = next.strokeWidth?.dpToPx() ?: 1f + poly.isClickable = next.pressable ?: false + poly.isGeodesic = next.geodesic ?: false + poly.holes = next.holes?.map { hole -> + hole.coordinates.map { it.toLatLng() } + } ?: emptyList() + poly.zIndex = next.zIndex?.toFloat() ?: 0f } } diff --git a/android/src/main/java/com/rngooglemapsplus/MapPolylineBuilder.kt.kt b/android/src/main/java/com/rngooglemapsplus/MapPolylineBuilder.kt.kt index f5571a9..d4bb76f 100644 --- a/android/src/main/java/com/rngooglemapsplus/MapPolylineBuilder.kt.kt +++ b/android/src/main/java/com/rngooglemapsplus/MapPolylineBuilder.kt.kt @@ -5,18 +5,18 @@ import com.facebook.react.uimanager.PixelUtil.dpToPx import com.google.android.gms.maps.model.ButtCap import com.google.android.gms.maps.model.Cap import com.google.android.gms.maps.model.JointType -import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.Polyline import com.google.android.gms.maps.model.PolylineOptions import com.google.android.gms.maps.model.RoundCap import com.google.android.gms.maps.model.SquareCap import com.rngooglemapsplus.extensions.toColor +import com.rngooglemapsplus.extensions.toLatLng class MapPolylineBuilder { fun build(pl: RNPolyline): PolylineOptions = PolylineOptions().apply { pl.coordinates.forEach { pt -> - add(LatLng(pt.latitude, pt.longitude)) + add(pt.toLatLng()) } pl.width?.let { width(it.dpToPx()) } pl.lineCap?.let { @@ -25,6 +25,7 @@ class MapPolylineBuilder { } pl.lineJoin?.let { jointType(mapLineJoin(it)) } pl.color?.let { color(it.toColor()) } + pl.geodesic?.let { geodesic(it) } pl.pressable?.let { clickable(it) } pl.zIndex?.let { zIndex(it.toFloat()) } } @@ -33,14 +34,15 @@ class MapPolylineBuilder { polyline: Polyline, next: RNPolyline, ) { - polyline.points = next.coordinates.map { LatLng(it.latitude, it.longitude) } - + polyline.points = next.coordinates.map { it.toLatLng() } polyline.width = next.width?.dpToPx() ?: 1f val cap = mapLineCap(next.lineCap ?: RNLineCapType.BUTT) polyline.startCap = cap polyline.endCap = cap polyline.jointType = mapLineJoin(next.lineJoin ?: RNLineJoinType.MITER) polyline.color = next.color?.toColor() ?: Color.BLACK + polyline.isClickable = next.pressable ?: false + polyline.isGeodesic = next.geodesic ?: false polyline.zIndex = next.zIndex?.toFloat() ?: 0f } diff --git a/android/src/main/java/com/rngooglemapsplus/RNGoogleMapsPlusView.kt b/android/src/main/java/com/rngooglemapsplus/RNGoogleMapsPlusView.kt index d31d24e..899dbf4 100644 --- a/android/src/main/java/com/rngooglemapsplus/RNGoogleMapsPlusView.kt +++ b/android/src/main/java/com/rngooglemapsplus/RNGoogleMapsPlusView.kt @@ -3,6 +3,7 @@ package com.rngooglemapsplus import com.facebook.proguard.annotations.DoNotStrip import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.uimanager.ThemedReactContext +import com.google.android.gms.maps.GoogleMapOptions import com.google.android.gms.maps.model.MapStyleOptions import com.margelo.nitro.core.Promise import com.rngooglemapsplus.extensions.circleEquals @@ -40,11 +41,13 @@ class RNGoogleMapsPlusView( super.afterUpdate() if (!propsInitialized) { propsInitialized = true - view.initMapView( - initialProps?.mapId, - initialProps?.liteMode, - initialProps?.camera?.toCameraPosition(), - ) + val options = + GoogleMapOptions().apply { + initialProps?.mapId?.let { mapId(it) } + initialProps?.liteMode?.let { liteMode(it) } + initialProps?.camera?.let { camera(it.toCameraPosition()) } + } + view.initMapView(options) } } diff --git a/android/src/main/java/com/rngooglemapsplus/extensions/LatLngExtension.kt b/android/src/main/java/com/rngooglemapsplus/extensions/LatLngExtension.kt new file mode 100644 index 0000000..7042edb --- /dev/null +++ b/android/src/main/java/com/rngooglemapsplus/extensions/LatLngExtension.kt @@ -0,0 +1,6 @@ +package com.rngooglemapsplus.extensions + +import com.google.android.gms.maps.model.LatLng +import com.rngooglemapsplus.RNLatLng + +fun LatLng.toRnLatLng(): RNLatLng = RNLatLng(latitude, longitude) diff --git a/android/src/main/java/com/rngooglemapsplus/extensions/RNCameraExtension.kt b/android/src/main/java/com/rngooglemapsplus/extensions/RNCameraExtension.kt index ed29245..65c87e4 100644 --- a/android/src/main/java/com/rngooglemapsplus/extensions/RNCameraExtension.kt +++ b/android/src/main/java/com/rngooglemapsplus/extensions/RNCameraExtension.kt @@ -1,14 +1,13 @@ package com.rngooglemapsplus.extensions import com.google.android.gms.maps.model.CameraPosition -import com.google.android.gms.maps.model.LatLng import com.rngooglemapsplus.RNCamera fun RNCamera.toCameraPosition(): CameraPosition { val builder = CameraPosition.builder() center?.let { - builder.target(LatLng(it.latitude, it.longitude)) + builder.target(it.toLatLng()) } zoom?.let { builder.zoom(it.toFloat()) } diff --git a/android/src/main/java/com/rngooglemapsplus/extensions/RNLatLngExtension.kt b/android/src/main/java/com/rngooglemapsplus/extensions/RNLatLngExtension.kt new file mode 100644 index 0000000..0925f6a --- /dev/null +++ b/android/src/main/java/com/rngooglemapsplus/extensions/RNLatLngExtension.kt @@ -0,0 +1,6 @@ +package com.rngooglemapsplus.extensions + +import com.google.android.gms.maps.model.LatLng +import com.rngooglemapsplus.RNLatLng + +fun RNLatLng.toLatLng(): LatLng = LatLng(latitude, longitude) diff --git a/android/src/main/java/com/rngooglemapsplus/extensions/RNMarkerExtension.kt b/android/src/main/java/com/rngooglemapsplus/extensions/RNMarkerExtension.kt index 01fde5c..6da266d 100644 --- a/android/src/main/java/com/rngooglemapsplus/extensions/RNMarkerExtension.kt +++ b/android/src/main/java/com/rngooglemapsplus/extensions/RNMarkerExtension.kt @@ -7,6 +7,14 @@ fun RNMarker.markerEquals(b: RNMarker): Boolean = zIndex == b.zIndex && coordinate == b.coordinate && anchor == b.anchor && + showInfoWindow == b.showInfoWindow && + title == b.title && + snippet == b.snippet && + opacity == b.opacity && + flat == b.flat && + draggable == b.draggable && + rotation == b.rotation && + infoWindowAnchor == b.infoWindowAnchor && markerStyleEquals(b) fun RNMarker.markerStyleEquals(b: RNMarker): Boolean = iconSvg == b.iconSvg diff --git a/android/src/main/java/com/rngooglemapsplus/extensions/RNPolygonExtension.kt b/android/src/main/java/com/rngooglemapsplus/extensions/RNPolygonExtension.kt index cc037e8..4814d4b 100644 --- a/android/src/main/java/com/rngooglemapsplus/extensions/RNPolygonExtension.kt +++ b/android/src/main/java/com/rngooglemapsplus/extensions/RNPolygonExtension.kt @@ -8,6 +8,8 @@ fun RNPolygon.polygonEquals(b: RNPolygon): Boolean { if (strokeWidth != b.strokeWidth) return false if (fillColor != b.fillColor) return false if (strokeColor != b.strokeColor) return false + if (geodesic != b.geodesic) return false + if (!holes.contentEquals(b.holes)) return false val ac = coordinates val bc = b.coordinates if (ac.size != bc.size) return false diff --git a/android/src/main/java/com/rngooglemapsplus/extensions/RNPolylineExtension.kt b/android/src/main/java/com/rngooglemapsplus/extensions/RNPolylineExtension.kt index 61b4ae7..0b7cdc6 100644 --- a/android/src/main/java/com/rngooglemapsplus/extensions/RNPolylineExtension.kt +++ b/android/src/main/java/com/rngooglemapsplus/extensions/RNPolylineExtension.kt @@ -8,6 +8,7 @@ fun RNPolyline.polylineEquals(b: RNPolyline): Boolean { if ((width ?: 0.0) != (b.width ?: 0.0)) return false if (lineCap != b.lineCap) return false if (lineJoin != b.lineJoin) return false + if (geodesic != b.geodesic) return false if (color != b.color) return false val ac = coordinates val bc = b.coordinates diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 5935e1a..ff231e0 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -16,7 +16,7 @@ PODS: - hermes-engine (0.82.0): - hermes-engine/Pre-built (= 0.82.0) - hermes-engine/Pre-built (0.82.0) - - NitroModules (0.29.8): + - NitroModules (0.30.0): - boost - DoubleConversion - fast_float @@ -3018,7 +3018,7 @@ SPEC CHECKSUMS: Google-Maps-iOS-Utils: bed22fa703c919259b3901449434d60d994fae20 GoogleMaps: a40d3b1f511f0fa2036e7b08c920c33279b1d5fd hermes-engine: 8642d8f14a548ab718ec112e9bebdfdd154138b5 - NitroModules: 73c42f3089bd74f411172ea8e69024aac4a2ac9f + NitroModules: 9d7097ba832aa88b678bf65b8a04e5ea565334d8 RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: 22bf66112da540a7d40e536366ddd8557934fca1 RCTRequired: a0ed4dc41b35f79fbb6d8ba320e06882a8c792cf diff --git a/example/package.json b/example/package.json index ccbb59e..4c81cd0 100644 --- a/example/package.json +++ b/example/package.json @@ -18,11 +18,12 @@ "react-native": "0.82.0", "react-native-gesture-handler": "2.28.0", "react-native-google-maps-plus": "workspace:*", - "react-native-nitro-modules": "0.29.8", + "react-native-nitro-modules": "0.30.0", "react-native-reanimated": "4.1.3", "react-native-safe-area-context": "5.6.1", "react-native-screens": "4.16.0", - "react-native-worklets": "0.6.1" + "react-native-worklets": "0.6.1", + "superstruct": "^2.0.2" }, "devDependencies": { "@babel/core": "7.28.4", @@ -36,7 +37,7 @@ "@react-native/typescript-config": "0.82.0", "@types/react": "19.1.1", "react-native-builder-bob": "0.40.13", - "react-native-monorepo-config": "0.2.1" + "react-native-monorepo-config": "0.2.2" }, "engines": { "node": ">=20" diff --git a/example/src/App.tsx b/example/src/App.tsx index 05f1756..30c2af2 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { - NavigationContainer, - DefaultTheme, DarkTheme, + DefaultTheme, + NavigationContainer, } from '@react-navigation/native'; import { createStackNavigator } from '@react-navigation/stack'; @@ -113,7 +113,7 @@ export default function App() { options={{ title: 'Snapshot test' }} /> diff --git a/example/src/components/ControlPanel.tsx b/example/src/components/ControlPanel.tsx index 1df87fa..2d6d58b 100644 --- a/example/src/components/ControlPanel.tsx +++ b/example/src/components/ControlPanel.tsx @@ -1,17 +1,17 @@ import React, { useMemo } from 'react'; import { - View, - Text, - TouchableOpacity, ScrollView, StyleSheet, + Text, + TouchableOpacity, + View, } from 'react-native'; import Animated, { - useSharedValue, + Extrapolation, + interpolate, useAnimatedStyle, + useSharedValue, withTiming, - interpolate, - Extrapolation, } from 'react-native-reanimated'; import type { GoogleMapsViewRef } from 'react-native-google-maps-plus'; import { useAppTheme } from '../theme'; @@ -29,7 +29,7 @@ export default function ControlPanel({ mapRef, buttons }: Props) { const theme = useAppTheme(); const navigation = useNavigation(); const progress = useSharedValue(0); - const styles = getThemedStyles(theme); + const styles = useMemo(() => getThemedStyles(theme), [theme]); const toggle = () => { progress.value = withTiming(progress.value === 1 ? 0 : 1, { @@ -62,7 +62,10 @@ export default function ControlPanel({ mapRef, buttons }: Props) { { title: 'Check Google Play Services', onPress: () => - console.log(mapRef.current?.isGooglePlayServicesAvailable()), + console.log( + 'Google Play Services result', + mapRef.current?.isGooglePlayServicesAvailable() + ), }, ], [buttons, mapRef, navigation] diff --git a/example/src/components/HeaderButton.tsx b/example/src/components/HeaderButton.tsx new file mode 100644 index 0000000..68e9880 --- /dev/null +++ b/example/src/components/HeaderButton.tsx @@ -0,0 +1,34 @@ +import React, { useMemo } from 'react'; +import { Pressable, StyleSheet, Text } from 'react-native'; +import { useAppTheme } from '../theme'; + +type Props = { + title: string; + onPress: () => void; +}; + +export default function HeaderButton({ title, onPress }: Props) { + const theme = useAppTheme(); + const styles = useMemo(() => getThemedStyles(theme), [theme]); + + return ( + [styles.headerButton]}> + {title} + + ); +} + +const getThemedStyles = (theme: any) => + StyleSheet.create({ + headerButton: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 6, + marginRight: 12, + }, + headerButtonText: { + color: theme.textPrimary, + fontSize: 15, + fontWeight: '600', + }, + }); diff --git a/example/src/components/MapWrapper.tsx b/example/src/components/MapWrapper.tsx index 5ccb035..14943ac 100644 --- a/example/src/components/MapWrapper.tsx +++ b/example/src/components/MapWrapper.tsx @@ -1,25 +1,23 @@ import React, { useMemo } from 'react'; +import type { ViewProps } from 'react-native'; import { ActivityIndicator, StyleSheet, View } from 'react-native'; -import { - GoogleMapsView, - type RNIndoorBuilding, - type RNIndoorLevel, - RNLocationErrorCode, - RNMapErrorCode, -} from 'react-native-google-maps-plus'; import type { GoogleMapsViewRef, - RNGoogleMapsPlusViewProps, RNCamera, + RNGoogleMapsPlusViewProps, + RNLatLng, RNLocation, RNRegion, - RNLatLng, } from 'react-native-google-maps-plus'; import { + GoogleMapsView, RNAndroidLocationPriority, + type RNIndoorBuilding, + type RNIndoorLevel, RNIOSLocationAccuracy, + RNLocationErrorCode, + RNMapErrorCode, } from 'react-native-google-maps-plus'; -import type { ViewProps } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { callback } from 'react-native-nitro-modules'; import { useTheme } from '@react-navigation/native'; @@ -33,7 +31,7 @@ type Props = ViewProps & export default function MapWrapper(props: Props) { const { children, ...rest } = props; const theme = useTheme(); - const styles = getThemedStyles(theme); + const styles = useMemo(() => getThemedStyles(theme), [theme]); const layout = useSafeAreaInsets(); const [mapReady, setMapReady] = React.useState(false); @@ -67,7 +65,7 @@ export default function MapWrapper(props: Props) { const mapPadding = useMemo(() => { return props.children ? { top: 20, left: 20, bottom: layout.bottom + 80, right: 20 } - : undefined; + : { top: 20, left: 20, bottom: layout.bottom, right: 20 }; }, [layout.bottom, props.children]); const mapZoomConfig = useMemo(() => ({ min: 0, max: 20 }), []); @@ -217,7 +215,7 @@ const getThemedStyles = (theme: any) => StyleSheet.create({ container: { flex: 1, - backgroundColor: theme.colors.background, + backgroundColor: theme.background, }, map: { position: 'absolute', @@ -227,10 +225,10 @@ const getThemedStyles = (theme: any) => bottom: 0, }, loadingOverlay: { - ...StyleSheet.absoluteFillObject, + ...StyleSheet.absoluteFill, justifyContent: 'center', alignItems: 'center', zIndex: 10, - backgroundColor: theme.dark ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.7)', + backgroundColor: theme.overlay, }, }); diff --git a/example/src/components/maptConfigDialog/MapConfigDialog.tsx b/example/src/components/maptConfigDialog/MapConfigDialog.tsx new file mode 100644 index 0000000..d981748 --- /dev/null +++ b/example/src/components/maptConfigDialog/MapConfigDialog.tsx @@ -0,0 +1,498 @@ +import React, { useMemo, useState } from 'react'; +import { + Alert, + FlatList, + Modal, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import type { Struct } from 'superstruct'; +import { validate } from 'superstruct'; +import { useAppTheme } from '../../theme'; + +type FieldType = 'text' | 'number' | 'boolean' | 'json'; + +export type FieldSchema = { + key: keyof T; + label?: string; + type: FieldType; + multiline?: boolean; + placeholder?: string; + options?: any[]; +}; + +type Props = { + visible: boolean; + title: string; + initialData: T; + onClose: () => void; + onSave: (updated: T) => void; + validator?: Struct; +}; + +const boolOptions = [true, false] as const; + +export default function MapConfigDialog({ + visible, + title, + initialData, + onClose, + onSave, + validator, +}: Props) { + const theme = useAppTheme(); + const styles = useMemo(() => getThemedStyles(theme), [theme]); + const [draft, setDraft] = useState(initialData); + const [activeDropdown, setActiveDropdown] = useState(null); + + const autoSchema: FieldSchema[] = useMemo(() => { + if (!validator || !('schema' in validator)) return []; + const unwrap = (s: any): any => { + let base = s; + while ( + base && + ['optional', 'nullable', 'defaulted'].includes(base.type) + ) { + if (base._values && base.schema && !base.schema._values) + base.schema._values = base._values; + if (base._schema && base.schema && !base.schema._schema) + base.schema._schema = base._schema; + base = base.schema; + } + return base; + }; + + const extractOptions = (schema: any): string[] | undefined => { + let v = schema; + while (v && ['optional', 'nullable', 'defaulted'].includes(v.type)) { + v = v.schema; + } + + if (!v) return; + if ( + Array.isArray(v._values) && + v._values.every((x: any) => typeof x === 'string') + ) { + return v._values; + } + + if (v.type === 'enums' && v.schema && typeof v.schema === 'object') { + const vals = Object.values(v.schema).filter( + (x): x is string => typeof x === 'string' + ); + if (vals.length) { + return vals; + } + } + + if (v.type === 'union' && Array.isArray(v._schema)) { + const literals = v._schema + .map((x: any) => { + return typeof x === 'object' && + x.schema && + typeof x.schema === 'string' + ? x.schema + : (x.value ?? x._value); + }) + .filter((x: any): x is string => typeof x === 'string'); + if (literals.length) { + return literals; + } + } + + if (v.type === 'union' && Array.isArray(v.schema)) { + const literals = v.schema + .map((x: any) => { + return typeof x === 'object' && + x.schema && + typeof x.schema === 'string' + ? x.schema + : (x.value ?? x._value); + }) + .filter((x: any): x is string => typeof x === 'string'); + if (literals.length) { + return literals; + } + } + + return undefined; + }; + + const result: FieldSchema[] = []; + + for (const [key, raw] of Object.entries((validator as any).schema)) { + const unwrapped = unwrap(raw); + + const options = extractOptions(raw); + + let type: FieldType = 'text'; + if (!options) { + if (unwrapped?.type === 'boolean') type = 'boolean'; + else if (unwrapped?.type === 'number') type = 'number'; + else if (unwrapped?.type === 'object' || unwrapped?.type === 'array') + type = 'json'; + } + + result.push({ + key: key as keyof T, + label: key, + type, + options, + multiline: type === 'json', + }); + } + + return result; + }, [validator]); + + const [jsonInputs, setJsonInputs] = useState>(() => { + const initial: Record = {}; + autoSchema.forEach((f) => { + if (f.type !== 'json') return; + const key = String(f.key); + const v = (initialData as any)[key]; + initial[key] = + v && typeof v === 'object' + ? JSON.stringify(v, null, 2) + : String(v ?? ''); + }); + return initial; + }); + + const updateField = (key: keyof T, value: any, type: FieldType) => { + setDraft((prev) => ({ + ...(prev as any), + [key]: + type === 'number' + ? value === '' || isNaN(Number(value)) + ? undefined + : Number(value) + : value, + })); + }; + + const handleSave = () => { + const updated: any = { ...(draft as any) }; + for (const f of autoSchema) { + const k = String(f.key); + if (f.type === 'json') { + const jsonStr = jsonInputs[k] ?? ''; + if (jsonStr.trim().length === 0) { + updated[k] = undefined; + } else { + try { + updated[k] = JSON.parse(jsonStr); + } catch (e: any) { + Alert.alert('Invalid JSON', `${f.label ?? k}: ${e.message}`); + return; + } + } + } + } + if (validator) { + const [err, value] = validate(updated, validator); + if (err) { + Alert.alert( + 'Validation Error', + `${err.path?.join('.') || '(root)'}: ${err.message}` + ); + return; + } + onSave(value as T); + } else { + onSave(updated as T); + } + onClose(); + }; + + const renderDropdown = ( + key: keyof T, + label: string, + value: any, + options: any[] + ) => ( + + {label} + setActiveDropdown(String(key))} + style={[ + styles.dropdownTrigger, + activeDropdown === String(key) && { borderColor: theme.bgAccent }, + ]} + > + + {value?.toString() ?? 'Select...'} + + + + {activeDropdown === String(key) && ( + + + setActiveDropdown(null)} + /> + + {label} + item.toString()} + showsVerticalScrollIndicator={false} + renderItem={({ item }) => ( + [styles.dropdownItem]} + onPress={() => { + updateField(key, item, 'text'); + setActiveDropdown(null); + }} + > + + {item.toString()} + + + )} + /> + setActiveDropdown(null)} + style={styles.dropdownCancel} + > + Cancel + + + + + )} + + ); + + const renderMultilineInput = ( + k: string, + value: string, + onChangeText: (v: string) => void, + placeholder?: string + ) => ( + + {k} + + + + + ); + + return ( + + + + {title} + + {autoSchema.map((f) => { + const key = f.key; + const k = String(key); + const label = f.label ?? k; + const value = (draft as any)[k]; + + if (f.options?.length) { + return renderDropdown(key, label, value, f.options); + } + + if (f.type === 'boolean') { + return renderDropdown(key, label, value, boolOptions as any); + } + + if (f.type === 'json') { + const jsonValue = jsonInputs[k] ?? ''; + return renderMultilineInput( + k, + jsonValue, + (v) => setJsonInputs((prev) => ({ ...prev, [k]: v })), + f.placeholder ?? '{}' + ); + } + + if (f.multiline) { + const str = value?.toString() ?? ''; + return renderMultilineInput( + k, + str, + (v) => updateField(key, v, f.type), + f.placeholder ?? label + ); + } + + return ( + + {label} + updateField(key, v, f.type)} + placeholder={f.placeholder ?? label} + placeholderTextColor={styles.placeholder.color} + autoCorrect={false} + autoComplete="off" + spellCheck={false} + autoCapitalize="none" + style={styles.input} + /> + + ); + })} + + + + Cancel + + + Save + + + + + + ); +} + +const getThemedStyles = (theme: any) => + StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: theme.overlay, + justifyContent: 'center', + alignItems: 'center', + }, + dialog: { + width: '85%', + maxHeight: '85%', + backgroundColor: theme.bgPrimary, + borderRadius: 12, + flexShrink: 1, + }, + scroll: { paddingBottom: 12, margin: 12 }, + title: { + padding: 12, + fontSize: 18, + fontWeight: '600', + color: theme.textPrimary, + marginBottom: 12, + }, + field: { marginBottom: 12 }, + label: { fontSize: 14, marginBottom: 4, color: theme.label }, + placeholder: { color: theme.placeholder }, + input: { + borderWidth: 1, + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 6, + fontSize: 13, + borderColor: theme.border, + color: theme.textPrimary, + backgroundColor: theme.inputBg, + fontFamily: 'monospace', + }, + multilineInput: { minHeight: 90, textAlignVertical: 'top' }, + innerScroll: { borderRadius: 8 }, + dropdownOverlay: { + flex: 1, + backgroundColor: theme.overlay, + justifyContent: 'center', + alignItems: 'center', + }, + dropdownBackdrop: { + ...StyleSheet.absoluteFillObject, + }, + dropdownContainer: { + width: '80%', + maxHeight: '70%', + borderRadius: 12, + backgroundColor: theme.bgPrimary, + paddingVertical: 12, + paddingHorizontal: 14, + shadowColor: '#000', + shadowOpacity: 0.25, + shadowRadius: 8, + elevation: 6, + }, + dropdownHeader: { + fontSize: 16, + fontWeight: '600', + color: theme.textPrimary, + marginBottom: 10, + textAlign: 'center', + }, + dropdownItem: { + paddingVertical: 10, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: theme.border, + }, + dropdownItemText: { + fontSize: 14, + color: theme.textPrimary, + textAlign: 'center', + }, + dropdownCancel: { + marginTop: 10, + backgroundColor: theme.cancelBg, + alignSelf: 'center', + borderRadius: 8, + paddingVertical: 8, + paddingHorizontal: 16, + }, + dropdownCancelText: { + color: theme.textOnAccent, + fontWeight: '500', + fontSize: 14, + }, + dropdownTrigger: { + borderWidth: 1, + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 10, + borderColor: theme.border, + backgroundColor: theme.inputBg, + }, + dropdownText: { fontSize: 14, color: theme.textPrimary }, + actions: { + flexDirection: 'row', + justifyContent: 'flex-end', + padding: 12, + }, + cancelButton: { + paddingVertical: 8, + paddingHorizontal: 14, + borderRadius: 8, + backgroundColor: theme.cancelBg, + marginRight: 8, + }, + saveButton: { + paddingVertical: 8, + paddingHorizontal: 14, + borderRadius: 8, + backgroundColor: theme.bgAccent, + }, + buttonText: { color: theme.textOnAccent, fontWeight: '500' }, + }); diff --git a/example/src/components/maptConfigDialog/validator.ts b/example/src/components/maptConfigDialog/validator.ts new file mode 100644 index 0000000..e017bc6 --- /dev/null +++ b/example/src/components/maptConfigDialog/validator.ts @@ -0,0 +1,347 @@ +import { + array, + boolean, + enums, + literal, + number, + object, + optional, + string, + type Struct, + union, +} from 'superstruct'; + +import { + RNAndroidLocationPermissionResult, + RNAndroidLocationPriority, + RNIOSLocationAccuracy, + RNIOSPermissionResult, + RNLocationErrorCode, + RNMapErrorCode, +} from 'react-native-google-maps-plus'; + +const enumValues = (e: T): T[keyof T][] => + Object.values(e).filter( + (v): v is T[keyof T] => typeof v === 'number' || typeof v === 'string' + ); + +export function unionWithValues( + ...values: T[] +): Struct { + if (values.length === 0) throw new Error('unionWithValues: no values given'); + const literals = values.map((v) => literal(v)) as [Struct, ...Struct[]]; + const innerUnion = union(literals); + (innerUnion as any)._values = values; + (innerUnion as any)._schema = literals; + const wrapped = optional(innerUnion); + (wrapped as any)._values = values; + (wrapped as any)._schema = literals; + if ((wrapped as any).schema) { + (wrapped as any).schema._values = values; + (wrapped as any).schema._schema = literals; + } + + return wrapped as any; +} + +export const RNLatLngValidator = object({ + latitude: number(), + longitude: number(), +}); + +export const RNLatLngBoundsValidator = object({ + northEast: RNLatLngValidator, + southWest: RNLatLngValidator, +}); + +export const RNSizeValidator = object({ + width: number(), + height: number(), +}); + +export const RNSnapshotFormatValidator = union([ + literal('png'), + literal('jpg'), + literal('jpeg'), +]); + +export const RNSnapshotResultTypeValidator = union([ + literal('base64'), + literal('file'), +]); + +export const RNSnapshotOptionsValidator = object({ + size: optional(RNSizeValidator), + format: RNSnapshotFormatValidator, + quality: number(), + resultType: RNSnapshotResultTypeValidator, +}); + +export const RNMapPaddingValidator = object({ + top: number(), + left: number(), + bottom: number(), + right: number(), +}); + +export const RNMapTypeValidator = unionWithValues( + 'none', + 'normal', + 'hybrid', + 'satellite', + 'terrain' +); + +export const RNUserInterfaceStyleValidator = unionWithValues( + 'light', + 'dark', + 'default' +); + +export const RNPositionValidator = object({ + x: number(), + y: number(), +}); + +export const RNCameraValidator = object({ + center: optional(RNLatLngValidator), + zoom: optional(number()), + bearing: optional(number()), + tilt: optional(number()), +}); + +export const RNRegionValidator = object({ + center: RNLatLngValidator, + latitudeDelta: number(), + longitudeDelta: number(), +}); + +export const RNMapUiSettingsValidator = object({ + allGesturesEnabled: optional(boolean()), + compassEnabled: optional(boolean()), + indoorLevelPickerEnabled: optional(boolean()), + mapToolbarEnabled: optional(boolean()), + myLocationButtonEnabled: optional(boolean()), + rotateEnabled: optional(boolean()), + scrollEnabled: optional(boolean()), + scrollDuringRotateOrZoomEnabled: optional(boolean()), + tiltEnabled: optional(boolean()), + zoomControlsEnabled: optional(boolean()), + zoomGesturesEnabled: optional(boolean()), +}); + +export const RNMapZoomConfigValidator = object({ + min: optional(number()), + max: optional(number()), +}); + +export const RNLineCapTypeValidator = union([ + literal('butt'), + literal('round'), + literal('square'), +]); + +export const RNLineJoinTypeValidator = union([ + literal('miter'), + literal('round'), + literal('bevel'), +]); + +export const RNMarkerSvgValidator = object({ + width: number(), + height: number(), + svgString: string(), +}); + +export const RNMarkerValidator = object({ + id: string(), + zIndex: optional(number()), + coordinate: RNLatLngValidator, + anchor: optional(RNPositionValidator), + showInfoWindow: optional(boolean()), + title: optional(string()), + snippet: optional(string()), + opacity: optional(number()), + flat: optional(boolean()), + draggable: optional(boolean()), + rotation: optional(number()), + infoWindowAnchor: optional(RNPositionValidator), + iconSvg: optional(RNMarkerSvgValidator), +}); + +export const RNPolygonHoleValidator = object({ + coordinates: array(RNLatLngValidator), +}); + +export const RNPolygonValidator = object({ + id: string(), + zIndex: optional(number()), + pressable: optional(boolean()), + coordinates: array(RNLatLngValidator), + fillColor: optional(string()), + strokeColor: optional(string()), + strokeWidth: optional(number()), + holes: optional(array(RNPolygonHoleValidator)), + geodesic: optional(boolean()), +}); + +export const RNPolylineValidator = object({ + id: string(), + zIndex: optional(number()), + pressable: optional(boolean()), + coordinates: array(RNLatLngValidator), + lineCap: optional(RNLineCapTypeValidator), + lineJoin: optional(RNLineJoinTypeValidator), + geodesic: optional(boolean()), + color: optional(string()), + width: optional(number()), +}); + +export const RNCircleValidator = object({ + id: string(), + pressable: optional(boolean()), + zIndex: optional(number()), + center: RNLatLngValidator, + radius: number(), + strokeWidth: optional(number()), + strokeColor: optional(string()), + fillColor: optional(string()), +}); + +export const RNHeatmapPointValidator = object({ + latitude: number(), + longitude: number(), + weight: number(), +}); + +export const RNHeatmapGradientValidator = object({ + colors: array(string()), + startPoints: array(number()), + colorMapSize: number(), +}); + +export const RNHeatmapValidator = object({ + id: string(), + pressable: optional(boolean()), + zIndex: optional(number()), + weightedData: array(RNHeatmapPointValidator), + radius: optional(number()), + opacity: optional(number()), + gradient: optional(RNHeatmapGradientValidator), +}); + +export const RNKMLayerValidator = object({ + id: string(), + kmlString: string(), +}); + +export const RNIndoorLevelValidator = object({ + index: number(), + name: optional(string()), + shortName: optional(string()), + active: optional(boolean()), +}); + +export const RNIndoorBuildingValidator = object({ + activeLevelIndex: optional(number()), + defaultLevelIndex: optional(number()), + levels: array(RNIndoorLevelValidator), + underground: optional(boolean()), +}); + +const RNAndroidLocationConfigValidator = object({ + priority: optional(enums(enumValues(RNAndroidLocationPriority))), + interval: optional(number()), + minUpdateInterval: optional(number()), +}); + +export const RNIOSLocationConfigValidator = object({ + desiredAccuracy: optional(enums(enumValues(RNIOSLocationAccuracy))), + distanceFilterMeters: optional(number()), +}); + +export const RNLocationConfigValidator = object({ + android: optional(RNAndroidLocationConfigValidator), + ios: optional(RNIOSLocationConfigValidator), +}); + +export const RNLocationPermissionResultValidator = object({ + android: optional(enums(enumValues(RNAndroidLocationPermissionResult))), + ios: optional(enums(enumValues(RNIOSPermissionResult))), +}); + +export const RNLocationValidator = object({ + center: RNLatLngValidator, + bearing: number(), +}); + +export const RNLocationErrorCodeStructValidator = optional( + enums(enumValues(RNLocationErrorCode)) +); + +export const RNMapErrorCodeStructValidator = optional( + enums(enumValues(RNMapErrorCode)) +); + +export const RNMapStylerValidator = object({ + color: optional(string()), + visibility: optional(string()), + weight: optional(number()), + gamma: optional(number()), + lightness: optional(number()), + saturation: optional(number()), + invert_lightness: optional(boolean()), +}); + +export const RNMapStyleElementValidator = object({ + featureType: optional(string()), + elementType: optional(string()), + stylers: array(RNMapStylerValidator), +}); + +export const RNBasicMapConfigValidator = object({ + initialProps: optional( + object({ + mapId: optional(string()), + liteMode: optional(boolean()), + camera: optional(RNCameraValidator), + }) + ), + uiSettings: optional(RNMapUiSettingsValidator), + myLocationEnabled: optional(boolean()), + buildingEnabled: optional(boolean()), + trafficEnabled: optional(boolean()), + indoorEnabled: optional(boolean()), + customMapStyle: optional(string()), + userInterfaceStyle: optional(RNUserInterfaceStyleValidator), + mapZoomConfig: optional(RNMapZoomConfigValidator), + mapPadding: optional(RNMapPaddingValidator), + mapType: optional(RNMapTypeValidator), + locationConfig: optional(RNLocationConfigValidator), +}); + +const schema: any = (RNBasicMapConfigValidator as any).schema; + +if (schema.mapType?.type === 'union' && !schema.mapType._schema) { + schema.mapType._schema = [ + { type: 'literal', schema: 'none' }, + { type: 'literal', schema: 'normal' }, + { type: 'literal', schema: 'hybrid' }, + { type: 'literal', schema: 'satellite' }, + { type: 'literal', schema: 'terrain' }, + ]; +} + +if ( + schema.userInterfaceStyle?.type === 'union' && + !schema.userInterfaceStyle._schema +) { + schema.userInterfaceStyle._schema = [ + { type: 'literal', schema: 'light' }, + { type: 'literal', schema: 'dark' }, + { type: 'literal', schema: 'default' }, + ]; +} + +export type RNBasicMapConfigType = + typeof RNBasicMapConfigValidator extends Struct ? O : never; diff --git a/example/src/hooks/useHeaderButton.tsx b/example/src/hooks/useHeaderButton.tsx new file mode 100644 index 0000000..cd39644 --- /dev/null +++ b/example/src/hooks/useHeaderButton.tsx @@ -0,0 +1,18 @@ +import { useCallback, useLayoutEffect } from 'react'; +import HeaderButton from '../components/HeaderButton'; +import React from 'react'; + +export function useHeaderButton( + navigation: any, + title: string, + onPress: () => void +) { + const renderHeaderButton = useCallback( + () => , + [title, onPress] + ); + + useLayoutEffect(() => { + navigation.setOptions({ headerRight: renderHeaderButton }); + }, [navigation, renderHeaderButton]); +} diff --git a/example/src/screens/BasicMapScreen.tsx b/example/src/screens/BasicMapScreen.tsx index 7a8048b..d6cf5d8 100644 --- a/example/src/screens/BasicMapScreen.tsx +++ b/example/src/screens/BasicMapScreen.tsx @@ -1,14 +1,91 @@ -import React, { useRef } from 'react'; +import React, { useRef, useState } from 'react'; import MapWrapper from '../components/MapWrapper'; -import type { GoogleMapsViewRef } from 'react-native-google-maps-plus'; +import { + type GoogleMapsViewRef, + RNAndroidLocationPriority, + RNIOSLocationAccuracy, +} from 'react-native-google-maps-plus'; import ControlPanel from '../components/ControlPanel'; +import MapConfigDialog from '../components/maptConfigDialog/MapConfigDialog'; +import type { RNBasicMapConfig } from '../types/basicMapConfig'; +import { useNavigation } from '@react-navigation/native'; +import { RNBasicMapConfigValidator } from '../components/maptConfigDialog/validator'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useHeaderButton } from '../hooks/useHeaderButton'; export default function BasicMapScreen() { const mapRef = useRef(null); + const layout = useSafeAreaInsets(); + const navigation = useNavigation(); + const [init, setInit] = useState(false); + const [mapConfig, setMapConfig] = useState({ + initialProps: { + mapId: undefined, + liteMode: false, + camera: { + center: { latitude: 37.7749, longitude: -122.4194 }, + zoom: 12, + }, + }, + uiSettings: { + allGesturesEnabled: true, + compassEnabled: true, + indoorLevelPickerEnabled: true, + mapToolbarEnabled: true, + myLocationButtonEnabled: true, + rotateEnabled: true, + scrollEnabled: true, + scrollDuringRotateOrZoomEnabled: true, + tiltEnabled: true, + zoomControlsEnabled: true, + zoomGesturesEnabled: true, + }, + myLocationEnabled: false, + buildingEnabled: undefined, + trafficEnabled: undefined, + indoorEnabled: undefined, + customMapStyle: '', + userInterfaceStyle: 'default', + mapZoomConfig: { min: 0, max: 20 }, + mapPadding: { top: 20, left: 20, bottom: layout.bottom, right: 20 }, + mapType: 'normal', + locationConfig: { + android: { + priority: RNAndroidLocationPriority.PRIORITY_BALANCED_POWER_ACCURACY, + interval: 5000, + minUpdateInterval: 5000, + }, + ios: { + desiredAccuracy: RNIOSLocationAccuracy.ACCURACY_BEST, + distanceFilterMeters: 10, + }, + }, + }); + + const [dialogVisible, setDialogVisible] = useState(true); + + useHeaderButton(navigation, mapConfig ? 'Edit' : 'Add', () => + setDialogVisible(true) + ); return ( - - - + <> + {init && ( + + + + )} + + visible={dialogVisible} + title="Edit Map Settings" + initialData={mapConfig} + validator={RNBasicMapConfigValidator} + onClose={() => { + setInit(true); + setDialogVisible(false); + }} + onSave={(c) => setMapConfig(c)} + /> + ); } diff --git a/example/src/screens/BlankScreen.tsx b/example/src/screens/BlankScreen.tsx index 5eeff57..8bf6cd2 100644 --- a/example/src/screens/BlankScreen.tsx +++ b/example/src/screens/BlankScreen.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { useNavigation, useTheme } from '@react-navigation/native'; import type { RootNavigationProp } from '../types/navigation'; diff --git a/example/src/screens/CameraTestScreen.tsx b/example/src/screens/CameraTestScreen.tsx index 3610685..91e2a22 100644 --- a/example/src/screens/CameraTestScreen.tsx +++ b/example/src/screens/CameraTestScreen.tsx @@ -3,9 +3,9 @@ import MapWrapper from '../components/MapWrapper'; import ControlPanel from '../components/ControlPanel'; import type { GoogleMapsViewRef, - RNLatLngBounds, RNCamera, RNLatLng, + RNLatLngBounds, } from 'react-native-google-maps-plus'; export default function CameraTestScreen() { diff --git a/example/src/screens/CirclesScreen.tsx b/example/src/screens/CirclesScreen.tsx index a215fa6..3379c0a 100644 --- a/example/src/screens/CirclesScreen.tsx +++ b/example/src/screens/CirclesScreen.tsx @@ -1,10 +1,36 @@ -import React, { useRef } from 'react'; +import React, { useRef, useState } from 'react'; import MapWrapper from '../components/MapWrapper'; import { makeCircle } from '../utils/mapGenerators'; -import type { GoogleMapsViewRef } from 'react-native-google-maps-plus'; +import type { + GoogleMapsViewRef, + RNCircle, +} from 'react-native-google-maps-plus'; +import MapConfigDialog from '../components/maptConfigDialog/MapConfigDialog'; +import { useNavigation } from '@react-navigation/native'; +import { RNCircleValidator } from '../components/maptConfigDialog/validator'; +import { useHeaderButton } from '../hooks/useHeaderButton'; export default function CirclesScreen() { const mapRef = useRef(null); - const circles = [makeCircle(1)]; - return ; + const navigation = useNavigation(); + const [circle, setCircle] = useState(undefined); + const [dialogVisible, setDialogVisible] = useState(true); + + useHeaderButton(navigation, circle ? 'Edit' : 'Add', () => + setDialogVisible(true) + ); + + return ( + <> + + + visible={dialogVisible} + title="Edit circle" + initialData={makeCircle(1)} + validator={RNCircleValidator} + onClose={() => setDialogVisible(false)} + onSave={(c) => setCircle(c)} + /> + + ); } diff --git a/example/src/screens/CustomStyleScreen.tsx b/example/src/screens/CustomStyleScreen.tsx index 0141db6..f61a45d 100644 --- a/example/src/screens/CustomStyleScreen.tsx +++ b/example/src/screens/CustomStyleScreen.tsx @@ -1,7 +1,7 @@ import React, { useMemo, useRef, useState } from 'react'; import MapWrapper from '../components/MapWrapper'; import ControlPanel from '../components/ControlPanel'; -import { standardMapStyle, silverMapStyle } from '../utils/mapStyles'; +import { silverMapStyle, standardMapStyle } from '../utils/mapStyles'; import type { GoogleMapsViewRef } from 'react-native-google-maps-plus'; export default function CustomStyleScreen() { diff --git a/example/src/screens/HeatmapScreen.tsx b/example/src/screens/HeatmapScreen.tsx index 7f9fa7e..ba6b35a 100644 --- a/example/src/screens/HeatmapScreen.tsx +++ b/example/src/screens/HeatmapScreen.tsx @@ -1,10 +1,36 @@ -import React, { useRef } from 'react'; +import React, { useRef, useState } from 'react'; import MapWrapper from '../components/MapWrapper'; import { makeHeatmap } from '../utils/mapGenerators'; -import type { GoogleMapsViewRef } from 'react-native-google-maps-plus'; +import type { + GoogleMapsViewRef, + RNHeatmap, +} from 'react-native-google-maps-plus'; +import MapConfigDialog from '../components/maptConfigDialog/MapConfigDialog'; +import { useNavigation } from '@react-navigation/native'; +import { RNHeatmapValidator } from '../components/maptConfigDialog/validator'; +import { useHeaderButton } from '../hooks/useHeaderButton'; export default function HeatmapScreen() { const mapRef = useRef(null); - const heatmaps = [makeHeatmap(1)]; - return ; + const navigation = useNavigation(); + const [heatmap, setHeatmap] = useState(undefined); + const [dialogVisible, setDialogVisible] = useState(true); + + useHeaderButton(navigation, heatmap ? 'Edit' : 'Add', () => + setDialogVisible(true) + ); + + return ( + <> + + + visible={dialogVisible} + title="Edit heatmap" + initialData={makeHeatmap(1)} + validator={RNHeatmapValidator} + onClose={() => setDialogVisible(false)} + onSave={(c) => setHeatmap(c)} + /> + + ); } diff --git a/example/src/screens/HomeScreen.tsx b/example/src/screens/HomeScreen.tsx index 5c4455a..08aa896 100644 --- a/example/src/screens/HomeScreen.tsx +++ b/example/src/screens/HomeScreen.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { Text, TouchableOpacity, StyleSheet, ScrollView } from 'react-native'; +import React, { useMemo } from 'react'; +import { ScrollView, StyleSheet, Text, TouchableOpacity } from 'react-native'; import type { StackNavigationProp } from '@react-navigation/stack'; import { useNavigation } from '@react-navigation/native'; import { useAppTheme } from '../theme'; @@ -23,7 +23,7 @@ const screens = [ export default function HomeScreen() { const navigation = useNavigation>(); const theme = useAppTheme(); - const styles = getThemedStyles(theme); + const styles = useMemo(() => getThemedStyles(theme), [theme]); return ( diff --git a/example/src/screens/KmlLayerScreen.tsx b/example/src/screens/KmlLayerScreen.tsx index 66602ed..f3cf25a 100644 --- a/example/src/screens/KmlLayerScreen.tsx +++ b/example/src/screens/KmlLayerScreen.tsx @@ -1,11 +1,36 @@ -import React, { useRef } from 'react'; +import React, { useRef, useState } from 'react'; import MapWrapper from '../components/MapWrapper'; import { kmlString } from '../utils/kmlData'; -import type { GoogleMapsViewRef } from 'react-native-google-maps-plus'; +import type { + GoogleMapsViewRef, + RNKMLayer, +} from 'react-native-google-maps-plus'; +import MapConfigDialog from '../components/maptConfigDialog/MapConfigDialog'; +import { useNavigation } from '@react-navigation/native'; +import { RNKMLayerValidator } from '../components/maptConfigDialog/validator'; +import { useHeaderButton } from '../hooks/useHeaderButton'; export default function KmlLayerScreen() { const mapRef = useRef(null); + const navigation = useNavigation(); + const [kmlLayer, setKmlLayer] = useState(undefined); + const [dialogVisible, setDialogVisible] = useState(true); - const kmlLayers = [{ id: '21', zIndex: 1, kmlString }]; - return ; + useHeaderButton(navigation, kmlLayer ? 'Edit' : 'Add', () => + setDialogVisible(true) + ); + + return ( + <> + + + visible={dialogVisible} + title="Edit KML layer" + initialData={{ id: '1', kmlString: kmlString }} + validator={RNKMLayerValidator} + onClose={() => setDialogVisible(false)} + onSave={(c) => setKmlLayer(c)} + /> + + ); } diff --git a/example/src/screens/MarkersScreen.tsx b/example/src/screens/MarkersScreen.tsx index 6e5c303..880c7df 100644 --- a/example/src/screens/MarkersScreen.tsx +++ b/example/src/screens/MarkersScreen.tsx @@ -1,47 +1,36 @@ -import React, { useMemo, useRef, useState } from 'react'; +import React, { useRef, useState } from 'react'; import MapWrapper from '../components/MapWrapper'; -import ControlPanel from '../components/ControlPanel'; import { makeMarker } from '../utils/mapGenerators'; import type { GoogleMapsViewRef, RNMarker, } from 'react-native-google-maps-plus'; +import MapConfigDialog from '../components/maptConfigDialog/MapConfigDialog'; +import { useNavigation } from '@react-navigation/native'; +import { RNMarkerValidator } from '../components/maptConfigDialog/validator'; +import { useHeaderButton } from '../hooks/useHeaderButton'; export default function MarkersScreen() { const mapRef = useRef(null); - const [markers, setMarkers] = useState( - Array.from({ length: 2 }, (_, i) => makeMarker(i + 1)) - ); + const navigation = useNavigation(); + const [marker, setMarker] = useState(undefined); + const [dialogVisible, setDialogVisible] = useState(true); - const buttons = useMemo( - () => [ - { - title: 'Marker +1', - onPress: () => setMarkers((m) => [...m, makeMarker(m.length + 1)]), - }, - { - title: 'Marker -1', - onPress: () => setMarkers((m) => m.slice(0, Math.max(0, m.length - 1))), - }, - { - title: 'Fit to markers', - onPress: () => { - const coords = markers.map((m) => m.coordinate); - mapRef.current?.setCameraToCoordinates( - coords, - { top: 50, left: 50, bottom: 50, right: 50 }, - true, - 300 - ); - }, - }, - ], - [markers] + useHeaderButton(navigation, marker ? 'Edit' : 'Add', () => + setDialogVisible(true) ); return ( - - - + <> + + + visible={dialogVisible} + title="Edit marker" + initialData={makeMarker(1)} + validator={RNMarkerValidator} + onClose={() => setDialogVisible(false)} + onSave={(c) => setMarker(c)} + /> + ); } diff --git a/example/src/screens/PolygonsScreen.tsx b/example/src/screens/PolygonsScreen.tsx index 8f2df34..dfd2ec9 100644 --- a/example/src/screens/PolygonsScreen.tsx +++ b/example/src/screens/PolygonsScreen.tsx @@ -1,11 +1,36 @@ -import React, { useRef } from 'react'; +import React, { useRef, useState } from 'react'; import MapWrapper from '../components/MapWrapper'; import { makePolygon } from '../utils/mapGenerators'; -import type { GoogleMapsViewRef } from 'react-native-google-maps-plus'; +import type { + GoogleMapsViewRef, + RNPolygon, +} from 'react-native-google-maps-plus'; +import MapConfigDialog from '../components/maptConfigDialog/MapConfigDialog'; +import { useNavigation } from '@react-navigation/native'; +import { RNPolygonValidator } from '../components/maptConfigDialog/validator'; +import { useHeaderButton } from '../hooks/useHeaderButton'; export default function PolygonsScreen() { const mapRef = useRef(null); + const navigation = useNavigation(); + const [polygon, setPolygon] = useState(undefined); + const [dialogVisible, setDialogVisible] = useState(true); - const polygons = [makePolygon(1)]; - return ; + useHeaderButton(navigation, polygon ? 'Edit' : 'Add', () => + setDialogVisible(true) + ); + + return ( + <> + + + visible={dialogVisible} + title="Edit polygon" + initialData={makePolygon(1)} + validator={RNPolygonValidator} + onClose={() => setDialogVisible(false)} + onSave={(c) => setPolygon(c)} + /> + + ); } diff --git a/example/src/screens/PolylinesScreen.tsx b/example/src/screens/PolylinesScreen.tsx index fa3aaff..e697fe5 100644 --- a/example/src/screens/PolylinesScreen.tsx +++ b/example/src/screens/PolylinesScreen.tsx @@ -1,10 +1,36 @@ -import React, { useRef } from 'react'; +import React, { useRef, useState } from 'react'; import MapWrapper from '../components/MapWrapper'; import { makePolyline } from '../utils/mapGenerators'; -import type { GoogleMapsViewRef } from 'react-native-google-maps-plus'; +import type { + GoogleMapsViewRef, + RNPolyline, +} from 'react-native-google-maps-plus'; +import MapConfigDialog from '../components/maptConfigDialog/MapConfigDialog'; +import { useNavigation } from '@react-navigation/native'; +import { RNPolylineValidator } from '../components/maptConfigDialog/validator'; +import { useHeaderButton } from '../hooks/useHeaderButton'; export default function PolylinesScreen() { const mapRef = useRef(null); - const polylines = [makePolyline(1)]; - return ; + const navigation = useNavigation(); + const [polyline, setPolyline] = useState(undefined); + const [dialogVisible, setDialogVisible] = useState(true); + + useHeaderButton(navigation, polyline ? 'Edit' : 'Add', () => + setDialogVisible(true) + ); + + return ( + <> + + + visible={dialogVisible} + title="Edit polyline" + initialData={makePolyline(1)} + validator={RNPolylineValidator} + onClose={() => setDialogVisible(false)} + onSave={(c) => setPolyline(c)} + /> + + ); } diff --git a/example/src/screens/SnaptshotTestScreen.tsx b/example/src/screens/SnaptshotTestScreen.tsx index 142baf6..73c88ae 100644 --- a/example/src/screens/SnaptshotTestScreen.tsx +++ b/example/src/screens/SnaptshotTestScreen.tsx @@ -7,11 +7,10 @@ import type { GoogleMapsViewRef } from 'react-native-google-maps-plus'; export default function SnapshotTestScreen() { const mapRef = useRef(null); + const theme = useAppTheme(); const [snapshotUri, setSnapshotUri] = useState(null); const [visible, setVisible] = useState(false); - const theme = useAppTheme(); - const buttons = useMemo( () => [ { @@ -57,7 +56,7 @@ export default function SnapshotTestScreen() { [] ); - const styles = getThemedStyles(theme); + const styles = useMemo(() => getThemedStyles(theme), [theme]); return ( diff --git a/example/src/screens/StressTestScreen.tsx b/example/src/screens/StressTestScreen.tsx index 27ea360..12a1fc2 100644 --- a/example/src/screens/StressTestScreen.tsx +++ b/example/src/screens/StressTestScreen.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import MapWrapper from '../components/MapWrapper'; import ControlPanel from '../components/ControlPanel'; -import { makeMarker } from '../utils/mapGenerators'; +import { makeRandomMarkerForStressTest } from '../utils/mapGenerators'; import type { GoogleMapsViewRef, RNMarker, @@ -18,7 +18,8 @@ export default function StressTestScreen() { setMarkers((m) => { const next = [...m]; while (next.length > 100) next.shift(); - for (let i = 0; i < 500; i++) next.push(makeMarker(next.length + 1)); + for (let i = 0; i < 500; i++) + next.push(makeRandomMarkerForStressTest(next.length + 1)); return next; }); }, 100); @@ -46,7 +47,11 @@ export default function StressTestScreen() { }, { title: 'Marker +1', - onPress: () => setMarkers((m) => [...m, makeMarker(m.length + 1)]), + onPress: () => + setMarkers((m) => [ + ...m, + makeRandomMarkerForStressTest(m.length + 1), + ]), }, { title: 'Marker -1', diff --git a/example/src/theme.ts b/example/src/theme.ts index c881ee3..7b2cf42 100644 --- a/example/src/theme.ts +++ b/example/src/theme.ts @@ -7,6 +7,13 @@ export const lightTheme = { textPrimary: '#111827', textOnAccent: '#FFFFFF', shadow: '#000000', + overlay: 'rgba(0,0,0,0.5)', + label: '#222', + placeholder: '#9CA3AF', + border: '#D1D5DB', + inputBg: '#FFFFFF', + buttonBg: '#3B82F6', + cancelBg: '#9CA3AF', }; export const darkTheme = { @@ -16,6 +23,13 @@ export const darkTheme = { textPrimary: '#FFFFFF', textOnAccent: '#FFFFFF', shadow: '#000000', + overlay: 'rgba(0,0,0,0.6)', + label: '#D1D5DB', + placeholder: '#6B7280', + border: '#3F3F46', + inputBg: '#2C2C2E', + buttonBg: '#2D6BE9', + cancelBg: '#4B5563', }; export type AppTheme = typeof lightTheme; diff --git a/example/src/types/basicMapConfig.ts b/example/src/types/basicMapConfig.ts new file mode 100644 index 0000000..065f5b2 --- /dev/null +++ b/example/src/types/basicMapConfig.ts @@ -0,0 +1,28 @@ +import type { + RNCamera, + RNLocationConfig, + RNMapPadding, + RNMapType, + RNMapUiSettings, + RNMapZoomConfig, + RNUserInterfaceStyle, +} from 'react-native-google-maps-plus'; + +export type RNBasicMapConfig = { + initialProps?: { + mapId?: string; + liteMode?: boolean; + camera?: RNCamera; + }; + uiSettings?: RNMapUiSettings; + myLocationEnabled?: boolean; + buildingEnabled?: boolean; + trafficEnabled?: boolean; + indoorEnabled?: boolean; + customMapStyle?: string; + userInterfaceStyle?: RNUserInterfaceStyle; + mapZoomConfig?: RNMapZoomConfig; + mapPadding?: RNMapPadding; + mapType?: RNMapType; + locationConfig?: RNLocationConfig; +}; diff --git a/example/src/types/navigation.ts b/example/src/types/navigation.ts index 0cdaa46..bf29d2f 100644 --- a/example/src/types/navigation.ts +++ b/example/src/types/navigation.ts @@ -13,7 +13,7 @@ export type RootStackParamList = { IndoorLevelMap: undefined; Camera: undefined; Snapshot: undefined; - StressTest: undefined; + Stress: undefined; }; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; diff --git a/example/src/utils/kmlData.ts b/example/src/utils/kmlData.ts index b9ea623..b8ce087 100644 --- a/example/src/utils/kmlData.ts +++ b/example/src/utils/kmlData.ts @@ -1,53 +1 @@ -export const kmlString = ` - - - Example KML Data - Example with marker, polygon and circle near San Francisco center - - Center Point - -122.4194,37.7749,0 - - - Example Polygon - - - - - - -122.4244,37.7784,0 - -122.4144,37.7784,0 - -122.4144,37.7714,0 - -122.4244,37.7714,0 - -122.4244,37.7784,0 - - - - - - - Approximate Circle - - - - - - -122.4194,37.7770,0 - -122.4174,37.7770,0 - -122.4174,37.7730,0 - -122.4194,37.7730,0 - -122.4214,37.7730,0 - -122.4214,37.7770,0 - -122.4194,37.7770,0 - - - - - - -;`; +export const kmlString = `Example KML DataExample with marker, polygon and circle near San Francisco centerCenter Point-122.4194,37.7749,0Example Polygon-122.4244,37.7784,0 -122.4144,37.7784,0 -122.4144,37.7714,0 -122.4244,37.7714,0 -122.4244,37.7784,0Approximate Circle-122.4194,37.7770,0 -122.4174,37.7770,0 -122.4174,37.7730,0 -122.4194,37.7730,0 -122.4214,37.7730,0 -122.4214,37.7770,0 -122.4194,37.7770,0`; diff --git a/example/src/utils/mapGenerators.ts b/example/src/utils/mapGenerators.ts index af0720d..060d17f 100644 --- a/example/src/utils/mapGenerators.ts +++ b/example/src/utils/mapGenerators.ts @@ -1,9 +1,9 @@ import type { + RNCircle, + RNHeatmap, RNMarker, RNPolygon, RNPolyline, - RNCircle, - RNHeatmap, } from 'react-native-google-maps-plus'; export function randomColor() { @@ -15,8 +15,12 @@ export function randomColor() { ); } -export function makeSvgIcon(width: number, height: number): string { - const color = randomColor(); +export function makeSvgIcon( + width: number, + height: number, + color?: string +): string { + color = color ?? randomColor(); return ` @@ -34,25 +38,25 @@ export const randomCoordinates = ( longitude: baseLng + (Math.random() - 0.5) * offset, }); -export const randomWeightedCoordinates = ( - baseLat: number, - baseLng: number, - offset = 0.01 -) => ({ - latitude: baseLat + (Math.random() - 0.5) * offset, - longitude: baseLng + (Math.random() - 0.5) * offset, - weight: Math.floor(Math.random() * (100 - 10 + 1)) + 10, -}); - export const makePolygon = (id: number): RNPolygon => ({ id: id.toString(), zIndex: id, pressable: true, coordinates: [ - randomCoordinates(37.7749, -122.4194, 0.01), - randomCoordinates(37.7749, -122.4194, 0.01), - randomCoordinates(37.7749, -122.4194, 0.01), - randomCoordinates(37.7749, -122.4194, 0.01), + { latitude: 37.7749, longitude: -122.4194 }, + { latitude: 37.7799, longitude: -122.4194 }, + { latitude: 37.7799, longitude: -122.4144 }, + { latitude: 37.7749, longitude: -122.4144 }, + ], + holes: [ + { + coordinates: [ + { latitude: 37.776, longitude: -122.418 }, + { latitude: 37.778, longitude: -122.418 }, + { latitude: 37.778, longitude: -122.416 }, + { latitude: 37.776, longitude: -122.416 }, + ], + }, ], fillColor: '#0000ff', strokeColor: '#ff0000', @@ -64,23 +68,55 @@ export const makePolyline = (id: number): RNPolyline => ({ zIndex: id, pressable: true, coordinates: [ - randomCoordinates(37.7749, -122.4194, 0.02), - randomCoordinates(37.7749, -122.4194, 0.02), - randomCoordinates(37.7749, -122.4194, 0.02), + { + latitude: 37.768827809530706, + longitude: -122.4094318055856, + }, + { + latitude: 37.769061988963294, + longitude: -122.42813903044735, + }, + { + latitude: 37.78432665625552, + longitude: -122.4146025550078, + }, + { + latitude: 37.77625509715684, + longitude: -122.42576943109252, + }, + { + latitude: 37.781078997316904, + longitude: -122.41360075209455, + }, + { + latitude: 37.78114738526304, + longitude: -122.41118118480473, + }, + { + latitude: 37.76597525181739, + longitude: -122.42891273762548, + }, + { + latitude: 37.77486270614536, + longitude: -122.42667530588818, + }, ], - lineCap: id % 2 === 0 ? 'round' : 'square', - lineJoin: id % 3 === 0 ? 'bevel' : 'round', - color: id % 2 === 0 ? '#00ff00' : '#ff0000', - width: 2 + (id % 4), + lineCap: 'square', + lineJoin: 'round', + color: '#ff0000', + width: 3, }); export const makeCircle = (id: number): RNCircle => ({ id: id.toString(), zIndex: id, pressable: true, - center: randomCoordinates(37.7749, -122.4194, 0.02), - radius: 100 + (id % 5), - strokeWidth: 1 + (id % 5), + center: { + latitude: 37.78280299333499, + longitude: -122.41439638994537, + }, + radius: 250, + strokeWidth: 2, strokeColor: '#ff0000', fillColor: '#0000ff', }); @@ -89,39 +125,95 @@ export const makeHeatmap = (id: number): RNHeatmap => ({ id: id.toString(), zIndex: id, weightedData: [ - randomWeightedCoordinates(37.7749, -122.4194, 0.02), - randomWeightedCoordinates(37.7749, -122.4194, 0.03), - randomWeightedCoordinates(37.7749, -122.4194, 0.05), - randomWeightedCoordinates(37.7749, -122.4194, 0.01), - randomWeightedCoordinates(37.7749, -122.4194, 0.08), - randomWeightedCoordinates(37.7749, -122.4194, 0.03), - randomWeightedCoordinates(37.7749, -122.4194, 0.09), + { + latitude: 37.777714074525925, + longitude: -122.42099587858186, + weight: 1, + }, + { + latitude: 37.785184052875735, + longitude: -122.42914114591328, + weight: 1, + }, + { + latitude: 37.769334961755526, + longitude: -122.41418426583697, + weight: 5, + }, + { + latitude: 37.7717263096532, + longitude: -122.41931954914673, + weight: 4, + }, + { + latitude: 37.78589459403588, + longitude: -122.40573314204349, + weight: 3, + }, + { + latitude: 37.78664297332888, + longitude: -122.42602082474453, + weight: 2, + }, + { + latitude: 37.74874321698208, + longitude: -122.44390470794693, + weight: 1, + }, ], gradient: { colors: ['#00f', '#0ff', '#0f0', '#ff0', '#f00'], - startPoints: [0.1, 0.3, 0.5, 0.7, 1], - colorMapSize: 256, + startPoints: [0.0, 0.25, 0.5, 0.75, 1.0], + colorMapSize: 1024, }, radius: 100, opacity: 1, }); export function makeMarker(id: number): RNMarker { + return { + id: id.toString(), + zIndex: id, + coordinate: { + latitude: 37.759135945148444, + longitude: -122.43568673729897, + }, + anchor: { + x: 0.5, + y: 1, + }, + title: 'Marker title id: 1', + snippet: 'Marker snippet id: 1', + draggable: true, + infoWindowAnchor: { + x: 0.5, + y: 0, + }, + iconSvg: { + width: 32, + height: 44, + svgString: makeSvgIcon(32, 44, '#2D6BE9'), + }, + }; +} + +export function makeRandomMarkerForStressTest(id: number): RNMarker { const customIcon = id % 2 === 0; + return { id: id.toString(), zIndex: id, coordinate: randomCoordinates(37.7749, -122.4194, 0.2), - anchor: customIcon ? { x: 0.5, y: 1.0 } : undefined, + anchor: { x: 0.5, y: 1.0 }, + draggable: false, + opacity: Math.random(), + flat: customIcon, + rotation: customIcon ? Math.random() * 180 : 0, title: `Marker title id: ${id}`, - snippet: `Marker snippet id: ${id}`, - draggable: customIcon, - iconSvg: customIcon - ? { - width: (64 / 100) * 50, - height: (88 / 100) * 50, - svgString: makeSvgIcon(64, 88), - } - : undefined, + iconSvg: { + width: (64 / 100) * 50, + height: (88 / 100) * 50, + svgString: makeSvgIcon(64, 88), + }, }; } diff --git a/ios/GoogleMapViewImpl.swift b/ios/GoogleMapViewImpl.swift index 4fcedc2..5da6af7 100644 --- a/ios/GoogleMapViewImpl.swift +++ b/ios/GoogleMapViewImpl.swift @@ -62,17 +62,12 @@ GMSIndoorDisplayDelegate { } @MainActor - func initMapView(mapId: String?, liteMode: Bool?, camera: GMSCameraPosition?) { + func initMapView(googleMapOptions: GMSMapViewOptions) { if initialized { return } initialized = true - let options = GMSMapViewOptions() - options.frame = bounds + googleMapOptions.frame = bounds - mapId.map { options.mapID = GMSMapID(identifier: $0) } - liteMode.map { _ in /* not supported */ } - camera.map { options.camera = $0 } - - mapView = GMSMapView.init(options: options) + mapView = GMSMapView.init(options: googleMapOptions) mapView?.delegate = self mapView?.autoresizingMask = [.flexibleWidth, .flexibleHeight] mapView?.paddingAdjustmentBehavior = .never @@ -93,10 +88,7 @@ GMSIndoorDisplayDelegate { != loc.coordinate.longitude { self.onLocationUpdate?( RNLocation( - RNLatLng( - latitude: loc.coordinate.latitude, - longitude: loc.coordinate.longitude - ), + loc.coordinate.toRNLatLng(), loc.course ) ) @@ -348,27 +340,16 @@ GMSIndoorDisplayDelegate { animated: Bool, durationMs: Double ) { - if coordinates.isEmpty { + guard let firstCoordinates = coordinates.first else { return } var bounds = GMSCoordinateBounds( - coordinate: CLLocationCoordinate2D( - latitude: coordinates[0].latitude, - longitude: coordinates[0].longitude - ), - coordinate: CLLocationCoordinate2D( - latitude: coordinates[0].latitude, - longitude: coordinates[0].longitude - ) + coordinate: firstCoordinates.toCLLocationCoordinate2D(), + coordinate: firstCoordinates.toCLLocationCoordinate2D() ) for coord in coordinates.dropFirst() { - bounds = bounds.includingCoordinate( - CLLocationCoordinate2D( - latitude: coord.latitude, - longitude: coord.longitude - ) - ) + bounds = bounds.includingCoordinate(coord.toCLLocationCoordinate2D()) } let insets = UIEdgeInsets( @@ -736,15 +717,12 @@ GMSIndoorDisplayDelegate { let cp = mapView.camera let region = RNRegion( - center: RNLatLng(center.latitude, center.longitude), + center: center.toRNLatLng(), latitudeDelta: latDelta, longitudeDelta: lngDelta ) let cam = RNCamera( - center: RNLatLng( - latitude: cp.target.latitude, - longitude: cp.target.longitude - ), + center: cp.target.toRNLatLng(), zoom: Double(cp.zoom), bearing: cp.bearing, tilt: cp.viewingAngle @@ -781,15 +759,12 @@ GMSIndoorDisplayDelegate { let cp = mapView.camera let region = RNRegion( - center: RNLatLng(center.latitude, center.longitude), + center: center.toRNLatLng(), latitudeDelta: latDelta, longitudeDelta: lngDelta ) let cam = RNCamera( - center: RNLatLng( - latitude: cp.target.latitude, - longitude: cp.target.longitude - ), + center: cp.target.toRNLatLng(), zoom: Double(cp.zoom), bearing: cp.bearing, tilt: cp.viewingAngle @@ -814,15 +789,12 @@ GMSIndoorDisplayDelegate { let cp = mapView.camera let region = RNRegion( - center: RNLatLng(center.latitude, center.longitude), + center: center.toRNLatLng(), latitudeDelta: latDelta, longitudeDelta: lngDelta ) let cam = RNCamera( - center: RNLatLng( - latitude: cp.target.latitude, - longitude: cp.target.longitude - ), + center: cp.target.toRNLatLng(), zoom: Double(cp.zoom), bearing: cp.bearing, tilt: cp.viewingAngle @@ -837,10 +809,7 @@ GMSIndoorDisplayDelegate { ) { onMain { self.onMapPress?( - RNLatLng( - latitude: coordinate.latitude, - longitude: coordinate.longitude - ) + coordinate.toRNLatLng(), ) } } @@ -875,7 +844,7 @@ GMSIndoorDisplayDelegate { onMain { self.onMarkerDragStart?( marker.userData as? String, - RNLatLng(marker.position.latitude, marker.position.longitude) + marker.position.toRNLatLng() ) } } @@ -884,7 +853,7 @@ GMSIndoorDisplayDelegate { onMain { self.onMarkerDrag?( marker.userData as? String, - RNLatLng(marker.position.latitude, marker.position.longitude) + marker.position.toRNLatLng() ) } } @@ -893,7 +862,7 @@ GMSIndoorDisplayDelegate { onMain { self.onMarkerDragEnd?( marker.userData as? String, - RNLatLng(marker.position.latitude, marker.position.longitude) + marker.position.toRNLatLng() ) } } diff --git a/ios/MapCircleBuilder.swift b/ios/MapCircleBuilder.swift index 729c7f6..50377bd 100644 --- a/ios/MapCircleBuilder.swift +++ b/ios/MapCircleBuilder.swift @@ -3,11 +3,7 @@ import GoogleMaps final class MapCircleBuilder { func build(_ c: RNCircle) -> GMSCircle { let circle = GMSCircle() - circle.position = CLLocationCoordinate2D( - latitude: c.center.latitude, - longitude: c.center.longitude - ) - + circle.position = c.center.toCLLocationCoordinate2D() circle.radius = c.radius c.fillColor.map { circle.fillColor = $0.toUIColor() } c.strokeColor.map { circle.strokeColor = $0.toUIColor() } @@ -19,11 +15,7 @@ final class MapCircleBuilder { } func update(_ next: RNCircle, _ c: GMSCircle) { - c.position = CLLocationCoordinate2D( - latitude: next.center.latitude, - longitude: next.center.longitude - ) - + c.position = next.center.toCLLocationCoordinate2D() c.radius = next.radius c.fillColor = next.fillColor?.toUIColor() ?? nil c.strokeColor = next.strokeColor?.toUIColor() ?? .black diff --git a/ios/MapMarkerBuilder.swift b/ios/MapMarkerBuilder.swift index d0e5803..e5f7b27 100644 --- a/ios/MapMarkerBuilder.swift +++ b/ios/MapMarkerBuilder.swift @@ -13,10 +13,7 @@ final class MapMarkerBuilder { func build(_ m: RNMarker, icon: UIImage?) -> GMSMarker { let marker = GMSMarker( - position: CLLocationCoordinate2D( - latitude: m.coordinate.latitude, - longitude: m.coordinate.longitude - ) + position: m.coordinate.toCLLocationCoordinate2D() ) marker.userData = m.id marker.tracksViewChanges = true @@ -26,6 +23,10 @@ final class MapMarkerBuilder { m.opacity.map { marker.iconView?.alpha = CGFloat($0) } m.flat.map { marker.isFlat = $0 } m.draggable.map { marker.isDraggable = $0 } + m.rotation.map { marker.rotation = $0 } + m.infoWindowAnchor.map { + marker.infoWindowAnchor = CGPoint(x: $0.x, y: $0.y) + } m.anchor.map { marker.groundAnchor = CGPoint( x: $0.x, @@ -43,16 +44,17 @@ final class MapMarkerBuilder { @MainActor func update(_ prev: RNMarker, _ next: RNMarker, _ m: GMSMarker) { - m.position = CLLocationCoordinate2D( - latitude: next.coordinate.latitude, - longitude: next.coordinate.longitude - ) - + m.position = next.coordinate.toCLLocationCoordinate2D() m.title = next.title m.snippet = next.snippet - m.iconView?.alpha = CGFloat(next.opacity ?? 0) + m.iconView?.alpha = CGFloat(next.opacity ?? 1) m.isFlat = next.flat ?? false m.isDraggable = next.draggable ?? false + m.rotation = next.rotation ?? 0 + m.infoWindowAnchor = CGPoint( + x: next.infoWindowAnchor?.x ?? 0.5, + y: next.infoWindowAnchor?.y ?? 0 + ) m.zIndex = Int32(next.zIndex ?? 0) m.groundAnchor = CGPoint( x: next.anchor?.x ?? 0.5, diff --git a/ios/MapPolygonBuilder.swift b/ios/MapPolygonBuilder.swift index 2dcd68e..c8c840b 100644 --- a/ios/MapPolygonBuilder.swift +++ b/ios/MapPolygonBuilder.swift @@ -5,7 +5,7 @@ final class MapPolygonBuilder { let path = GMSMutablePath() p.coordinates.forEach { path.add( - CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) + $0.toCLLocationCoordinate2D() ) } @@ -15,6 +15,14 @@ final class MapPolygonBuilder { p.strokeColor.map { pg.strokeColor = $0.toUIColor() } p.strokeWidth.map { pg.strokeWidth = CGFloat($0) } p.pressable.map { pg.isTappable = $0 } + p.geodesic.map { pg.geodesic = $0 } + p.holes.map { + pg.holes = $0.map { hole in + let path = GMSMutablePath() + hole.coordinates.forEach { path.add($0.toCLLocationCoordinate2D()) } + return path + } + } p.zIndex.map { pg.zIndex = Int32($0) } return pg @@ -24,7 +32,7 @@ final class MapPolygonBuilder { let path = GMSMutablePath() next.coordinates.forEach { path.add( - CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) + $0.toCLLocationCoordinate2D() ) } pg.path = path @@ -33,6 +41,13 @@ final class MapPolygonBuilder { pg.strokeColor = next.strokeColor?.toUIColor() ?? .black pg.strokeWidth = CGFloat(next.strokeWidth ?? 1.0) pg.isTappable = next.pressable ?? false + pg.geodesic = next.geodesic ?? false + pg.holes = + next.holes?.map { hole in + let path = GMSMutablePath() + hole.coordinates.forEach { path.add($0.toCLLocationCoordinate2D()) } + return path + } ?? [] pg.zIndex = Int32(next.zIndex ?? 0) } } diff --git a/ios/MapPolylineBuilder.swift b/ios/MapPolylineBuilder.swift index 0fdf0a3..ba41dc2 100644 --- a/ios/MapPolylineBuilder.swift +++ b/ios/MapPolylineBuilder.swift @@ -5,7 +5,7 @@ final class MapPolylineBuilder { let path = GMSMutablePath() p.coordinates.forEach { path.add( - CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) + $0.toCLLocationCoordinate2D() ) } @@ -16,6 +16,7 @@ final class MapPolylineBuilder { p.lineCap.map { _ in /* not supported */ } p.lineJoin.map { _ in /* not supported */ } p.pressable.map { pl.isTappable = $0 } + p.geodesic.map { pl.geodesic = $0 } p.zIndex.map { pl.zIndex = Int32($0) } return pl @@ -25,7 +26,7 @@ final class MapPolylineBuilder { let path = GMSMutablePath() next.coordinates.forEach { path.add( - CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude) + $0.toCLLocationCoordinate2D() ) } pl.path = path @@ -35,6 +36,7 @@ final class MapPolylineBuilder { pl.strokeWidth = CGFloat(next.width ?? 1.0) pl.strokeColor = next.color?.toUIColor() ?? .black pl.isTappable = next.pressable ?? false + pl.geodesic = next.geodesic ?? false pl.zIndex = Int32(next.zIndex ?? 0) } } diff --git a/ios/RNGoogleMapsPlusView.swift b/ios/RNGoogleMapsPlusView.swift index 13b7e87..0fd68ee 100644 --- a/ios/RNGoogleMapsPlusView.swift +++ b/ios/RNGoogleMapsPlusView.swift @@ -34,11 +34,13 @@ final class RNGoogleMapsPlusView: HybridRNGoogleMapsPlusViewSpec { if !propsInitialized { propsInitialized = true Task { @MainActor in - impl.initMapView( - mapId: self.initialProps?.mapId, - liteMode: self.initialProps?.liteMode, - camera: self.initialProps?.camera?.toGMSCameraPosition(current: nil) - ) + let options = GMSMapViewOptions() + initialProps?.mapId.map { options.mapID = GMSMapID(identifier: $0) } + initialProps?.liteMode.map { _ in /* not supported */ } + initialProps?.camera.map { + options.camera = $0.toGMSCameraPosition(current: nil) + } + impl.initMapView(googleMapOptions: options) } } } diff --git a/ios/extensions/CLLocationCoordinate2D+Extension.swift b/ios/extensions/CLLocationCoordinate2D+Extension.swift new file mode 100644 index 0000000..a8281e7 --- /dev/null +++ b/ios/extensions/CLLocationCoordinate2D+Extension.swift @@ -0,0 +1,7 @@ +import CoreLocation + +extension CLLocationCoordinate2D { + func toRNLatLng() -> RNLatLng { + RNLatLng(latitude: latitude, longitude: longitude) + } +} diff --git a/ios/extensions/RNLatLng+Extension.swift b/ios/extensions/RNLatLng+Extension.swift new file mode 100644 index 0000000..36f7e66 --- /dev/null +++ b/ios/extensions/RNLatLng+Extension.swift @@ -0,0 +1,7 @@ +import CoreLocation + +extension RNLatLng { + func toCLLocationCoordinate2D() -> CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + } +} diff --git a/ios/extensions/RNMarker+Extension.swift b/ios/extensions/RNMarker+Extension.swift index e3636e1..b6bbd07 100644 --- a/ios/extensions/RNMarker+Extension.swift +++ b/ios/extensions/RNMarker+Extension.swift @@ -6,6 +6,11 @@ extension RNMarker { && coordinate.latitude == b.coordinate.latitude && coordinate.longitude == b.coordinate.longitude && anchor?.x == b.anchor?.x && anchor?.y == b.anchor?.y + && showInfoWindow == b.showInfoWindow && title == b.title + && snippet == b.snippet && opacity == b.opacity && flat == b.flat + && draggable == b.draggable && rotation == b.rotation + && infoWindowAnchor?.x == b.infoWindowAnchor?.x + && infoWindowAnchor?.y == b.infoWindowAnchor?.y && markerStyleEquals(b) } diff --git a/ios/extensions/RNPolygon+Extension.swift.swift b/ios/extensions/RNPolygon+Extension.swift.swift index 7aee161..0d743d9 100644 --- a/ios/extensions/RNPolygon+Extension.swift.swift +++ b/ios/extensions/RNPolygon+Extension.swift.swift @@ -7,7 +7,9 @@ extension RNPolygon { strokeWidth == b.strokeWidth, fillColor == b.fillColor, strokeColor == b.strokeColor, - coordinates.count == b.coordinates.count + geodesic == b.geodesic, + coordinates.count == b.coordinates.count, + holes?.count == b.holes?.count else { return false } for i in 0..