diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 2eecd11..311e216 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -105,17 +105,7 @@ jobs: with: xcode-version: ${{ env.XCODE_VERSION }} - - name: Cache Pods - id: pods-cache - uses: actions/cache@v4.2.4 - with: - path: example/ios/Pods - key: ${{ runner.os }}-pods-${{ hashFiles('example/ios/Podfile', 'example/ios/Podfile.lock', 'example/package.json') }} - restore-keys: | - ${{ runner.os }}-pods- - - name: Install cocoapods - if: steps.pods-cache.outputs.cache-hit != 'true' working-directory: example run: yarn ios:pods diff --git a/CHANGELOG.md b/CHANGELOG.md index b7bada3..4e6c8e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,24 +17,6 @@ * fix CHANGELOG.md ([2f2bb2c](https://github.com/pinpong/react-native-google-maps-plus/commit/2f2bb2c617260166551abbc07dfa9a8ae27cf31e)) * merge dev into main ([a510e2a](https://github.com/pinpong/react-native-google-maps-plus/commit/a510e2a1bebabde03e319256b2a1246f10ce1b95)) -## [1.2.0-dev.1](https://github.com/pinpong/react-native-google-maps-plus/compare/v1.1.0...v1.2.0-dev.1) (2025-10-09) - -### ✨ Features - -* add kml layer support ([4faf558](https://github.com/pinpong/react-native-google-maps-plus/commit/4faf558425831cc18a6e9c9e2d20ef0c4f42e702)) -* add kml layer support ([35098bd](https://github.com/pinpong/react-native-google-maps-plus/commit/35098bd4c75b825f96f58696cbb37a4fcdebbdb8)) - -### 🐛 Bug Fixes - -* **example:** build issues ([cee0708](https://github.com/pinpong/react-native-google-maps-plus/commit/cee0708dfdee185ee4c8bb2836abd2a3c022fc93)) - -### 🛠️ Other changes - -* **ci:** move PR template to root for auto-apply ([03e8a84](https://github.com/pinpong/react-native-google-maps-plus/commit/03e8a8438b0d5edab80fcdf2f2c8abf3372288c2)) -* **example:** beautify example app UI ([4f390ec](https://github.com/pinpong/react-native-google-maps-plus/commit/4f390ecd9ebc2f3e559913882ac56d33a30ac45b)) -* **example:** beautify example app UI ([73c997c](https://github.com/pinpong/react-native-google-maps-plus/commit/73c997c69f23deeb48eb9b2be5df76a36ff0afea)) -* fix CHANGELOG.md ([2f2bb2c](https://github.com/pinpong/react-native-google-maps-plus/commit/2f2bb2c617260166551abbc07dfa9a8ae27cf31e)) - ## [1.1.0](https://github.com/pinpong/react-native-google-maps-plus/compare/v1.0.2...v1.1.0) (2025-10-08) ### ✨ Features @@ -78,18 +60,6 @@ * update to react-native 0.82.0 ([31d5ff5](https://github.com/pinpong/react-native-google-maps-plus/commit/31d5ff5157ec8357b9d699d4dcc09bda09e11afb)) * update to react-native 0.82.0 ([8c8e8ae](https://github.com/pinpong/react-native-google-maps-plus/commit/8c8e8ae1c4fcf97e04059d873461f083e4c346cf)) -## [1.1.0-dev.5](https://github.com/pinpong/react-native-google-maps-plus/compare/v1.1.0-dev.4...v1.1.0-dev.5) (2025-10-08) - -### 🐛 Bug Fixes - -* **example:** build issues ([cee0708](https://github.com/pinpong/react-native-google-maps-plus/commit/cee0708dfdee185ee4c8bb2836abd2a3c022fc93)) - -### 🛠️ Other changes - -* **example:** beautify example app UI ([4f390ec](https://github.com/pinpong/react-native-google-maps-plus/commit/4f390ecd9ebc2f3e559913882ac56d33a30ac45b)) -* **example:** beautify example app UI ([73c997c](https://github.com/pinpong/react-native-google-maps-plus/commit/73c997c69f23deeb48eb9b2be5df76a36ff0afea)) -* fix CHANGELOG.md ([2f2bb2c](https://github.com/pinpong/react-native-google-maps-plus/commit/2f2bb2c617260166551abbc07dfa9a8ae27cf31e)) - ## [1.1.0](https://github.com/pinpong/react-native-google-maps-plus/compare/v1.0.2...v1.1.0) (2025-10-08) ### ✨ Features diff --git a/android/src/main/java/com/rngooglemapsplus/GoogleMapsViewImpl.kt b/android/src/main/java/com/rngooglemapsplus/GoogleMapsViewImpl.kt index 5a0486d..ad55a07 100644 --- a/android/src/main/java/com/rngooglemapsplus/GoogleMapsViewImpl.kt +++ b/android/src/main/java/com/rngooglemapsplus/GoogleMapsViewImpl.kt @@ -1,8 +1,12 @@ package com.rngooglemapsplus import android.annotation.SuppressLint +import android.graphics.Bitmap import android.location.Location +import android.util.Base64 +import android.util.Size import android.widget.FrameLayout +import androidx.core.graphics.scale import com.facebook.react.bridge.LifecycleEventListener import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.uimanager.PixelUtil.dpToPx @@ -15,6 +19,7 @@ import com.google.android.gms.maps.MapView import com.google.android.gms.maps.model.CameraPosition import com.google.android.gms.maps.model.Circle import com.google.android.gms.maps.model.CircleOptions +import com.google.android.gms.maps.model.IndoorBuilding import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.LatLngBounds import com.google.android.gms.maps.model.MapColorScheme @@ -28,9 +33,15 @@ import com.google.android.gms.maps.model.PolylineOptions import com.google.android.gms.maps.model.TileOverlay 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.toLocationErrorCode +import com.rngooglemapsplus.extensions.toRNIndoorBuilding +import com.rngooglemapsplus.extensions.toRNIndoorLevel import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileOutputStream import java.nio.charset.StandardCharsets class GoogleMapsViewImpl( @@ -47,6 +58,8 @@ class GoogleMapsViewImpl( GoogleMap.OnPolylineClickListener, GoogleMap.OnPolygonClickListener, GoogleMap.OnCircleClickListener, + GoogleMap.OnMarkerDragListener, + GoogleMap.OnIndoorStateChangeListener, LifecycleEventListener { private var initialized = false private var mapReady = false @@ -136,12 +149,13 @@ class GoogleMapsViewImpl( googleMap?.setOnPolygonClickListener(this@GoogleMapsViewImpl) googleMap?.setOnCircleClickListener(this@GoogleMapsViewImpl) googleMap?.setOnMapClickListener(this@GoogleMapsViewImpl) + googleMap?.setOnMarkerDragListener(this@GoogleMapsViewImpl) } initLocationCallbacks() applyPending() + mapReady = true + onMapReady?.invoke(true) } - mapReady = true - onMapReady?.invoke(true) } override fun onCameraMoveStarted(reason: Int) { @@ -182,6 +196,8 @@ class GoogleMapsViewImpl( if (cameraPosition == lastSubmittedCameraPosition) { return } + lastSubmittedCameraPosition = cameraPosition + val isGesture = GoogleMap.OnCameraMoveStartedListener.REASON_GESTURE == cameraMoveReason val latDelta = bounds.northeast.latitude - bounds.southwest.latitude @@ -201,7 +217,6 @@ class GoogleMapsViewImpl( ), isGesture, ) - lastSubmittedCameraPosition = cameraPosition } override fun onCameraIdle() { @@ -357,6 +372,8 @@ class GoogleMapsViewImpl( } } + var initialProps: RNInitialProps? = null + var uiSettings: RNMapUiSettings? = null set(value) { field = value @@ -481,10 +498,15 @@ class GoogleMapsViewImpl( var onLocationUpdate: ((RNLocation) -> Unit)? = null var onLocationError: ((RNLocationErrorCode) -> Unit)? = null var onMapPress: ((RNLatLng) -> Unit)? = null - var onMarkerPress: ((String) -> Unit)? = null - var onPolylinePress: ((String) -> Unit)? = null - var onPolygonPress: ((String) -> Unit)? = null - var onCirclePress: ((String) -> Unit)? = null + var onMarkerPress: ((String?) -> Unit)? = null + var onPolylinePress: ((String?) -> Unit)? = null + var onPolygonPress: ((String?) -> Unit)? = null + var onCirclePress: ((String?) -> Unit)? = null + var onMarkerDragStart: ((String?, RNLatLng) -> Unit)? = null + var onMarkerDrag: ((String?, RNLatLng) -> Unit)? = null + var onMarkerDragEnd: ((String?, RNLatLng) -> Unit)? = null + var onIndoorBuildingFocused: ((RNIndoorBuilding) -> Unit)? = null + var onIndoorLevelActivated: ((RNIndoorLevel) -> Unit)? = null var onCameraChangeStart: ((RNRegion, RNCamera, Boolean) -> Unit)? = null var onCameraChange: ((RNRegion, RNCamera, Boolean) -> Unit)? = null var onCameraChangeComplete: ((RNRegion, RNCamera, Boolean) -> Unit)? = null @@ -492,7 +514,7 @@ class GoogleMapsViewImpl( fun setCamera( cameraPosition: CameraPosition, animated: Boolean, - durationMS: Int, + durationMs: Int, ) { onUi { val current = googleMap?.cameraPosition @@ -503,7 +525,7 @@ class GoogleMapsViewImpl( val update = CameraUpdateFactory.newCameraPosition(cameraPosition) if (animated) { - googleMap?.animateCamera(update, durationMS, null) + googleMap?.animateCamera(update, durationMs, null) } else { googleMap?.moveCamera(update) } @@ -514,7 +536,7 @@ class GoogleMapsViewImpl( coordinates: Array, padding: RNMapPadding, animated: Boolean, - durationMS: Int, + durationMs: Int, ) { if (coordinates.isEmpty()) { return @@ -572,13 +594,85 @@ class GoogleMapsViewImpl( 0, ) if (animated) { - googleMap?.animateCamera(update, durationMS, null) + googleMap?.animateCamera(update, durationMs, null) } else { googleMap?.moveCamera(update) } } } + fun setCameraBounds(bounds: LatLngBounds?) { + onUi { + googleMap?.setLatLngBoundsForCameraTarget(bounds) + } + } + + fun animateToBounds( + bounds: LatLngBounds, + padding: Int, + durationMs: Int, + lockBounds: Boolean, + ) { + onUi { + if (lockBounds) { + googleMap?.setLatLngBoundsForCameraTarget(bounds) + } + val update = + CameraUpdateFactory.newLatLngBounds( + bounds, + padding, + ) + googleMap?.animateCamera(update, durationMs, null) + } + } + + fun snapshot( + size: Size?, + format: String, + compressFormat: Bitmap.CompressFormat, + quality: Double, + resultIsFile: Boolean, + ): Promise { + val promise = Promise() + onUi { + googleMap?.snapshot { bitmap -> + try { + if (bitmap == null) { + promise.resolve(null) + return@snapshot + } + + val scaledBitmap = + size?.let { + bitmap.scale(it.width, it.height) + } ?: bitmap + + val output = ByteArrayOutputStream() + scaledBitmap.compress(compressFormat, (quality * 100).toInt().coerceIn(0, 100), output) + val bytes = output.toByteArray() + + if (resultIsFile) { + val file = File(context.cacheDir, "map_snapshot_${System.currentTimeMillis()}.$format") + FileOutputStream(file).use { it.write(bytes) } + promise.resolve(file.absolutePath) + } else { + val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP) + promise.resolve("data:image/$format;base64,$base64") + } + + if (scaledBitmap != bitmap) { + scaledBitmap.recycle() + } + bitmap.recycle() + } catch (e: Exception) { + promise.resolve(null) + } + } + } + + return promise + } + fun addMarker( id: String, opts: MarkerOptions, @@ -883,6 +977,7 @@ class GoogleMapsViewImpl( fun destroyInternal() { onUi { + locationHandler.stop() markerBuilder.cancelAllJobs() clearMarkers() clearPolylines() @@ -890,7 +985,6 @@ class GoogleMapsViewImpl( clearCircles() clearHeatmaps() clearKmlLayer() - locationHandler.stop() googleMap?.apply { setOnCameraMoveStartedListener(null) setOnCameraMoveListener(null) @@ -900,6 +994,7 @@ class GoogleMapsViewImpl( setOnPolygonClickListener(null) setOnCircleClickListener(null) setOnMapClickListener(null) + setOnMarkerDragListener(null) } googleMap = null mapView?.apply { @@ -910,6 +1005,7 @@ class GoogleMapsViewImpl( } super.removeAllViews() reactContext.removeLifecycleEventListener(this) + initialized = false } } @@ -954,20 +1050,21 @@ class GoogleMapsViewImpl( } override fun onMarkerClick(marker: Marker): Boolean { - onMarkerPress?.invoke(marker.tag?.toString() ?: "unknown") + marker.showInfoWindow() + onMarkerPress?.invoke(marker.tag?.toString()) return true } override fun onPolylineClick(polyline: Polyline) { - onPolylinePress?.invoke(polyline.tag?.toString() ?: "unknown") + onPolylinePress?.invoke(polyline.tag?.toString()) } override fun onPolygonClick(polygon: Polygon) { - onPolygonPress?.invoke(polygon.tag?.toString() ?: "unknown") + onPolygonPress?.invoke(polygon.tag?.toString()) } override fun onCircleClick(circle: Circle) { - onCirclePress?.invoke(circle.tag?.toString() ?: "unknown") + onCirclePress?.invoke(circle.tag?.toString()) } override fun onMapClick(coordinates: LatLng) { @@ -975,6 +1072,42 @@ class GoogleMapsViewImpl( RNLatLng(coordinates.latitude, coordinates.longitude), ) } + + override fun onMarkerDragStart(marker: Marker) { + onMarkerDragStart?.invoke( + marker.tag?.toString(), + RNLatLng(marker.position.latitude, marker.position.longitude), + ) + } + + override fun onMarkerDrag(marker: Marker) { + onMarkerDrag?.invoke( + marker.tag?.toString(), + RNLatLng(marker.position.latitude, marker.position.longitude), + ) + } + + override fun onMarkerDragEnd(marker: Marker) { + onMarkerDragEnd?.invoke( + marker.tag?.toString(), + RNLatLng(marker.position.latitude, marker.position.longitude), + ) + } + + override fun onIndoorBuildingFocused() { + val building = googleMap?.focusedBuilding ?: return + onIndoorBuildingFocused?.invoke(building.toRNIndoorBuilding()) + } + + override fun onIndoorLevelActivated(indoorBuilding: IndoorBuilding) { + val activeLevel = indoorBuilding.levels.getOrNull(indoorBuilding.activeLevelIndex) ?: return + onIndoorLevelActivated?.invoke( + activeLevel.toRNIndoorLevel( + indoorBuilding.activeLevelIndex, + true, + ), + ) + } } private inline fun onUi(crossinline block: () -> Unit) { diff --git a/android/src/main/java/com/rngooglemapsplus/LocationHandler.kt b/android/src/main/java/com/rngooglemapsplus/LocationHandler.kt index 42313db..c03c8b5 100644 --- a/android/src/main/java/com/rngooglemapsplus/LocationHandler.kt +++ b/android/src/main/java/com/rngooglemapsplus/LocationHandler.kt @@ -131,7 +131,6 @@ class LocationHandler( private fun restartLocationUpdates() { stop() - // 4) Google Play Services checken – früh zurückmelden val playServicesStatus = GoogleApiAvailability .getInstance() diff --git a/android/src/main/java/com/rngooglemapsplus/MapMarkerBuilder.kt b/android/src/main/java/com/rngooglemapsplus/MapMarkerBuilder.kt index abd0d86..1e2fe56 100644 --- a/android/src/main/java/com/rngooglemapsplus/MapMarkerBuilder.kt +++ b/android/src/main/java/com/rngooglemapsplus/MapMarkerBuilder.kt @@ -41,8 +41,13 @@ class MapMarkerBuilder( ): MarkerOptions = MarkerOptions().apply { position(LatLng(m.coordinate.latitude, m.coordinate.longitude)) - anchor((m.anchor?.x ?: 0.5).toFloat(), (m.anchor?.y ?: 0.5).toFloat()) 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.anchor?.let { anchor((m.anchor.x).toFloat(), (m.anchor.y).toFloat()) } m.zIndex?.let { zIndex(it.toFloat()) } } @@ -56,17 +61,22 @@ class MapMarkerBuilder( next.coordinate.latitude, next.coordinate.longitude, ) - marker.zIndex = next.zIndex?.toFloat() ?: 0f if (!prev.markerStyleEquals(next)) { buildIconAsync(marker.id, next) { icon -> marker.setIcon(icon) } } + marker.title = next.title + marker.snippet = next.snippet + marker.alpha = next.opacity?.toFloat() ?: 0f + marker.isFlat = next.flat ?: false + marker.isDraggable = next.draggable ?: false marker.setAnchor( (next.anchor?.x ?: 0.5).toFloat(), - (next.anchor?.y ?: 0.5).toFloat(), + (next.anchor?.y ?: 1.0).toFloat(), ) + marker.zIndex = next.zIndex?.toFloat() ?: 0f } fun buildIconAsync( diff --git a/android/src/main/java/com/rngooglemapsplus/RNGoogleMapsPlusView.kt b/android/src/main/java/com/rngooglemapsplus/RNGoogleMapsPlusView.kt index ede216d..d31d24e 100644 --- a/android/src/main/java/com/rngooglemapsplus/RNGoogleMapsPlusView.kt +++ b/android/src/main/java/com/rngooglemapsplus/RNGoogleMapsPlusView.kt @@ -6,16 +6,22 @@ import com.facebook.react.uimanager.ThemedReactContext import com.google.android.gms.maps.model.MapStyleOptions import com.margelo.nitro.core.Promise import com.rngooglemapsplus.extensions.circleEquals +import com.rngooglemapsplus.extensions.isFileResult import com.rngooglemapsplus.extensions.markerEquals import com.rngooglemapsplus.extensions.polygonEquals import com.rngooglemapsplus.extensions.polylineEquals import com.rngooglemapsplus.extensions.toCameraPosition +import com.rngooglemapsplus.extensions.toCompressFormat +import com.rngooglemapsplus.extensions.toFileExtension +import com.rngooglemapsplus.extensions.toLatLngBounds import com.rngooglemapsplus.extensions.toMapColorScheme +import com.rngooglemapsplus.extensions.toSize @DoNotStrip class RNGoogleMapsPlusView( val context: ThemedReactContext, ) : HybridRNGoogleMapsPlusViewSpec() { + private var propsInitialized = false private var currentCustomMapStyle: String? = null private var permissionHandler = PermissionHandler(context) private var locationHandler = LocationHandler(context) @@ -30,15 +36,23 @@ class RNGoogleMapsPlusView( override val view = GoogleMapsViewImpl(context, locationHandler, playServiceHandler, markerBuilder) + override fun afterUpdate() { + super.afterUpdate() + if (!propsInitialized) { + propsInitialized = true + view.initMapView( + initialProps?.mapId, + initialProps?.liteMode, + initialProps?.camera?.toCameraPosition(), + ) + } + } + override var initialProps: RNInitialProps? = null set(value) { if (field == value) return field = value - view.initMapView( - value?.mapId, - value?.liteMode, - value?.camera?.toCameraPosition(), - ) + view.initialProps = value } override var uiSettings: RNMapUiSettings? = null @@ -280,26 +294,51 @@ class RNGoogleMapsPlusView( view.onMapPress = cb } - override var onMarkerPress: ((String) -> Unit)? = null + override var onMarkerPress: ((String?) -> Unit)? = null set(cb) { view.onMarkerPress = cb } - override var onPolylinePress: ((String) -> Unit)? = null + override var onPolylinePress: ((String?) -> Unit)? = null set(cb) { view.onPolylinePress = cb } - override var onPolygonPress: ((String) -> Unit)? = null + override var onPolygonPress: ((String?) -> Unit)? = null set(cb) { view.onPolygonPress = cb } - override var onCirclePress: ((String) -> Unit)? = null + override var onCirclePress: ((String?) -> Unit)? = null set(cb) { view.onCirclePress = cb } + override var onMarkerDragStart: ((String?, RNLatLng) -> Unit)? = null + set(cb) { + view.onMarkerDragStart = cb + } + + override var onMarkerDrag: ((String?, RNLatLng) -> Unit)? = null + set(cb) { + view.onMarkerDrag = cb + } + + override var onMarkerDragEnd: ((String?, RNLatLng) -> Unit)? = null + set(cb) { + view.onMarkerDragEnd = cb + } + + override var onIndoorBuildingFocused: ((RNIndoorBuilding) -> Unit)? = null + set(cb) { + view.onIndoorBuildingFocused = cb + } + + override var onIndoorLevelActivated: ((RNIndoorLevel) -> Unit)? = null + set(cb) { + view.onIndoorLevelActivated = cb + } + override var onCameraChangeStart: ((RNRegion, RNCamera, Boolean) -> Unit)? = null set(cb) { view.onCameraChangeStart = cb @@ -318,25 +357,54 @@ class RNGoogleMapsPlusView( override fun setCamera( camera: RNCamera, animated: Boolean?, - durationMS: Double?, + durationMs: Double?, ) { - view.setCamera(camera.toCameraPosition(), animated == true, durationMS?.toInt() ?: 3000) + view.setCamera(camera.toCameraPosition(), animated == true, durationMs?.toInt() ?: 3000) } override fun setCameraToCoordinates( coordinates: Array, padding: RNMapPadding?, animated: Boolean?, - durationMS: Double?, + durationMs: Double?, ) { view.setCameraToCoordinates( coordinates, padding = padding ?: RNMapPadding(0.0, 0.0, 0.0, 0.0), animated == true, - durationMS?.toInt() ?: 3000, + durationMs?.toInt() ?: 3000, ) } + override fun setCameraBounds(bounds: RNLatLngBounds?) { + view.setCameraBounds( + bounds?.toLatLngBounds(), + ) + } + + override fun animateToBounds( + bounds: RNLatLngBounds, + padding: Double?, + durationMs: Double?, + lockBounds: Boolean?, + ) { + view.animateToBounds( + bounds.toLatLngBounds(), + padding = padding?.toInt() ?: 0, + durationMs?.toInt() ?: 3000, + lockBounds = false, + ) + } + + override fun snapshot(options: RNSnapshotOptions): Promise = + view.snapshot( + size = options.size.toSize(), + format = options.format.toFileExtension(), + compressFormat = options.format.toCompressFormat(), + quality = options.quality, + resultIsFile = options.resultType.isFileResult(), + ) + override fun showLocationDialog() { locationHandler.showLocationDialog() } diff --git a/android/src/main/java/com/rngooglemapsplus/extensions/IndoorBuildingExtension.kt b/android/src/main/java/com/rngooglemapsplus/extensions/IndoorBuildingExtension.kt new file mode 100644 index 0000000..eace541 --- /dev/null +++ b/android/src/main/java/com/rngooglemapsplus/extensions/IndoorBuildingExtension.kt @@ -0,0 +1,33 @@ +package com.rngooglemapsplus.extensions + +import com.google.android.gms.maps.model.IndoorBuilding +import com.google.android.gms.maps.model.IndoorLevel +import com.rngooglemapsplus.RNIndoorBuilding +import com.rngooglemapsplus.RNIndoorLevel + +fun IndoorBuilding.toRNIndoorBuilding(): RNIndoorBuilding { + val mappedLevels = + levels + .mapIndexed { index, level -> + val active = index == activeLevelIndex + level.toRNIndoorLevel(index, active) + }.toTypedArray() + + return RNIndoorBuilding( + activeLevelIndex = activeLevelIndex.toDouble(), + defaultLevelIndex = defaultLevelIndex.toDouble(), + levels = mappedLevels, + underground = isUnderground, + ) +} + +fun IndoorLevel.toRNIndoorLevel( + index: Int, + active: Boolean, +): RNIndoorLevel = + RNIndoorLevel( + index = index.toDouble(), + name = name, + shortName = shortName, + active = active, + ) diff --git a/android/src/main/java/com/rngooglemapsplus/extensions/RNLatLngBoundsExtension.kt b/android/src/main/java/com/rngooglemapsplus/extensions/RNLatLngBoundsExtension.kt new file mode 100644 index 0000000..b073d0f --- /dev/null +++ b/android/src/main/java/com/rngooglemapsplus/extensions/RNLatLngBoundsExtension.kt @@ -0,0 +1,17 @@ +package com.rngooglemapsplus.extensions + +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.LatLngBounds +import com.rngooglemapsplus.RNLatLngBounds + +fun RNLatLngBounds.toLatLngBounds(): LatLngBounds = + LatLngBounds( + LatLng( + southWest.latitude, + southWest.longitude, + ), + LatLng( + northEast.latitude, + northEast.longitude, + ), + ) diff --git a/android/src/main/java/com/rngooglemapsplus/extensions/RNSize.kt b/android/src/main/java/com/rngooglemapsplus/extensions/RNSize.kt new file mode 100644 index 0000000..132fd8a --- /dev/null +++ b/android/src/main/java/com/rngooglemapsplus/extensions/RNSize.kt @@ -0,0 +1,7 @@ +package com.rngooglemapsplus.extensions + +import android.util.Size +import com.facebook.react.uimanager.PixelUtil.dpToPx +import com.rngooglemapsplus.RNSize + +fun RNSize?.toSize(): Size? = this?.let { Size(width.dpToPx().toInt(), height.dpToPx().toInt()) } diff --git a/android/src/main/java/com/rngooglemapsplus/extensions/RNSnapshotFormat.kt b/android/src/main/java/com/rngooglemapsplus/extensions/RNSnapshotFormat.kt new file mode 100644 index 0000000..da71976 --- /dev/null +++ b/android/src/main/java/com/rngooglemapsplus/extensions/RNSnapshotFormat.kt @@ -0,0 +1,16 @@ +package com.rngooglemapsplus.extensions + +import android.graphics.Bitmap +import com.rngooglemapsplus.RNSnapshotFormat + +fun RNSnapshotFormat?.toCompressFormat(): Bitmap.CompressFormat = + when (this) { + RNSnapshotFormat.JPG, RNSnapshotFormat.JPEG -> Bitmap.CompressFormat.JPEG + RNSnapshotFormat.PNG, null -> Bitmap.CompressFormat.PNG + } + +fun RNSnapshotFormat?.toFileExtension(): String = + when (this) { + RNSnapshotFormat.JPG, RNSnapshotFormat.JPEG -> "jpg" + RNSnapshotFormat.PNG, null -> "png" + } diff --git a/android/src/main/java/com/rngooglemapsplus/extensions/RNSnapshotResultType.kt b/android/src/main/java/com/rngooglemapsplus/extensions/RNSnapshotResultType.kt new file mode 100644 index 0000000..112d333 --- /dev/null +++ b/android/src/main/java/com/rngooglemapsplus/extensions/RNSnapshotResultType.kt @@ -0,0 +1,9 @@ +package com.rngooglemapsplus.extensions + +import com.rngooglemapsplus.RNSnapshotResultType + +fun RNSnapshotResultType?.isFileResult(): Boolean = + when (this) { + RNSnapshotResultType.FILE -> true + RNSnapshotResultType.BASE64, null -> false + } diff --git a/example/index.js b/example/index.js index 117ddca..470a3ba 100644 --- a/example/index.js +++ b/example/index.js @@ -1,5 +1,8 @@ import { AppRegistry } from 'react-native'; import App from './src/App'; import { name as appName } from './app.json'; +import { LogBox } from 'react-native'; + +LogBox.ignoreLogs(['InteractionManager has been deprecated']); AppRegistry.registerComponent(appName, () => App); diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 413288b..5935e1a 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -2474,7 +2474,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - RNGoogleMapsPlus (1.1.0-dev.5): + - RNGoogleMapsPlus (1.2.0): - boost - DoubleConversion - fast_float @@ -3086,7 +3086,7 @@ SPEC CHECKSUMS: ReactCodegen: 3873d7ac09960375f7845384ff47d53e478462dc ReactCommon: f5527f5d97a9957ab46eb5db78875d3579e03b97 RNGestureHandler: e1cc4de7646eb557ad62d1271d8eac73c304a896 - RNGoogleMapsPlus: cdea400ea1e69740d91e07dbb5882d93be4c0a77 + RNGoogleMapsPlus: 43e90cbedb2f3deec67a3a0a14fb5065f608ea3c RNReanimated: 8f0185df21f0dea34ee8c9611ba88c17a290ed9a RNScreens: 2e9c41cd099b1ca50136af8d57c3594214d0086a RNWorklets: ab618bf7d1c7fd2cb793b9f0f39c3e29274b3ebf @@ -3094,6 +3094,6 @@ SPEC CHECKSUMS: SVGKit: 1ad7513f8c74d9652f94ed64ddecda1a23864dea Yoga: ce55ebb197c21e22b6700cd36e3f36b7ec26e6f8 -PODFILE CHECKSUM: 246331f3f9b61838ac0bd43aa0f04db450c4bd52 +PODFILE CHECKSUM: 18d25340bc263a2eab86e2d8e5cfd9ad55ef6458 COCOAPODS: 1.15.2 diff --git a/example/src/App.tsx b/example/src/App.tsx index f44ddd3..05f1756 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -19,8 +19,13 @@ import CustomStyleScreen from './screens/CustomStyleScreen'; import StressTestScreen from './screens/StressTestScreen'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { useColorScheme } from 'react-native'; +import BlankScreen from './screens/BlankScreen'; +import IndoorLevelMapScreen from './screens/IndoorLevelMapScreen'; +import CameraTestScreen from './screens/CameraTestScreen'; +import type { RootStackParamList } from './types/navigation'; +import SnapshotTestScreen from './screens/SnaptshotTestScreen'; -const Stack = createStackNavigator(); +const Stack = createStackNavigator(); export default function App() { const scheme = useColorScheme(); @@ -42,7 +47,11 @@ export default function App() { component={HomeScreen} options={{ title: 'Google Maps Examples' }} /> - + + + + diff --git a/example/src/components/ControlPanel.tsx b/example/src/components/ControlPanel.tsx index ef81b1f..1df87fa 100644 --- a/example/src/components/ControlPanel.tsx +++ b/example/src/components/ControlPanel.tsx @@ -15,6 +15,8 @@ import Animated, { } from 'react-native-reanimated'; import type { GoogleMapsViewRef } from 'react-native-google-maps-plus'; import { useAppTheme } from '../theme'; +import { useNavigation } from '@react-navigation/native'; +import type { RootNavigationProp } from '../types/navigation'; export type ButtonItem = { title: string; onPress: () => void }; @@ -25,7 +27,9 @@ type Props = { export default function ControlPanel({ mapRef, buttons }: Props) { const theme = useAppTheme(); + const navigation = useNavigation(); const progress = useSharedValue(0); + const styles = getThemedStyles(theme); const toggle = () => { progress.value = withTiming(progress.value === 1 ? 0 : 1, { @@ -36,6 +40,10 @@ export default function ControlPanel({ mapRef, buttons }: Props) { const finalButtons = useMemo( () => [ ...buttons, + { + title: `Navigate to blank screen`, + onPress: () => navigation.navigate('Blank'), + }, { title: 'Request location permission', onPress: async () => { @@ -57,7 +65,7 @@ export default function ControlPanel({ mapRef, buttons }: Props) { console.log(mapRef.current?.isGooglePlayServicesAvailable()), }, ], - [buttons, mapRef] + [buttons, mapRef, navigation] ); const buttonHeight = 52; @@ -83,22 +91,16 @@ export default function ControlPanel({ mapRef, buttons }: Props) { return ( - - Controls - - - ▼ - + Controls + @@ -106,16 +108,11 @@ export default function ControlPanel({ mapRef, buttons }: Props) { {finalButtons.map((btn, i) => ( - - {btn.title} - + {btn.title} ))} @@ -124,53 +121,61 @@ export default function ControlPanel({ mapRef, buttons }: Props) { ); } -const styles = StyleSheet.create({ - scrollView: { - position: 'absolute', - bottom: 0, - left: 0, - right: 0, - paddingHorizontal: 12, - paddingTop: 12, - }, - scrollContent: { - paddingBottom: 40, - }, - header: { - borderRadius: 10, - paddingVertical: 12, - alignItems: 'center', - marginBottom: 10, - flexDirection: 'row', - justifyContent: 'center', - }, - headerText: { - fontWeight: '600', - fontSize: 16, - marginRight: 6, - }, - arrow: { - fontSize: 14, - fontWeight: '600', - }, - animatedContainer: { - overflow: 'hidden', - }, - buttonList: { - gap: 8, - }, - button: { - paddingVertical: 12, - borderRadius: 10, - alignItems: 'center', - justifyContent: 'center', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.25, - shadowRadius: 4, - elevation: 1, - }, - buttonText: { - fontWeight: '600', - fontSize: 15, - }, -}); +const getThemedStyles = (theme: any) => + StyleSheet.create({ + scrollView: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + paddingHorizontal: 12, + paddingTop: 12, + backgroundColor: theme.bgPrimary, + }, + scrollContent: { + paddingBottom: 40, + }, + header: { + borderRadius: 10, + paddingVertical: 12, + alignItems: 'center', + marginBottom: 10, + flexDirection: 'row', + justifyContent: 'center', + backgroundColor: theme.bgHeader, + }, + headerText: { + fontWeight: '600', + fontSize: 16, + marginRight: 6, + color: theme.textPrimary, + }, + arrow: { + fontSize: 14, + fontWeight: '600', + color: theme.textPrimary, + }, + animatedContainer: { + overflow: 'hidden', + }, + buttonList: { + gap: 8, + }, + button: { + backgroundColor: theme.bgAccent, + paddingVertical: 12, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + shadowColor: theme.shadow, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 1, + }, + buttonText: { + fontWeight: '600', + fontSize: 15, + color: theme.textOnAccent, + }, + }); diff --git a/example/src/components/MapWrapper.tsx b/example/src/components/MapWrapper.tsx index d6075fd..5ccb035 100644 --- a/example/src/components/MapWrapper.tsx +++ b/example/src/components/MapWrapper.tsx @@ -1,6 +1,12 @@ import React, { useMemo } from 'react'; -import { StyleSheet, useColorScheme, View } from 'react-native'; -import { GoogleMapsView } from 'react-native-google-maps-plus'; +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, @@ -15,6 +21,8 @@ import { } 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'; type Props = ViewProps & RNGoogleMapsPlusViewProps & { @@ -24,8 +32,11 @@ type Props = ViewProps & export default function MapWrapper(props: Props) { const { children, ...rest } = props; - const scheme = useColorScheme(); + const theme = useTheme(); + const styles = getThemedStyles(theme); const layout = useSafeAreaInsets(); + + const [mapReady, setMapReady] = React.useState(false); const initialProps = useMemo( () => ({ camera: { @@ -53,10 +64,11 @@ export default function MapWrapper(props: Props) { [] ); - const mapPadding = useMemo( - () => ({ top: 20, left: 20, bottom: layout.bottom + 80, right: 20 }), - [layout.bottom] - ); + const mapPadding = useMemo(() => { + return props.children + ? { top: 20, left: 20, bottom: layout.bottom + 80, right: 20 } + : undefined; + }, [layout.bottom, props.children]); const mapZoomConfig = useMemo(() => ({ min: 0, max: 20 }), []); @@ -88,105 +100,137 @@ export default function MapWrapper(props: Props) { uiSettings={props.uiSettings ?? uiSettings} style={[styles.map, props.style]} userInterfaceStyle={ - (props.userInterfaceStyle ?? scheme === 'dark') ? 'dark' : 'light' + props.userInterfaceStyle ?? (theme.dark ? 'dark' : 'light') } mapType={props.mapType ?? 'normal'} mapZoomConfig={props.mapZoomConfig ?? mapZoomConfig} mapPadding={props.mapPadding ?? mapPadding} locationConfig={props.locationConfig ?? locationConfig} - onMapReady={ - props.onMapReady - ? { - f: (ready: boolean) => console.log('Map is ready! ' + ready), - } - : undefined - } - onMapPress={ - props.onMapPress - ? { - f: (c: RNLatLng) => console.log('Map press:', c), - } - : undefined - } - onMarkerPress={ - props.onMarkerPress - ? { - f: (id: string) => console.log('Marker press:', id), - } - : undefined - } - onPolylinePress={ - props.onPolylinePress - ? { - f: (id: string) => console.log('Polyline press:', id), - } - : undefined - } - onPolygonPress={ - props.onPolygonPress - ? { - f: (id: string) => console.log('Polygon press:', id), - } - : undefined - } - onCirclePress={ - props.onCirclePress - ? { - f: (id: string) => console.log('Circle press:', id), - } - : undefined - } - onCameraChangeStart={ - props.onCameraChangeStart - ? { - f: (r: RNRegion, cam: RNCamera, g: boolean) => - console.log('Cam start', r, cam, g), - } - : undefined - } - onCameraChange={ - props.onCameraChange - ? { - f: (r: RNRegion, cam: RNCamera, g: boolean) => - console.log('Cam', r, cam, g), - } - : undefined - } - onCameraChangeComplete={ - props.onCameraChangeComplete - ? { - f: (r: RNRegion, cam: RNCamera, g: boolean) => - console.log('Cam complete', r, cam, g), - } - : undefined - } - onLocationUpdate={ - props.onLocationUpdate - ? { - f: (l: RNLocation) => console.log('Location', l), - } - : undefined - } - onLocationError={ - props.onLocationError - ? { - f: (e: any) => console.log('Location error', e), - } - : undefined - } + onMapReady={callback( + props.onMapReady ?? { + f: (ready: boolean) => { + console.log('Map is ready! ' + ready); + setMapReady(true); + }, + } + )} + onMapError={callback( + props.onMapError ?? { + f: (error: RNMapErrorCode) => console.log('Map error:', error), + } + )} + onMapPress={callback( + props.onMapPress ?? { + f: (c: RNLatLng) => console.log('Map press:', c), + } + )} + onMarkerPress={callback( + props.onMarkerPress ?? { + f: (id: string | undefined) => console.log('Marker press:', id), + } + )} + onPolylinePress={callback( + props.onPolylinePress ?? { + f: (id: string | undefined) => console.log('Polyline press:', id), + } + )} + onPolygonPress={callback( + props.onPolygonPress ?? { + f: (id: string | undefined) => console.log('Polygon press:', id), + } + )} + onCirclePress={callback( + props.onCirclePress ?? { + f: (id: string | undefined) => console.log('Circle press:', id), + } + )} + onMarkerDragStart={callback( + props.onMarkerDragStart ?? { + f: (id: string | undefined, latLng: RNLatLng) => + console.log('Marker drag start', id, latLng), + } + )} + onMarkerDrag={callback( + props.onMarkerDrag ?? { + f: (id: string | undefined, latLng: RNLatLng) => + console.log('Marker drag', id, latLng), + } + )} + onMarkerDragEnd={callback( + props.onMarkerDragEnd ?? { + f: (id: string | undefined, latLng: RNLatLng) => + console.log('Marker drag end', id, latLng), + } + )} + onIndoorBuildingFocused={callback( + props.onIndoorBuildingFocused ?? { + f: (building: RNIndoorBuilding) => + console.log('Indoor building focused', building), + } + )} + onIndoorLevelActivated={callback( + props.onIndoorLevelActivated ?? { + f: (level: RNIndoorLevel) => + console.log('Indoor level activated', level), + } + )} + onCameraChangeStart={callback( + props.onCameraChangeStart ?? { + f: (r: RNRegion, cam: RNCamera, g: boolean) => + console.log('Cam start', r, cam, g), + } + )} + onCameraChange={callback( + props.onCameraChange ?? { + f: (r: RNRegion, cam: RNCamera, g: boolean) => + console.log('Cam', r, cam, g), + } + )} + onCameraChangeComplete={callback( + props.onCameraChangeComplete ?? { + f: (r: RNRegion, cam: RNCamera, g: boolean) => + console.log('Cam complete', r, cam, g), + } + )} + onLocationUpdate={callback( + props.onLocationUpdate ?? { + f: (l: RNLocation) => console.log('Location', l), + } + )} + onLocationError={callback( + props.onLocationError ?? { + f: (e: RNLocationErrorCode) => console.log('Location error', e), + } + )} /> {children} + {!mapReady && ( + + + + )} ); } -const styles = StyleSheet.create({ - container: { flex: 1 }, - map: { - position: 'absolute', - left: 0, - right: 0, - top: 0, - bottom: 0, - }, -}); +const getThemedStyles = (theme: any) => + StyleSheet.create({ + container: { + flex: 1, + backgroundColor: theme.colors.background, + }, + map: { + position: 'absolute', + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + loadingOverlay: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'center', + alignItems: 'center', + zIndex: 10, + backgroundColor: theme.dark ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.7)', + }, + }); diff --git a/example/src/screens/BlankScreen.tsx b/example/src/screens/BlankScreen.tsx new file mode 100644 index 0000000..5eeff57 --- /dev/null +++ b/example/src/screens/BlankScreen.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import { useNavigation, useTheme } from '@react-navigation/native'; +import type { RootNavigationProp } from '../types/navigation'; + +export default function BlankScreen() { + const navigation = useNavigation(); + const { colors } = useTheme(); + + return ( + + Blank Screen + + + This is an empty placeholder screen. + + + navigation.goBack()} + > + + ← Go Back + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 24, + }, + title: { + fontSize: 24, + fontWeight: '700', + marginBottom: 8, + }, + subtitle: { + fontSize: 16, + opacity: 0.8, + marginBottom: 32, + }, + button: { + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 10, + }, + buttonText: { + fontSize: 16, + fontWeight: '500', + }, +}); diff --git a/example/src/screens/CameraTestScreen.tsx b/example/src/screens/CameraTestScreen.tsx new file mode 100644 index 0000000..3610685 --- /dev/null +++ b/example/src/screens/CameraTestScreen.tsx @@ -0,0 +1,81 @@ +import React, { useMemo, useRef, useState } from 'react'; +import MapWrapper from '../components/MapWrapper'; +import ControlPanel from '../components/ControlPanel'; +import type { + GoogleMapsViewRef, + RNLatLngBounds, + RNCamera, + RNLatLng, +} from 'react-native-google-maps-plus'; + +export default function CameraTestScreen() { + const mapRef = useRef(null); + const [boundsActive, setBoundsActive] = useState(false); + + const coordinates = useMemo( + () => [ + { latitude: 37.7749, longitude: -122.4194 }, + { latitude: 37.7849, longitude: -122.4094 }, + { latitude: 37.7649, longitude: -122.4294 }, + ], + [] + ); + + const bounds = useMemo( + () => ({ + southWest: { latitude: 37.703, longitude: -122.527 }, + northEast: { latitude: 37.833, longitude: -122.356 }, + }), + [] + ); + + const buttons = useMemo( + () => [ + { + title: 'Set Camera to SF', + onPress: () => { + const camera: RNCamera = { + center: { latitude: 37.7749, longitude: -122.4194 }, + zoom: 12, + bearing: 0, + tilt: 0, + }; + mapRef.current?.setCamera(camera, true, 1000); + }, + }, + { + title: 'Fit Coordinates', + onPress: () => + mapRef.current?.setCameraToCoordinates( + coordinates, + { top: 50, bottom: 50, left: 50, right: 50 }, + true, + 1000 + ), + }, + { + title: boundsActive ? 'Clear Camera Bounds' : 'Set Camera Bounds', + onPress: () => { + if (boundsActive) { + mapRef.current?.setCameraBounds(undefined); + setBoundsActive(false); + } else { + mapRef.current?.setCameraBounds(bounds); + setBoundsActive(true); + } + }, + }, + { + title: 'Animate To Bounds', + onPress: () => mapRef.current?.animateToBounds(bounds, 50, 1200), + }, + ], + [bounds, boundsActive, coordinates] + ); + + return ( + + + + ); +} diff --git a/example/src/screens/HomeScreen.tsx b/example/src/screens/HomeScreen.tsx index fb721b1..5c4455a 100644 --- a/example/src/screens/HomeScreen.tsx +++ b/example/src/screens/HomeScreen.tsx @@ -14,61 +14,68 @@ const screens = [ { name: 'KmlLayer', title: 'KML Layer' }, { name: 'Location', title: 'Location & Permissions' }, { name: 'CustomStyle', title: 'Custom Map Style' }, - { name: 'StressTest', title: 'Stress Test' }, + { name: 'IndoorLevelMap', title: 'Indoor Level Map' }, + { name: 'Camera', title: 'Camera Test' }, + { name: 'Snapshot', title: 'Snapshot Test' }, + { name: 'Stress', title: 'Stress Test' }, ]; export default function HomeScreen() { const navigation = useNavigation>(); const theme = useAppTheme(); + const styles = getThemedStyles(theme); return ( - - - React Native Google Maps Plus Examples - + + React Native Google Maps Plus Examples {screens.map((s) => ( navigation.navigate(s.name)} activeOpacity={0.85} > - - {s.title} - + {s.title} ))} ); } -const styles = StyleSheet.create({ - container: { - flexGrow: 1, - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 40, - }, - title: { - fontSize: 18, - fontWeight: '600', - marginBottom: 20, - }, - button: { - paddingVertical: 14, - paddingHorizontal: 24, - borderRadius: 10, - marginVertical: 6, - width: '80%', - alignItems: 'center', - }, - buttonText: { - fontSize: 16, - fontWeight: '600', - }, -}); +const getThemedStyles = (theme: any) => + StyleSheet.create({ + container: { + flexGrow: 1, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 40, + backgroundColor: theme.bgPrimary, + }, + title: { + fontSize: 18, + fontWeight: '600', + marginBottom: 24, + color: theme.textPrimary, + textAlign: 'center', + }, + button: { + backgroundColor: theme.bgAccent, + paddingVertical: 14, + paddingHorizontal: 24, + borderRadius: 10, + marginVertical: 6, + width: '80%', + alignItems: 'center', + justifyContent: 'center', + shadowColor: theme.shadow, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 2, + }, + buttonText: { + fontSize: 16, + fontWeight: '600', + color: theme.textOnAccent, + }, + }); diff --git a/example/src/screens/IndoorLevelMapScreen.tsx b/example/src/screens/IndoorLevelMapScreen.tsx new file mode 100644 index 0000000..6e09919 --- /dev/null +++ b/example/src/screens/IndoorLevelMapScreen.tsx @@ -0,0 +1,26 @@ +import React, { useRef } from 'react'; +import MapWrapper from '../components/MapWrapper'; +import type { GoogleMapsViewRef } from 'react-native-google-maps-plus'; +import ControlPanel from '../components/ControlPanel'; + +export default function IndoorLevelMapScreen() { + const mapRef = useRef(null); + return ( + + + + ); +} diff --git a/example/src/screens/MarkersScreen.tsx b/example/src/screens/MarkersScreen.tsx index bccaf57..6e5c303 100644 --- a/example/src/screens/MarkersScreen.tsx +++ b/example/src/screens/MarkersScreen.tsx @@ -29,7 +29,7 @@ export default function MarkersScreen() { const coords = markers.map((m) => m.coordinate); mapRef.current?.setCameraToCoordinates( coords, - { top: 0, left: 0, bottom: 0, right: 0 }, + { top: 50, left: 50, bottom: 50, right: 50 }, true, 300 ); diff --git a/example/src/screens/SnaptshotTestScreen.tsx b/example/src/screens/SnaptshotTestScreen.tsx new file mode 100644 index 0000000..142baf6 --- /dev/null +++ b/example/src/screens/SnaptshotTestScreen.tsx @@ -0,0 +1,148 @@ +import React, { useMemo, useRef, useState } from 'react'; +import { Image, Modal, Pressable, StyleSheet, Text, View } from 'react-native'; +import MapWrapper from '../components/MapWrapper'; +import ControlPanel from '../components/ControlPanel'; +import { useAppTheme } from '../theme'; +import type { GoogleMapsViewRef } from 'react-native-google-maps-plus'; + +export default function SnapshotTestScreen() { + const mapRef = useRef(null); + const [snapshotUri, setSnapshotUri] = useState(null); + const [visible, setVisible] = useState(false); + + const theme = useAppTheme(); + + const buttons = useMemo( + () => [ + { + title: 'Take Snapshot (Base64)', + onPress: async () => { + try { + const result = await mapRef.current?.snapshot({ + format: 'jpg', + quality: 0.9, + resultType: 'base64', + }); + if (result) { + setSnapshotUri(result); + setVisible(true); + } + } catch (e) { + console.warn('Snapshot failed:', e); + } + }, + }, + { + title: 'Take Snapshot (File)', + onPress: async () => { + try { + const result = await mapRef.current?.snapshot({ + format: 'jpg', + quality: 0.9, + resultType: 'file', + }); + if (result) { + const uri = result.startsWith('file://') + ? result + : `file://${result}`; + setSnapshotUri(uri); + setVisible(true); + } + } catch (e) { + console.warn('Snapshot failed:', e); + } + }, + }, + ], + [] + ); + + const styles = getThemedStyles(theme); + + return ( + + + + + + + Map Snapshot + + {snapshotUri ? ( + + ) : ( + No image + )} + + setVisible(false)} + style={styles.closeButton} + > + Close + + + + + + ); +} + +const getThemedStyles = (theme: any) => + StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.6)', + alignItems: 'center', + justifyContent: 'center', + }, + dialog: { + backgroundColor: theme.bgPrimary, + borderColor: theme.border, + padding: 20, + borderRadius: 14, + alignItems: 'center', + width: '80%', + shadowColor: theme.shadow, + shadowOpacity: 0.2, + shadowRadius: 10, + shadowOffset: { width: 0, height: 4 }, + elevation: 3, + }, + title: { + fontSize: 18, + fontWeight: '600', + marginBottom: 14, + color: theme.textPrimary, + }, + image: { + width: 260, + height: 260, + borderRadius: 10, + marginBottom: 20, + backgroundColor: theme.bgHeader, + }, + noImage: { + color: theme.textSecondary, + marginBottom: 20, + }, + closeButton: { + backgroundColor: theme.bgAccent, + borderRadius: 10, + paddingVertical: 10, + paddingHorizontal: 20, + shadowColor: theme.shadow, + shadowOpacity: 0.25, + shadowRadius: 6, + shadowOffset: { width: 0, height: 3 }, + elevation: 2, + }, + closeText: { + color: theme.textOnAccent, + fontWeight: '600', + fontSize: 15, + }, + }); diff --git a/example/src/screens/StressTestScreen.tsx b/example/src/screens/StressTestScreen.tsx index f33ecc6..27ea360 100644 --- a/example/src/screens/StressTestScreen.tsx +++ b/example/src/screens/StressTestScreen.tsx @@ -38,7 +38,7 @@ export default function StressTestScreen() { if (coords.length) mapRef.current?.setCameraToCoordinates( coords, - { top: 0, left: 0, bottom: 0, right: 0 }, + { top: 50, left: 50, bottom: 50, right: 50 }, true, 300 ); diff --git a/example/src/types/navigation.ts b/example/src/types/navigation.ts new file mode 100644 index 0000000..0cdaa46 --- /dev/null +++ b/example/src/types/navigation.ts @@ -0,0 +1,21 @@ +export type RootStackParamList = { + Home: undefined; + Blank: undefined; + BasicMap: undefined; + Markers: undefined; + Polygons: undefined; + Polylines: undefined; + Circles: undefined; + Heatmap: undefined; + KmlLayer: undefined; + Location: undefined; + CustomStyle: undefined; + IndoorLevelMap: undefined; + Camera: undefined; + Snapshot: undefined; + StressTest: undefined; +}; + +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; + +export type RootNavigationProp = NativeStackNavigationProp; diff --git a/example/src/utils/mapGenerators.ts b/example/src/utils/mapGenerators.ts index f83e01b..af0720d 100644 --- a/example/src/utils/mapGenerators.ts +++ b/example/src/utils/mapGenerators.ts @@ -106,17 +106,22 @@ export const makeHeatmap = (id: number): RNHeatmap => ({ opacity: 1, }); -export const makeMarker = (id: number): RNMarker => ({ - id: id.toString(), - zIndex: id, - coordinate: randomCoordinates(37.7749, -122.4194, 0.2), - anchor: { x: 0.5, y: 1.0 }, - iconSvg: - id % 2 === 0 +export function makeMarker(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, + 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, -}); + }; +} diff --git a/ios/.swiftlint.yml b/ios/.swiftlint.yml index 8d80b0f..a84aed4 100644 --- a/ios/.swiftlint.yml +++ b/ios/.swiftlint.yml @@ -4,7 +4,6 @@ disabled_rules: - cyclomatic_complexity - function_body_length - closure_parameter_position - - todo identifier_name: min_length: diff --git a/ios/GoogleMapViewImpl.swift b/ios/GoogleMapViewImpl.swift index a800ebe..4fcedc2 100644 --- a/ios/GoogleMapViewImpl.swift +++ b/ios/GoogleMapViewImpl.swift @@ -3,7 +3,8 @@ import GoogleMaps import GoogleMapsUtils import UIKit -final class GoogleMapsViewImpl: UIView, GMSMapViewDelegate { +final class GoogleMapsViewImpl: UIView, GMSMapViewDelegate, +GMSIndoorDisplayDelegate { private let locationHandler: LocationHandler private let markerBuilder: MapMarkerBuilder @@ -40,6 +41,7 @@ final class GoogleMapsViewImpl: UIView, GMSMapViewDelegate { setupAppLifecycleObservers() } + @MainActor private func setupAppLifecycleObservers() { NotificationCenter.default.addObserver( self, @@ -81,6 +83,7 @@ final class GoogleMapsViewImpl: UIView, GMSMapViewDelegate { mapReady = true } + @MainActor private func initLocationCallbacks() { locationHandler.onUpdate = { [weak self] loc in guard let self = self else { return } @@ -136,7 +139,10 @@ final class GoogleMapsViewImpl: UIView, GMSMapViewDelegate { myLocationEnabled.map { mapView?.isMyLocationEnabled = $0 } buildingEnabled.map { mapView?.isBuildingsEnabled = $0 } trafficEnabled.map { mapView?.isTrafficEnabled = $0 } - indoorEnabled.map { mapView?.isIndoorEnabled = $0 } + indoorEnabled.map { + mapView?.isIndoorEnabled = $0 + mapView?.indoorDisplay.delegate = $0 == true ? self : nil + } customMapStyle.map { mapView?.mapStyle = $0 } mapType.map { mapView?.mapType = $0 } userInterfaceStyle.map { mapView?.overrideUserInterfaceStyle = $0 } @@ -187,10 +193,16 @@ final class GoogleMapsViewImpl: UIView, GMSMapViewDelegate { } } + @MainActor var currentCamera: GMSCameraPosition? { mapView?.camera } + @MainActor + var initialProps: RNInitialProps? { + didSet {} + } + @MainActor var uiSettings: RNMapUiSettings? { didSet { @@ -236,6 +248,7 @@ final class GoogleMapsViewImpl: UIView, GMSMapViewDelegate { var indoorEnabled: Bool? { didSet { mapView?.isIndoorEnabled = indoorEnabled ?? false + mapView?.indoorDisplay.delegate = indoorEnabled == true ? self : nil } } @@ -263,7 +276,8 @@ final class GoogleMapsViewImpl: UIView, GMSMapViewDelegate { } } - @MainActor var mapPadding: RNMapPadding? { + @MainActor + var mapPadding: RNMapPadding? { didSet { mapView?.padding = mapPadding.map { @@ -277,13 +291,15 @@ final class GoogleMapsViewImpl: UIView, GMSMapViewDelegate { } } - @MainActor var mapType: GMSMapViewType? { + @MainActor + var mapType: GMSMapViewType? { didSet { mapView?.mapType = mapType ?? .normal } } - @MainActor var locationConfig: RNLocationConfig? { + @MainActor + var locationConfig: RNLocationConfig? { didSet { locationHandler.desiredAccuracy = locationConfig?.ios?.desiredAccuracy?.toCLLocationAccuracy @@ -297,19 +313,25 @@ final class GoogleMapsViewImpl: UIView, GMSMapViewDelegate { var onLocationUpdate: ((RNLocation) -> Void)? var onLocationError: ((_ error: RNLocationErrorCode) -> Void)? var onMapPress: ((RNLatLng) -> Void)? - var onMarkerPress: ((String) -> Void)? - var onPolylinePress: ((String) -> Void)? - var onPolygonPress: ((String) -> Void)? - var onCirclePress: ((String) -> Void)? + var onMarkerPress: ((String?) -> Void)? + var onPolylinePress: ((String?) -> Void)? + var onPolygonPress: ((String?) -> Void)? + var onCirclePress: ((String?) -> Void)? + var onMarkerDragStart: ((String?, RNLatLng) -> Void)? + var onMarkerDrag: ((String?, RNLatLng) -> Void)? + var onMarkerDragEnd: ((String?, RNLatLng) -> Void)? + var onIndoorBuildingFocused: ((RNIndoorBuilding) -> Void)? + var onIndoorLevelActivated: ((RNIndoorLevel) -> Void)? var onCameraChangeStart: ((RNRegion, RNCamera, Bool) -> Void)? var onCameraChange: ((RNRegion, RNCamera, Bool) -> Void)? var onCameraChangeComplete: ((RNRegion, RNCamera, Bool) -> Void)? - func setCamera(camera: GMSCameraPosition, animated: Bool, durationMS: Double) { + @MainActor + func setCamera(camera: GMSCameraPosition, animated: Bool, durationMs: Double) { if animated { withCATransaction( disableActions: false, - duration: durationMS / 1000.0 + duration: durationMs / 1000.0 ) { mapView?.animate(to: camera) } @@ -319,11 +341,12 @@ final class GoogleMapsViewImpl: UIView, GMSMapViewDelegate { } } + @MainActor func setCameraToCoordinates( coordinates: [RNLatLng], padding: RNMapPadding, animated: Bool, - durationMS: Double + durationMs: Double ) { if coordinates.isEmpty { return @@ -359,7 +382,7 @@ final class GoogleMapsViewImpl: UIView, GMSMapViewDelegate { if animated { withCATransaction( disableActions: false, - duration: durationMS / 1000.0 + duration: durationMs / 1000.0 ) { mapView?.animate(with: update) } @@ -368,6 +391,90 @@ final class GoogleMapsViewImpl: UIView, GMSMapViewDelegate { } } + @MainActor + func setCameraBounds(_ bounds: GMSCoordinateBounds?) { + mapView?.cameraTargetBounds = bounds + } + + @MainActor + func animateToBounds( + _ bounds: GMSCoordinateBounds, + padding: Double, + durationMs: Double, + lockBounds: Bool + ) { + if lockBounds { + mapView?.cameraTargetBounds = bounds + } + + let update = GMSCameraUpdate.fit(bounds, withPadding: CGFloat(padding)) + mapView?.animate(with: update) + } + + @MainActor + func snapshot( + size: CGSize?, + format: String, + imageFormat: ImageFormat, + quality: CGFloat, + resultIsFile: Bool + ) -> NitroModules.Promise { + let promise = Promise() + + DispatchQueue.main.async { + guard let mapView = self.mapView else { + promise.resolve(withResult: nil) + return + } + + let renderer = UIGraphicsImageRenderer(bounds: mapView.bounds) + let image = renderer.image { ctx in + mapView.layer.render(in: ctx.cgContext) + } + + var finalImage = image + + size.map { + UIGraphicsBeginImageContextWithOptions($0, false, 0.0) + image.draw(in: CGRect(origin: .zero, size: $0)) + finalImage = UIGraphicsGetImageFromCurrentImageContext() ?? image + UIGraphicsEndImageContext() + } + + let data: Data? + switch imageFormat { + case .jpeg: + data = finalImage.jpegData(compressionQuality: quality) + case .png: + data = finalImage.pngData() + } + + guard let imageData = data else { + promise.resolve(withResult: nil) + return + } + + // Rückgabe + if resultIsFile { + let filename = + "map_snapshot_\(Int(Date().timeIntervalSince1970)).\(format)" + let fileURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(filename) + do { + try imageData.write(to: fileURL) + promise.resolve(withResult: fileURL.path) + } catch { + promise.resolve(withResult: nil) + } + } else { + let base64 = imageData.base64EncodedString() + promise.resolve(withResult: "data:image/\(format);base64,\(base64)") + } + } + + return promise + } + @MainActor func addMarker(id: String, marker: GMSMarker) { if mapView == nil { @@ -527,7 +634,10 @@ final class GoogleMapsViewImpl: UIView, GMSMapViewDelegate { @MainActor func clearHeatmaps() { - heatmapsById.values.forEach { $0.map = nil } + heatmapsById.values.forEach { + $0.clearTileCache() + $0.map = nil + } heatmapsById.removeAll() pendingHeatmaps.removeAll() } @@ -569,16 +679,21 @@ final class GoogleMapsViewImpl: UIView, GMSMapViewDelegate { } func deinitInternal() { - markerBuilder.cancelAllIconTasks() - clearMarkers() - clearPolylines() - clearPolygons() - clearCircles() - clearHeatmaps() - locationHandler.stop() - mapView?.clear() - mapView?.delegate = nil - mapView = nil + onMain { + self.locationHandler.stop() + self.markerBuilder.cancelAllIconTasks() + self.clearMarkers() + self.clearPolylines() + self.clearPolygons() + self.clearCircles() + self.clearHeatmaps() + self.clearKmlLayers() + self.mapView?.clear() + self.mapView?.indoorDisplay.delegate = nil + self.mapView?.delegate = nil + self.mapView = nil + self.initialized = false + } } @objc private func appDidBecomeActive() { @@ -594,9 +709,6 @@ final class GoogleMapsViewImpl: UIView, GMSMapViewDelegate { override func didMoveToWindow() { super.didMoveToWindow() if window != nil { - if mapView != nil && mapReady { - onMapReady?(true) - } locationHandler.start() } else { locationHandler.stop() @@ -609,140 +721,215 @@ final class GoogleMapsViewImpl: UIView, GMSMapViewDelegate { } func mapView(_ mapView: GMSMapView, willMove gesture: Bool) { - let visibleRegion = mapView.projection.visibleRegion() - let bounds = GMSCoordinateBounds(region: visibleRegion) - - let center = CLLocationCoordinate2D( - latitude: (bounds.northEast.latitude + bounds.southWest.latitude) / 2.0, - longitude: (bounds.northEast.longitude + bounds.southWest.longitude) / 2.0 - ) + onMain { + let visibleRegion = mapView.projection.visibleRegion() + let bounds = GMSCoordinateBounds(region: visibleRegion) + + let center = CLLocationCoordinate2D( + latitude: (bounds.northEast.latitude + bounds.southWest.latitude) / 2.0, + longitude: (bounds.northEast.longitude + bounds.southWest.longitude) + / 2.0 + ) - let latDelta = bounds.northEast.latitude - bounds.southWest.latitude - let lngDelta = bounds.northEast.longitude - bounds.southWest.longitude + let latDelta = bounds.northEast.latitude - bounds.southWest.latitude + let lngDelta = bounds.northEast.longitude - bounds.southWest.longitude - let cp = mapView.camera - let region = RNRegion( - center: RNLatLng(center.latitude, center.longitude), - latitudeDelta: latDelta, - longitudeDelta: lngDelta - ) - let cam = RNCamera( - center: RNLatLng( - latitude: cp.target.latitude, - longitude: cp.target.longitude - ), - zoom: Double(cp.zoom), - bearing: cp.bearing, - tilt: cp.viewingAngle - ) - cameraMoveReasonIsGesture = gesture + let cp = mapView.camera + let region = RNRegion( + center: RNLatLng(center.latitude, center.longitude), + latitudeDelta: latDelta, + longitudeDelta: lngDelta + ) + let cam = RNCamera( + center: RNLatLng( + latitude: cp.target.latitude, + longitude: cp.target.longitude + ), + zoom: Double(cp.zoom), + bearing: cp.bearing, + tilt: cp.viewingAngle + ) + self.cameraMoveReasonIsGesture = gesture - onCameraChangeStart?(region, cam, gesture) + self.onCameraChangeStart?(region, cam, gesture) + } } func mapView(_ mapView: GMSMapView, didChange position: GMSCameraPosition) { - if let last = lastSubmittedCameraPosition, - last.target.latitude == position.target.latitude, - last.target.longitude == position.target.longitude, - last.zoom == position.zoom, - last.bearing == position.bearing, - last.viewingAngle == position.viewingAngle { - return - } - let visibleRegion = mapView.projection.visibleRegion() - let bounds = GMSCoordinateBounds(region: visibleRegion) + onMain { + if let last = self.lastSubmittedCameraPosition, + last.target.latitude == position.target.latitude, + last.target.longitude == position.target.longitude, + last.zoom == position.zoom, + last.bearing == position.bearing, + last.viewingAngle == position.viewingAngle { + return + } - let center = CLLocationCoordinate2D( - latitude: (bounds.northEast.latitude + bounds.southWest.latitude) / 2.0, - longitude: (bounds.northEast.longitude + bounds.southWest.longitude) / 2.0 - ) + self.lastSubmittedCameraPosition = position + let visibleRegion = mapView.projection.visibleRegion() + let bounds = GMSCoordinateBounds(region: visibleRegion) - let latDelta = bounds.northEast.latitude - bounds.southWest.latitude - let lngDelta = bounds.northEast.longitude - bounds.southWest.longitude + let center = CLLocationCoordinate2D( + latitude: (bounds.northEast.latitude + bounds.southWest.latitude) / 2.0, + longitude: (bounds.northEast.longitude + bounds.southWest.longitude) + / 2.0 + ) - let cp = mapView.camera - let region = RNRegion( - center: RNLatLng(center.latitude, center.longitude), - latitudeDelta: latDelta, - longitudeDelta: lngDelta - ) - let cam = RNCamera( - center: RNLatLng( - latitude: cp.target.latitude, - longitude: cp.target.longitude - ), - zoom: Double(cp.zoom), - bearing: cp.bearing, - tilt: cp.viewingAngle - ) - onCameraChange?(region, cam, cameraMoveReasonIsGesture) - lastSubmittedCameraPosition = position + let latDelta = bounds.northEast.latitude - bounds.southWest.latitude + let lngDelta = bounds.northEast.longitude - bounds.southWest.longitude + + let cp = mapView.camera + let region = RNRegion( + center: RNLatLng(center.latitude, center.longitude), + latitudeDelta: latDelta, + longitudeDelta: lngDelta + ) + let cam = RNCamera( + center: RNLatLng( + latitude: cp.target.latitude, + longitude: cp.target.longitude + ), + zoom: Double(cp.zoom), + bearing: cp.bearing, + tilt: cp.viewingAngle + ) + self.onCameraChange?(region, cam, self.cameraMoveReasonIsGesture) + } } func mapView(_ mapView: GMSMapView, idleAt position: GMSCameraPosition) { - let visibleRegion = mapView.projection.visibleRegion() - let bounds = GMSCoordinateBounds(region: visibleRegion) - - let center = CLLocationCoordinate2D( - latitude: (bounds.northEast.latitude + bounds.southWest.latitude) / 2.0, - longitude: (bounds.northEast.longitude + bounds.southWest.longitude) / 2.0 - ) + onMain { + let visibleRegion = mapView.projection.visibleRegion() + let bounds = GMSCoordinateBounds(region: visibleRegion) + + let center = CLLocationCoordinate2D( + latitude: (bounds.northEast.latitude + bounds.southWest.latitude) / 2.0, + longitude: (bounds.northEast.longitude + bounds.southWest.longitude) + / 2.0 + ) - let latDelta = bounds.northEast.latitude - bounds.southWest.latitude - let lngDelta = bounds.northEast.longitude - bounds.southWest.longitude + let latDelta = bounds.northEast.latitude - bounds.southWest.latitude + let lngDelta = bounds.northEast.longitude - bounds.southWest.longitude - let cp = mapView.camera - let region = RNRegion( - center: RNLatLng(center.latitude, center.longitude), - latitudeDelta: latDelta, - longitudeDelta: lngDelta - ) - let cam = RNCamera( - center: RNLatLng( - latitude: cp.target.latitude, - longitude: cp.target.longitude - ), - zoom: Double(cp.zoom), - bearing: cp.bearing, - tilt: cp.viewingAngle - ) - onCameraChangeComplete?(region, cam, cameraMoveReasonIsGesture) + let cp = mapView.camera + let region = RNRegion( + center: RNLatLng(center.latitude, center.longitude), + latitudeDelta: latDelta, + longitudeDelta: lngDelta + ) + let cam = RNCamera( + center: RNLatLng( + latitude: cp.target.latitude, + longitude: cp.target.longitude + ), + zoom: Double(cp.zoom), + bearing: cp.bearing, + tilt: cp.viewingAngle + ) + self.onCameraChangeComplete?(region, cam, self.cameraMoveReasonIsGesture) + } } func mapView( _ mapView: GMSMapView, didTapAt coordinate: CLLocationCoordinate2D ) { - onMapPress?( - RNLatLng( - latitude: coordinate.latitude, - longitude: coordinate.longitude + onMain { + self.onMapPress?( + RNLatLng( + latitude: coordinate.latitude, + longitude: coordinate.longitude + ) ) - ) + } } func mapView(_ mapView: GMSMapView, didTap marker: GMSMarker) -> Bool { - let id = (marker.userData as? String) ?? "unknown" - onMarkerPress?(id) + onMain { + mapView.selectedMarker = marker + self.onMarkerPress?(marker.userData as? String, ) + } return true } func mapView(_ mapView: GMSMapView, didTap overlay: GMSOverlay) { - switch overlay { - case let circle as GMSCircle: - let id = (circle.userData as? String) ?? "unknown" - onCirclePress?(id) + onMain { + switch overlay { + case let circle as GMSCircle: + self.onCirclePress?(circle.userData as? String, ) - case let polygon as GMSPolygon: - let id = (polygon.userData as? String) ?? "unknown" - onPolygonPress?(id) + case let polygon as GMSPolygon: + self.onPolygonPress?(polygon.userData as? String, ) - case let polyline as GMSPolyline: - let id = (polyline.userData as? String) ?? "unknown" - onPolylinePress?(id) + case let polyline as GMSPolyline: + self.onPolylinePress?(polyline.userData as? String, ) - default: - break + default: + break + } + } + } + + func mapView(_ mapView: GMSMapView, didBeginDragging marker: GMSMarker) { + onMain { + self.onMarkerDragStart?( + marker.userData as? String, + RNLatLng(marker.position.latitude, marker.position.longitude) + ) + } + } + + func mapView(_ mapView: GMSMapView, didDrag marker: GMSMarker) { + onMain { + self.onMarkerDrag?( + marker.userData as? String, + RNLatLng(marker.position.latitude, marker.position.longitude) + ) + } + } + + func mapView(_ mapView: GMSMapView, didEndDragging marker: GMSMarker) { + onMain { + self.onMarkerDragEnd?( + marker.userData as? String, + RNLatLng(marker.position.latitude, marker.position.longitude) + ) } } + + func didChangeActiveBuilding(_ building: GMSIndoorBuilding?) { + onMain { + guard let display = self.mapView?.indoorDisplay, let building else { + return + } + self.onIndoorBuildingFocused?(building.toRNIndoorBuilding(from: display)) + } + } + + func didChangeActiveLevel(_ level: GMSIndoorLevel?) { + onMain { + guard + let display = self.mapView?.indoorDisplay, + let building = display.activeBuilding, + let level, + let index = building.levels.firstIndex(where: { + $0.name == level.name && $0.shortName == level.shortName + }) + else { return } + + self.onIndoorLevelActivated?( + level.toRNIndoorLevel(index: index, active: true) + ) + } + } +} + +@inline(__always) +func onMain(_ block: @escaping () -> Void) { + if Thread.isMainThread { + block() + } else { + DispatchQueue.main.async { block() } + } } diff --git a/ios/MapMarkerBuilder.swift b/ios/MapMarkerBuilder.swift index 98e2fe6..d0e5803 100644 --- a/ios/MapMarkerBuilder.swift +++ b/ios/MapMarkerBuilder.swift @@ -21,11 +21,17 @@ final class MapMarkerBuilder { marker.userData = m.id marker.tracksViewChanges = true marker.icon = icon - marker.groundAnchor = CGPoint( - x: m.anchor?.x ?? 0.5, - y: m.anchor?.y ?? 0.5 - ) - + m.title.map { marker.title = $0 } + m.snippet.map { marker.snippet = $0 } + m.opacity.map { marker.iconView?.alpha = CGFloat($0) } + m.flat.map { marker.isFlat = $0 } + m.draggable.map { marker.isDraggable = $0 } + m.anchor.map { + marker.groundAnchor = CGPoint( + x: $0.x, + y: $0.y + ) + } m.zIndex.map { marker.zIndex = Int32($0) } DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak marker] in @@ -42,13 +48,16 @@ final class MapMarkerBuilder { longitude: next.coordinate.longitude ) + m.title = next.title + m.snippet = next.snippet + m.iconView?.alpha = CGFloat(next.opacity ?? 0) + m.isFlat = next.flat ?? false + m.isDraggable = next.draggable ?? false m.zIndex = Int32(next.zIndex ?? 0) - m.groundAnchor = CGPoint( x: next.anchor?.x ?? 0.5, - y: next.anchor?.y ?? 0.5 + y: next.anchor?.y ?? 1 ) - if !prev.markerStyleEquals(next) { buildIconAsync(next.id, next) { img in m.tracksViewChanges = true diff --git a/ios/RNGoogleMapsPlusView.swift b/ios/RNGoogleMapsPlusView.swift index 49c498f..13b7e87 100644 --- a/ios/RNGoogleMapsPlusView.swift +++ b/ios/RNGoogleMapsPlusView.swift @@ -8,6 +8,7 @@ final class RNGoogleMapsPlusView: HybridRNGoogleMapsPlusViewSpec { private let permissionHandler: PermissionHandler private let locationHandler: LocationHandler + private var propsInitialized = false private let markerBuilder = MapMarkerBuilder() private let polylineBuilder = MapPolylineBuilder() private let polygonBuilder = MapPolygonBuilder() @@ -29,21 +30,23 @@ final class RNGoogleMapsPlusView: HybridRNGoogleMapsPlusViewSpec { ) } - /* - /// TODO: prepareForRecycle - override func prepareForRecycle() { - impl.clearAll() - } - */ + func afterUpdate() { + if !propsInitialized { + propsInitialized = true + Task { @MainActor in + impl.initMapView( + mapId: self.initialProps?.mapId, + liteMode: self.initialProps?.liteMode, + camera: self.initialProps?.camera?.toGMSCameraPosition(current: nil) + ) + } + } + } @MainActor var initialProps: RNInitialProps? { didSet { - impl.initMapView( - mapId: initialProps?.mapId, - liteMode: initialProps?.liteMode, - camera: initialProps?.camera?.toGMSCameraPosition(current: nil) - ) + impl.initialProps = initialProps } } @@ -277,100 +280,160 @@ final class RNGoogleMapsPlusView: HybridRNGoogleMapsPlusViewSpec { } } - @MainActor var locationConfig: RNLocationConfig? { + @MainActor + var locationConfig: RNLocationConfig? { didSet { impl.locationConfig = locationConfig } } + @MainActor var onMapError: ((RNMapErrorCode) -> Void)? { didSet { impl.onMapError = onMapError } } + @MainActor var onMapReady: ((Bool) -> Void)? { didSet { impl.onMapReady = onMapReady } } + @MainActor var onLocationUpdate: ((RNLocation) -> Void)? { didSet { impl.onLocationUpdate = onLocationUpdate } } + @MainActor var onLocationError: ((_ error: RNLocationErrorCode) -> Void)? { didSet { impl.onLocationError = onLocationError } } + @MainActor var onMapPress: ((RNLatLng) -> Void)? { didSet { impl.onMapPress = onMapPress } } - var onMarkerPress: ((String) -> Void)? { + @MainActor + var onMarkerPress: ((String?) -> Void)? { didSet { impl.onMarkerPress = onMarkerPress } } - var onPolylinePress: ((String) -> Void)? { + @MainActor + var onPolylinePress: ((String?) -> Void)? { didSet { impl.onPolylinePress = onPolylinePress } } - var onPolygonPress: ((String) -> Void)? { + @MainActor + var onPolygonPress: ((String?) -> Void)? { didSet { impl.onPolygonPress = onPolygonPress } } - var onCirclePress: ((String) -> Void)? { + @MainActor + var onCirclePress: ((String?) -> Void)? { didSet { impl.onCirclePress = onCirclePress } } + @MainActor + var onMarkerDragStart: ((String?, RNLatLng) -> Void)? { + didSet { impl.onMarkerDragStart = onMarkerDragStart } + } + @MainActor + var onMarkerDrag: ((String?, RNLatLng) -> Void)? { + didSet { impl.onMarkerDrag = onMarkerDrag } + } + @MainActor + var onMarkerDragEnd: ((String?, RNLatLng) -> Void)? { + didSet { impl.onMarkerDragEnd = onMarkerDragEnd } + } + @MainActor + var onIndoorBuildingFocused: ((RNIndoorBuilding) -> Void)? { + didSet { impl.onIndoorBuildingFocused = onIndoorBuildingFocused } + } + @MainActor + var onIndoorLevelActivated: ((RNIndoorLevel) -> Void)? { + didSet { impl.onIndoorLevelActivated = onIndoorLevelActivated } + } + @MainActor var onCameraChangeStart: ((RNRegion, RNCamera, Bool) -> Void)? { didSet { impl.onCameraChangeStart = onCameraChangeStart } } + @MainActor var onCameraChange: ((RNRegion, RNCamera, Bool) -> Void)? { didSet { impl.onCameraChange = onCameraChange } } + @MainActor var onCameraChangeComplete: ((RNRegion, RNCamera, Bool) -> Void)? { didSet { impl.onCameraChangeComplete = onCameraChangeComplete } } - func setCamera(camera: RNCamera, animated: Bool?, durationMS: Double?) { + @MainActor + func setCamera(camera: RNCamera, animated: Bool?, durationMs: Double?) { let cam = camera.toGMSCameraPosition(current: impl.currentCamera) - onMain { - self.impl.setCamera( - camera: cam, - animated: animated ?? true, - durationMS: durationMS ?? 3000 - ) - } + impl.setCamera( + camera: cam, + animated: animated ?? true, + durationMs: durationMs ?? 3000 + ) } + @MainActor func setCameraToCoordinates( coordinates: [RNLatLng], padding: RNMapPadding?, animated: Bool?, - durationMS: Double? + durationMs: Double? ) { - onMain { - self.impl.setCameraToCoordinates( - coordinates: coordinates, - padding: padding ?? RNMapPadding(0, 0, 0, 0), - animated: animated ?? true, - durationMS: durationMS ?? 3000 - ) - } + impl.setCameraToCoordinates( + coordinates: coordinates, + padding: padding ?? RNMapPadding(0, 0, 0, 0), + animated: animated ?? true, + durationMs: durationMs ?? 3000 + ) } + @MainActor + func setCameraBounds(bounds: RNLatLngBounds?) { + impl.setCameraBounds(bounds?.toCoordinateBounds()) + } + + @MainActor + func animateToBounds( + bounds: RNLatLngBounds, + padding: Double?, + durationMs: Double?, + lockBounds: Bool? + ) { + impl.animateToBounds( + bounds.toCoordinateBounds(), + padding: padding ?? 0, + durationMs: durationMs ?? 3000, + lockBounds: false + ) + } + + @MainActor + func snapshot( + options: RNSnapshotOptions, + ) -> NitroModules.Promise { + return impl.snapshot( + size: options.size?.toCGSize(), + format: options.format.toFileExtension(), + imageFormat: options.format.toImageFormat(), + quality: CGFloat(options.quality), + resultIsFile: options.resultType.isFileResult() + ) + + } + + @MainActor func showLocationDialog() { locationHandler.showLocationDialog() } + @MainActor func openLocationSettings() { locationHandler.openLocationSettings() } + @MainActor func requestLocationPermission() -> NitroModules.Promise { return permissionHandler.requestLocationPermission() } + @MainActor func isGooglePlayServicesAvailable() -> Bool { /// not supported return true } } - -@inline(__always) -func onMain(_ block: @escaping () -> Void) { - if Thread.isMainThread { - block() - } else { - DispatchQueue.main.async { block() } - } -} diff --git a/ios/extensions/IndoorBuilding+Extension.swift b/ios/extensions/IndoorBuilding+Extension.swift new file mode 100644 index 0000000..87620b5 --- /dev/null +++ b/ios/extensions/IndoorBuilding+Extension.swift @@ -0,0 +1,33 @@ +import GoogleMaps +import NitroModules + +extension GMSIndoorLevel { + func toRNIndoorLevel(index: Int, active: Bool) -> RNIndoorLevel { + RNIndoorLevel( + index: Double(index), + name: self.name, + shortName: self.shortName, + active: active + ) + } +} + +extension GMSIndoorBuilding { + func toRNIndoorBuilding(from indoorDisplay: GMSIndoorDisplay) + -> RNIndoorBuilding { + let activeLevel = indoorDisplay.activeLevel + let levels = self.levels.enumerated().map { + (index, level) -> RNIndoorLevel in + let isActive = (level == activeLevel) + return level.toRNIndoorLevel(index: index, active: isActive) + } + let activeIndex = self.levels.firstIndex(where: { $0 == activeLevel }) + + return RNIndoorBuilding( + activeLevelIndex: activeIndex.map { Double($0) } ?? nil, + defaultLevelIndex: nil, + levels: levels, + underground: self.isUnderground + ) + } +} diff --git a/ios/extensions/RNLatLngBounds+Extension.swift b/ios/extensions/RNLatLngBounds+Extension.swift new file mode 100644 index 0000000..2d5ca16 --- /dev/null +++ b/ios/extensions/RNLatLngBounds+Extension.swift @@ -0,0 +1,16 @@ +import GoogleMaps + +extension RNLatLngBounds { + func toCoordinateBounds() -> GMSCoordinateBounds { + return GMSCoordinateBounds( + coordinate: CLLocationCoordinate2D( + latitude: southWest.latitude, + longitude: southWest.longitude + ), + coordinate: CLLocationCoordinate2D( + latitude: northEast.latitude, + longitude: northEast.longitude + ) + ) + } +} diff --git a/ios/extensions/RNSize+Extension.swift b/ios/extensions/RNSize+Extension.swift new file mode 100644 index 0000000..723509e --- /dev/null +++ b/ios/extensions/RNSize+Extension.swift @@ -0,0 +1,7 @@ +import UIKit + +extension RNSize { + func toCGSize() -> CGSize? { + CGSize(width: width, height: height) + } +} diff --git a/ios/extensions/RNSnapshotFormat+Extension.swift b/ios/extensions/RNSnapshotFormat+Extension.swift new file mode 100644 index 0000000..a6afda7 --- /dev/null +++ b/ios/extensions/RNSnapshotFormat+Extension.swift @@ -0,0 +1,28 @@ +enum ImageFormat { + case png + case jpeg +} + +extension RNSnapshotFormat { + func toImageFormat() -> ImageFormat { + switch self { + case .jpg, .jpeg: + return .jpeg + case .png: + return .png + @unknown default: + return .png + } + } + + func toFileExtension() -> String { + switch self { + case .jpg, .jpeg: + return "jpg" + case .png: + return "png" + @unknown default: + return "png" + } + } +} diff --git a/ios/extensions/RNSnapshotResultType+Extension.swift b/ios/extensions/RNSnapshotResultType+Extension.swift new file mode 100644 index 0000000..61a3e88 --- /dev/null +++ b/ios/extensions/RNSnapshotResultType+Extension.swift @@ -0,0 +1,12 @@ +extension RNSnapshotResultType { + func isFileResult() -> Bool { + switch self { + case .file: + return true + case .base64: + return false + @unknown default: + return false + } + } +} diff --git a/release.config.cjs b/release.config.cjs index bfb8b1b..5333499 100644 --- a/release.config.cjs +++ b/release.config.cjs @@ -7,6 +7,20 @@ const rules = [ { type: 'chore', release: false, title: '🛠️ Other changes' }, ]; +const isDev = + process.env.BRANCH_NAME === 'dev' || process.env.GITHUB_REF_NAME === 'dev'; + +const gitPlugin = isDev + ? false + : [ + '@semantic-release/git', + { + assets: ['package.json', 'CHANGELOG.md', 'example/package.json'], + message: + '🔖 release: ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}', + }, + ]; + const sortMap = Object.fromEntries( rules.map((rule, index) => [rule.title, index]) ); @@ -67,13 +81,6 @@ module.exports = { ], }, ], - [ - '@semantic-release/git', - { - assets: ['package.json', 'CHANGELOG.md', 'example/package.json'], - message: - '🔖 release: ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}', - }, - ], + ...(gitPlugin ? [gitPlugin] : []), ], }; diff --git a/scripts/nitrogen-patch.js b/scripts/nitrogen-patch.js index ce7832f..c4d5353 100644 --- a/scripts/nitrogen-patch.js +++ b/scripts/nitrogen-patch.js @@ -1,24 +1,44 @@ /** - * Recursively patches all generated Android files: + * Recursively patches all generated Nitro files (Android & iOS): + * + * ANDROID * - Replaces 'com.margelo.nitro.rngooglemapsplus' -> 'com.rngooglemapsplus' * - Replaces 'com/margelo/nitro/rngooglemapsplus' -> 'com/rngooglemapsplus' * - Removes 'margelo/nitro/' in RNGoogleMapsPlusOnLoad.cpp - * - Inserts `prepareToRecycleView()` under `onDropViewInstance()` if missing + * - Inserts `prepareToRecycleView()` + * nitrogen/generated/android/kotlin/com/margelo/nitro/rngooglemapsplus/views/HybridRNGoogleMapsPlusViewManager.kt + * + * iOS + * - Inserts `+ (BOOL)shouldBeRecycled` + * nitrogen/generated/ios/c++/views/HybridRNGoogleMapsPlusViewComponent.mm */ import { fileURLToPath } from 'url'; import { basename } from 'path'; import path from 'node:path'; import { readdir, readFile, writeFile } from 'node:fs/promises'; -const ROOT_DIR = path.join(process.cwd(), 'nitrogen', 'generated', 'android'); -console.log(ROOT_DIR); -const ANDROID_ONLOAD_FILE = path.join(ROOT_DIR, 'RNGoogleMapsPlusOnLoad.cpp'); +const ROOT_ANDROID = path.join( + process.cwd(), + 'nitrogen', + 'generated', + 'android' +); +const ROOT_IOS = path.join(process.cwd(), 'nitrogen', 'generated', 'ios'); +const ANDROID_ONLOAD_FILE = path.join( + ROOT_ANDROID, + 'RNGoogleMapsPlusOnLoad.cpp' +); const HYBRID_VIEW_MANAGER = path.join( - ROOT_DIR, + ROOT_ANDROID, 'kotlin/com/margelo/nitro/rngooglemapsplus/views/HybridRNGoogleMapsPlusViewManager.kt' ); +const HYBRID_VIEW_COMPONENT_IOS = path.join( + ROOT_IOS, + 'c++/views/HybridRNGoogleMapsPlusViewComponent.mm' +); + const REPLACEMENTS = [ { regex: /com\.margelo\.nitro\.rngooglemapsplus/g, @@ -33,14 +53,21 @@ const REPLACEMENTS = [ const __filename = fileURLToPath(import.meta.url); const filename = basename(__filename); -const RECYCLE_METHOD = ` +const RECYCLE_METHOD_ANDROID = ` /// added by ${filename} override fun prepareToRecycleView(reactContext: ThemedReactContext, view: View): View? { return null } `; -// Patch-Routine +const RECYCLE_METHOD_IOS = ` +/// added by ${filename} ++ (BOOL)shouldBeRecycled +{ + return NO; +} +`; + async function processFile(filePath) { let content = await readFile(filePath, 'utf8'); let updated = content; @@ -53,16 +80,30 @@ async function processFile(filePath) { updated = updated.replace(/margelo\/nitro\//g, ''); } - console.log(filePath); if (path.resolve(filePath) === path.resolve(HYBRID_VIEW_MANAGER)) { if (!/override fun prepareToRecycleView/.test(updated)) { const pattern = /(override fun onDropViewInstance\(view: View\)\s*\{[^}]+\}\s*)/m; if (pattern.test(updated)) { - updated = updated.replace(pattern, `$1${RECYCLE_METHOD}\n`); + updated = updated.replace(pattern, `$1${RECYCLE_METHOD_ANDROID}\n`); + } else { + throw new Error( + `Pattern for "onDropViewInstance" not found in ${filePath}` + ); + } + } + } + + if (path.resolve(filePath) === path.resolve(HYBRID_VIEW_COMPONENT_IOS)) { + if (!/\+\s*\(BOOL\)\s*shouldBeRecycled/.test(updated)) { + const pattern = + /(- \(instancetype\)\s*init\s*\{(?:[^{}]|\{[^{}]*\})*\})/m; + + if (pattern.test(updated)) { + updated = updated.replace(pattern, `$1\n${RECYCLE_METHOD_IOS}`); } else { - updated = updated.replace(/}\s*$/m, `${RECYCLE_METHOD}\n}\n`); + throw new Error(`Pattern for "init" not found in ${filePath}`); } } } @@ -87,8 +128,9 @@ async function start(dir) { (async () => { try { - await start(ROOT_DIR); - console.log('All occurrences patched successfully.'); + await start(ROOT_ANDROID); + await start(ROOT_IOS); + console.log('All Nitrogen files patched successfully.'); } catch (err) { console.error('Error while processing files:', err); process.exit(1); diff --git a/src/RNGoogleMapsPlusView.nitro.ts b/src/RNGoogleMapsPlusView.nitro.ts index 07f104f..24e6cda 100644 --- a/src/RNGoogleMapsPlusView.nitro.ts +++ b/src/RNGoogleMapsPlusView.nitro.ts @@ -24,6 +24,10 @@ import type { RNMapZoomConfig, RNHeatmap, RNKMLayer, + RNIndoorBuilding, + RNIndoorLevel, + RNLatLngBounds, + RNSnapshotOptions, } from './types'; export interface RNGoogleMapsPlusViewProps extends HybridViewProps { @@ -50,10 +54,15 @@ export interface RNGoogleMapsPlusViewProps extends HybridViewProps { onLocationUpdate?: (location: RNLocation) => void; onLocationError?: (error: RNLocationErrorCode) => void; onMapPress?: (coordinate: RNLatLng) => void; - onMarkerPress?: (id: string) => void; - onPolylinePress?: (id: string) => void; - onPolygonPress?: (id: string) => void; - onCirclePress?: (id: string) => void; + onMarkerPress?: (id?: string | undefined) => void; + onPolylinePress?: (id?: string | undefined) => void; + onPolygonPress?: (id?: string | undefined) => void; + onCirclePress?: (id?: string | undefined) => void; + onMarkerDragStart?: (id: string | undefined, location: RNLatLng) => void; + onMarkerDrag?: (id: string | undefined, location: RNLatLng) => void; + onMarkerDragEnd?: (id: string | undefined, location: RNLatLng) => void; + onIndoorBuildingFocused?: (indoorBuilding: RNIndoorBuilding) => void; + onIndoorLevelActivated?: (indoorLevel: RNIndoorLevel) => void; onCameraChangeStart?: ( region: RNRegion, camera: RNCamera, @@ -72,15 +81,26 @@ export interface RNGoogleMapsPlusViewProps extends HybridViewProps { } export interface RNGoogleMapsPlusViewMethods extends HybridViewMethods { - setCamera(camera: RNCamera, animated?: boolean, durationMS?: number): void; + setCamera(camera: RNCamera, animated?: boolean, durationMs?: number): void; setCameraToCoordinates( coordinates: RNLatLng[], padding?: RNMapPadding, animated?: boolean, - durationMS?: number + durationMs?: number ): void; + setCameraBounds(bounds?: RNLatLngBounds): void; + + animateToBounds( + bounds: RNLatLngBounds, + padding?: number, + durationMs?: number, + lockBounds?: boolean + ): void; + + snapshot(options: RNSnapshotOptions): Promise; + showLocationDialog(): void; openLocationSettings(): void; diff --git a/src/types.ts b/src/types.ts index fedb7b8..3746c6e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,9 +23,31 @@ export type RNMapUiSettings = { zoomGesturesEnabled?: boolean; }; -export type RNLatLng = { latitude: number; longitude: number }; +export type RNLatLng = { + latitude: number; + longitude: number; +}; -export type RNBoundingBox = { northEast: RNLatLng; southWest: RNLatLng }; +export type RNLatLngBounds = { + northEast: RNLatLng; + southWest: RNLatLng; +}; + +export type RNSnapshotOptions = { + size?: RNSize; + format: RNSnapshotFormat; + quality: number; + resultType: RNSnapshotResultType; +}; + +export type RNSize = { + width: number; + height: number; +}; + +export type RNSnapshotFormat = 'png' | 'jpg' | 'jpeg'; + +export type RNSnapshotResultType = 'base64' | 'file'; export type RNMapPadding = { top: number; @@ -136,6 +158,12 @@ export type RNMarker = { zIndex?: number; coordinate: RNLatLng; anchor?: RNPosition; + showInfoWindow?: boolean; + title?: string; + snippet?: string; + opacity?: number; + flat?: boolean; + draggable?: boolean; iconSvg?: RNMarkerSvg; }; @@ -204,6 +232,20 @@ export type RNKMLayer = { kmlString: string; }; +export type RNIndoorBuilding = { + activeLevelIndex?: number; + defaultLevelIndex?: number; + levels: RNIndoorLevel[]; + underground?: boolean; +}; + +export type RNIndoorLevel = { + index: number; + name?: string; + shortName?: string; + active?: boolean; +}; + export type RNLocationConfig = { android?: RNAndroidLocationConfig; ios?: RNIOSLocationConfig;