diff --git a/README.md b/README.md index 9c8c9bc..90ff171 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![API Docs](https://img.shields.io/static/v1?label=typedoc&message=docs&color=informational)](https://pinpong.github.io/react-native-google-maps-plus) ![React Native](https://img.shields.io/badge/react--native-%3E%3D0.82.0-61dafb.svg?logo=react) -React Native wrapper for Android & iOS Google Maps SDK. +React Native wrapper for Android & iOS Google Maps SDK with Street View support ## Documentation diff --git a/android/src/main/java/com/rngooglemapsplus/GestureAwareFrameLayout.kt b/android/src/main/java/com/rngooglemapsplus/GestureAwareFrameLayout.kt new file mode 100644 index 0000000..b8718ee --- /dev/null +++ b/android/src/main/java/com/rngooglemapsplus/GestureAwareFrameLayout.kt @@ -0,0 +1,57 @@ +package com.rngooglemapsplus + +import android.content.Context +import android.view.MotionEvent +import android.widget.FrameLayout + +abstract class GestureAwareFrameLayout( + context: Context, +) : FrameLayout(context) { + private var parentTouchInterceptDisallowed = false + + protected abstract val panGestureEnabled: Boolean + protected abstract val multiTouchGestureEnabled: Boolean + protected open val gesturesSupported: Boolean = true + + protected fun setParentTouchInterceptDisallowed(blocked: Boolean) { + if (parentTouchInterceptDisallowed == blocked) return + parentTouchInterceptDisallowed = blocked + var p = parent + while (p != null) { + p.requestDisallowInterceptTouchEvent(blocked) + p = p.parent + } + } + + override fun dispatchTouchEvent(ev: MotionEvent): Boolean { + if (!gesturesSupported) return super.dispatchTouchEvent(ev) + + val anyGestureEnabled = panGestureEnabled || multiTouchGestureEnabled + if (!anyGestureEnabled) return super.dispatchTouchEvent(ev) + + when (ev.actionMasked) { + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_MOVE, + MotionEvent.ACTION_POINTER_DOWN, + -> { + val pointers = ev.pointerCount + val shouldBlockParent = pointers >= (if (panGestureEnabled) 1 else 2) + setParentTouchInterceptDisallowed(shouldBlockParent) + } + + MotionEvent.ACTION_POINTER_UP -> { + val pointers = ev.pointerCount - 1 + val shouldBlockParent = pointers >= (if (panGestureEnabled) 1 else 2) + setParentTouchInterceptDisallowed(shouldBlockParent) + } + + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL, + -> { + setParentTouchInterceptDisallowed(false) + } + } + + return super.dispatchTouchEvent(ev) + } +} diff --git a/android/src/main/java/com/rngooglemapsplus/GoogleMapsViewImpl.kt b/android/src/main/java/com/rngooglemapsplus/GoogleMapsViewImpl.kt index c52c259..1673773 100644 --- a/android/src/main/java/com/rngooglemapsplus/GoogleMapsViewImpl.kt +++ b/android/src/main/java/com/rngooglemapsplus/GoogleMapsViewImpl.kt @@ -10,9 +10,7 @@ import android.content.res.Configuration import android.graphics.Bitmap import android.location.Location import android.util.Size -import android.view.MotionEvent import android.view.View -import android.widget.FrameLayout import androidx.lifecycle.Lifecycle import androidx.lifecycle.findViewTreeLifecycleOwner import com.facebook.react.uimanager.PixelUtil.dpToPx @@ -63,7 +61,7 @@ class GoogleMapsViewImpl( private val playServiceHandler: PlayServicesHandler, private val markerBuilder: MapMarkerBuilder, private val mapErrorHandler: MapErrorHandler, -) : FrameLayout(reactContext), +) : GestureAwareFrameLayout(reactContext), GoogleMap.OnCameraMoveStartedListener, GoogleMap.OnCameraMoveListener, GoogleMap.OnCameraIdleListener, @@ -81,8 +79,10 @@ class GoogleMapsViewImpl( GoogleMap.OnInfoWindowLongClickListener, GoogleMap.OnMyLocationClickListener, GoogleMap.OnMyLocationButtonClickListener, - GoogleMap.InfoWindowAdapter { - private var lifecycleObserver: MapLifecycleEventObserver? = null + GoogleMap.OnMapLoadedCallback, + GoogleMap.InfoWindowAdapter, + ComponentCallbacks2 { + private var lifecycleObserver: ViewLifecycleEventObserver? = null private var lifecycle: Lifecycle? = null private var mapViewInitialized = false @@ -107,75 +107,11 @@ class GoogleMapsViewImpl( private val kmlLayersById = mutableMapOf() private val urlTileOverlaysById = mutableMapOf() - private var parentTouchInterceptDisallowed = false private var cameraMoveReason = -1 - val componentCallbacks = - object : ComponentCallbacks2 { - override fun onConfigurationChanged(newConfig: Configuration) {} - - override fun onLowMemory() { - mapView?.onLowMemory() - markerBuilder.clearIconCache() - } - - override fun onTrimMemory(level: Int) { - mapView?.onLowMemory() - markerBuilder.cancelAllJobs() - } - } - - private fun setParentTouchInterceptDisallowed(blocked: Boolean) { - if (parentTouchInterceptDisallowed == blocked) return - parentTouchInterceptDisallowed = blocked - var p = parent - while (p != null) { - p.requestDisallowInterceptTouchEvent(blocked) - p = p.parent - } - } - - override fun dispatchTouchEvent(ev: MotionEvent): Boolean { - if (googleMapsOptions.liteMode == true) return super.dispatchTouchEvent(ev) - - val panEnabled = uiSettings?.scrollEnabled == true - val zoomEnabled = uiSettings?.zoomGesturesEnabled == true - val rotateEnabled = uiSettings?.rotateEnabled == true - val tiltEnabled = uiSettings?.tiltEnabled == true - - val multiTouchEnabled = zoomEnabled || rotateEnabled || tiltEnabled - val anyMapGestureEnabled = panEnabled || multiTouchEnabled - if (!anyMapGestureEnabled) return super.dispatchTouchEvent(ev) - - when (ev.actionMasked) { - MotionEvent.ACTION_DOWN, - MotionEvent.ACTION_MOVE, - MotionEvent.ACTION_POINTER_DOWN, - -> { - val pointers = ev.pointerCount - val shouldBlockParent = pointers >= (if (panEnabled) 1 else 2) - setParentTouchInterceptDisallowed(shouldBlockParent) - } - - MotionEvent.ACTION_POINTER_UP -> { - val pointers = ev.pointerCount - 1 - val shouldBlockParent = pointers >= (if (panEnabled) 1 else 2) - setParentTouchInterceptDisallowed(shouldBlockParent) - } - - MotionEvent.ACTION_UP, - MotionEvent.ACTION_CANCEL, - -> { - setParentTouchInterceptDisallowed(false) - } - } - - return super.dispatchTouchEvent(ev) - } - init { MapsInitializer.initialize(reactContext) - reactContext.registerComponentCallbacks(componentCallbacks) + reactContext.registerComponentCallbacks(this) } fun initMapView() = @@ -196,35 +132,22 @@ class GoogleMapsViewImpl( mapView = MapView(reactContext, googleMapsOptions).also { - lifecycleObserver = MapLifecycleEventObserver(it, locationHandler) + lifecycleObserver = + ViewLifecycleEventObserver( + locationHandler = locationHandler, + onCreateView = it::onCreate, + onStartView = it::onStart, + onResumeView = it::onResume, + onPauseView = it::onPause, + onStopView = it::onStop, + onDestroyView = it::onDestroy, + ) super.addView(it) it.getMapAsync { map -> + if (destroyed) return@getMapAsync googleMap = map googleMap?.setLocationSource(locationHandler) - googleMap?.setOnMapLoadedCallback { - googleMap?.setOnCameraMoveStartedListener(this@GoogleMapsViewImpl) - googleMap?.setOnCameraMoveListener(this@GoogleMapsViewImpl) - googleMap?.setOnCameraIdleListener(this@GoogleMapsViewImpl) - googleMap?.setOnMarkerClickListener(this@GoogleMapsViewImpl) - googleMap?.setOnPolylineClickListener(this@GoogleMapsViewImpl) - googleMap?.setOnPolygonClickListener(this@GoogleMapsViewImpl) - googleMap?.setOnCircleClickListener(this@GoogleMapsViewImpl) - googleMap?.setOnMapClickListener(this@GoogleMapsViewImpl) - googleMap?.setOnMapLongClickListener(this@GoogleMapsViewImpl) - googleMap?.setOnPoiClickListener(this@GoogleMapsViewImpl) - googleMap?.setOnMarkerDragListener(this@GoogleMapsViewImpl) - googleMap?.setOnInfoWindowClickListener(this@GoogleMapsViewImpl) - googleMap?.setOnInfoWindowCloseListener(this@GoogleMapsViewImpl) - googleMap?.setOnInfoWindowLongClickListener(this@GoogleMapsViewImpl) - googleMap?.setOnMyLocationClickListener(this@GoogleMapsViewImpl) - googleMap?.setOnMyLocationButtonClickListener(this@GoogleMapsViewImpl) - googleMap?.setInfoWindowAdapter(this@GoogleMapsViewImpl) - mapViewLoaded = true - onMapLoaded?.invoke( - map.projection.visibleRegion.toRnRegion(), - map.cameraPosition.toRnCamera(), - ) - } + googleMap?.setOnMapLoadedCallback(this@GoogleMapsViewImpl) applyProps() initLocationCallbacks() onMapReady?.invoke(true) @@ -232,6 +155,35 @@ class GoogleMapsViewImpl( } } + override fun onMapLoaded() = + onUi { + googleMap?.setOnCameraMoveStartedListener(this@GoogleMapsViewImpl) + googleMap?.setOnCameraMoveListener(this@GoogleMapsViewImpl) + googleMap?.setOnCameraIdleListener(this@GoogleMapsViewImpl) + googleMap?.setOnMarkerClickListener(this@GoogleMapsViewImpl) + googleMap?.setOnPolylineClickListener(this@GoogleMapsViewImpl) + googleMap?.setOnPolygonClickListener(this@GoogleMapsViewImpl) + googleMap?.setOnCircleClickListener(this@GoogleMapsViewImpl) + googleMap?.setOnMapClickListener(this@GoogleMapsViewImpl) + googleMap?.setOnMapLongClickListener(this@GoogleMapsViewImpl) + googleMap?.setOnPoiClickListener(this@GoogleMapsViewImpl) + googleMap?.setOnIndoorStateChangeListener(this@GoogleMapsViewImpl) + googleMap?.setOnMarkerDragListener(this@GoogleMapsViewImpl) + googleMap?.setOnInfoWindowClickListener(this@GoogleMapsViewImpl) + googleMap?.setOnInfoWindowCloseListener(this@GoogleMapsViewImpl) + googleMap?.setOnInfoWindowLongClickListener(this@GoogleMapsViewImpl) + googleMap?.setOnMyLocationClickListener(this@GoogleMapsViewImpl) + googleMap?.setOnMyLocationButtonClickListener(this@GoogleMapsViewImpl) + googleMap?.setInfoWindowAdapter(this@GoogleMapsViewImpl) + mapViewLoaded = true + googleMap?.let { map -> + onMapLoaded?.invoke( + map.projection.visibleRegion.toRnRegion(), + map.cameraPosition.toRnCamera(), + ) + } + } + override fun onCameraMoveStarted(reason: Int) = onUi { if (!mapViewLoaded) return@onUi @@ -873,6 +825,7 @@ class GoogleMapsViewImpl( clearKmlLayer() clearUrlTileOverlays() googleMap?.apply { + setOnMapLoadedCallback(null) setOnCameraMoveStartedListener(null) setOnCameraMoveListener(null) setOnCameraIdleListener(null) @@ -901,7 +854,7 @@ class GoogleMapsViewImpl( mapView?.removeAllViews() mapView = null super.removeAllViews() - reactContext.unregisterComponentCallbacks(componentCallbacks) + reactContext.unregisterComponentCallbacks(this) } override fun requestLayout() { @@ -932,6 +885,26 @@ class GoogleMapsViewImpl( super.onDetachedFromWindow() } + override val gesturesSupported get() = googleMapsOptions.liteMode != true + override val panGestureEnabled get() = uiSettings?.scrollEnabled == true + override val multiTouchGestureEnabled + get() = + (uiSettings?.zoomGesturesEnabled == true) || + (uiSettings?.rotateEnabled == true) || + (uiSettings?.tiltEnabled == true) + + override fun onConfigurationChanged(newConfig: Configuration) {} + + override fun onLowMemory() { + mapView?.onLowMemory() + markerBuilder.clearIconCache() + } + + override fun onTrimMemory(level: Int) { + mapView?.onLowMemory() + markerBuilder.cancelAllJobs() + } + override fun onMarkerClick(marker: Marker): Boolean { onUi { onMarkerPress?.invoke(marker.idTag) diff --git a/android/src/main/java/com/rngooglemapsplus/MapMarkerBuilder.kt b/android/src/main/java/com/rngooglemapsplus/MapMarkerBuilder.kt index c3c479f..1e0e05d 100644 --- a/android/src/main/java/com/rngooglemapsplus/MapMarkerBuilder.kt +++ b/android/src/main/java/com/rngooglemapsplus/MapMarkerBuilder.kt @@ -398,7 +398,7 @@ class MapMarkerBuilder( private suspend fun renderBitmap( iconSvg: RNMarkerSvg, markerId: String, - ): RenderBitmapResult? { + ): RenderBitmapResult { val wPx = iconSvg.width .dpToPx() diff --git a/android/src/main/java/com/rngooglemapsplus/OnStreetViewPanoramaChangeListenerNullSafe.java b/android/src/main/java/com/rngooglemapsplus/OnStreetViewPanoramaChangeListenerNullSafe.java new file mode 100644 index 0000000..fe078f1 --- /dev/null +++ b/android/src/main/java/com/rngooglemapsplus/OnStreetViewPanoramaChangeListenerNullSafe.java @@ -0,0 +1,18 @@ +package com.rngooglemapsplus; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.gms.maps.StreetViewPanorama; +import com.google.android.gms.maps.model.StreetViewPanoramaLocation; + +public interface OnStreetViewPanoramaChangeListenerNullSafe + extends StreetViewPanorama.OnStreetViewPanoramaChangeListener { + + @Override + default void onStreetViewPanoramaChange(@NonNull StreetViewPanoramaLocation location) { + onStreetViewPanoramaChangeNullable(location); + } + + void onStreetViewPanoramaChangeNullable(@Nullable StreetViewPanoramaLocation location); +} diff --git a/android/src/main/java/com/rngooglemapsplus/RNGoogleMapsPlusPackage.kt b/android/src/main/java/com/rngooglemapsplus/RNGoogleMapsPlusPackage.kt index c9c73b1..6a20c11 100644 --- a/android/src/main/java/com/rngooglemapsplus/RNGoogleMapsPlusPackage.kt +++ b/android/src/main/java/com/rngooglemapsplus/RNGoogleMapsPlusPackage.kt @@ -6,6 +6,7 @@ import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.module.model.ReactModuleInfoProvider import com.facebook.react.uimanager.ViewManager import com.rngooglemapsplus.RNGoogleMapsPlusPackage.AppContextHolder.context +import com.rngooglemapsplus.views.HybridRNGoogleMapsPlusStreetViewManager import com.rngooglemapsplus.views.HybridRNGoogleMapsPlusViewManager class RNGoogleMapsPlusPackage : BaseReactPackage() { @@ -20,6 +21,7 @@ class RNGoogleMapsPlusPackage : BaseReactPackage() { context = reactContext return listOf( HybridRNGoogleMapsPlusViewManager(), + HybridRNGoogleMapsPlusStreetViewManager(), ) } diff --git a/android/src/main/java/com/rngooglemapsplus/RNGoogleMapsPlusStreetView.kt b/android/src/main/java/com/rngooglemapsplus/RNGoogleMapsPlusStreetView.kt new file mode 100644 index 0000000..4e848d3 --- /dev/null +++ b/android/src/main/java/com/rngooglemapsplus/RNGoogleMapsPlusStreetView.kt @@ -0,0 +1,135 @@ +package com.rngooglemapsplus + +import com.facebook.proguard.annotations.DoNotStrip +import com.facebook.react.uimanager.ThemedReactContext +import com.google.android.gms.maps.StreetViewPanoramaOptions +import com.margelo.nitro.core.Promise +import com.rngooglemapsplus.extensions.toLatLng +import com.rngooglemapsplus.extensions.toStreetViewPanoramaCamera +import com.rngooglemapsplus.extensions.toStreetViewSource + +@DoNotStrip +class RNGoogleMapsPlusStreetView( + private val context: ThemedReactContext, +) : HybridRNGoogleMapsPlusStreetViewSpec() { + private val mapErrorHandler = MapErrorHandler() + private val permissionHandler = PermissionHandler(context) + private val locationHandler = LocationHandler(context) + private val playServiceHandler = PlayServicesHandler(context) + + override val view = + StreetViewPanoramaViewImpl( + context, + locationHandler, + playServiceHandler, + mapErrorHandler, + ) + + override fun onDropView() { + view.destroyInternal() + } + + override var initialProps: RNStreetViewInitialProps? = null + set(value) { + if (field == value) return + field = value + + val options = + StreetViewPanoramaOptions().apply { + panoramaId(initialProps?.panoramaId) + initialProps?.position?.let { + position( + it.toLatLng(), + initialProps?.radius?.toInt(), + initialProps?.source.toStreetViewSource(), + ) + } + initialProps?.camera?.toStreetViewPanoramaCamera()?.let { panoramaCamera(it) } + } + + view.streetViewPanoramaOptions = options + } + + override var uiSettings: RNStreetViewUiSettings? = null + set(value) { + if (field == value) return + field = value + view.uiSettings = value + } + + override var onPanoramaReady: ((Boolean) -> Unit)? = null + set(cb) { + view.onPanoramaReady = cb + } + + override var onLocationUpdate: ((RNLocation) -> Unit)? = null + set(cb) { + view.onLocationUpdate = cb + } + + override var onLocationError: ((RNLocationErrorCode) -> Unit)? = null + set(cb) { + view.onLocationError = cb + } + + override var onPanoramaChange: ((RNStreetViewPanoramaLocation) -> Unit)? = null + set(cb) { + view.onPanoramaChange = cb + } + + override var onCameraChange: ((RNStreetViewCamera) -> Unit)? = null + set(cb) { + view.onCameraChange = cb + } + + override var onPanoramaPress: ((RNStreetViewOrientation) -> Unit)? = null + set(cb) { + view.onPanoramaPress = cb + } + + override var onPanoramaError: ((RNMapErrorCode, String) -> Unit)? = null + set(cb) { + mapErrorHandler.callback = cb + } + + override fun setCamera( + camera: RNStreetViewCamera, + animated: Boolean?, + durationMs: Double?, + ) { + val current = view.currentCamera + view.setPanoramaCamera( + camera.toStreetViewPanoramaCamera(current), + animated ?: false, + durationMs?.toInt() ?: 1000, + ) + } + + override fun setPosition( + position: RNLatLng, + radius: Double?, + source: RNStreetViewSource?, + ) { + view.setPosition( + position.toLatLng(), + radius?.toInt(), + source.toStreetViewSource(), + ) + } + + override fun setPositionById(panoramaId: String) { + view.setPositionById(panoramaId) + } + + override fun showLocationDialog() { + locationHandler.showLocationDialog() + } + + override fun openLocationSettings() { + locationHandler.openLocationSettings() + } + + override fun requestLocationPermission(): Promise = permissionHandler.requestLocationPermission() + + override fun isGooglePlayServicesAvailable(): Boolean = playServiceHandler.isPlayServicesAvailable() +} diff --git a/android/src/main/java/com/rngooglemapsplus/RNGoogleMapsPlusView.kt b/android/src/main/java/com/rngooglemapsplus/RNGoogleMapsPlusView.kt index 14df0d2..45e04fa 100644 --- a/android/src/main/java/com/rngooglemapsplus/RNGoogleMapsPlusView.kt +++ b/android/src/main/java/com/rngooglemapsplus/RNGoogleMapsPlusView.kt @@ -27,9 +27,9 @@ class RNGoogleMapsPlusView( private val mapErrorHandler = MapErrorHandler() private var currentCustomMapStyle: String? = null - private var permissionHandler = PermissionHandler(context) - private var locationHandler = LocationHandler(context) - private var playServiceHandler = PlayServicesHandler(context) + private val permissionHandler = PermissionHandler(context) + private val locationHandler = LocationHandler(context) + private val playServiceHandler = PlayServicesHandler(context) private val markerBuilder = MapMarkerBuilder(context, mapErrorHandler) private val polylineBuilder = MapPolylineBuilder() diff --git a/android/src/main/java/com/rngooglemapsplus/StreetViewPanoramaViewImpl.kt b/android/src/main/java/com/rngooglemapsplus/StreetViewPanoramaViewImpl.kt new file mode 100644 index 0000000..deb0107 --- /dev/null +++ b/android/src/main/java/com/rngooglemapsplus/StreetViewPanoramaViewImpl.kt @@ -0,0 +1,255 @@ +package com.rngooglemapsplus + +import android.content.ComponentCallbacks2 +import android.content.res.Configuration +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.findViewTreeLifecycleOwner +import com.facebook.react.uimanager.ThemedReactContext +import com.google.android.gms.maps.MapsInitializer +import com.google.android.gms.maps.StreetViewPanorama +import com.google.android.gms.maps.StreetViewPanoramaOptions +import com.google.android.gms.maps.StreetViewPanoramaView +import com.google.android.gms.maps.model.LatLng +import com.google.android.gms.maps.model.StreetViewPanoramaCamera +import com.google.android.gms.maps.model.StreetViewPanoramaLocation +import com.google.android.gms.maps.model.StreetViewPanoramaOrientation +import com.google.android.gms.maps.model.StreetViewSource +import com.rngooglemapsplus.extensions.toRNMapErrorCodeOrNull +import com.rngooglemapsplus.extensions.toRnLatLng +import com.rngooglemapsplus.extensions.toRnLocation + +class StreetViewPanoramaViewImpl( + private val reactContext: ThemedReactContext, + private val locationHandler: LocationHandler, + private val playServiceHandler: PlayServicesHandler, + private val mapErrorHandler: MapErrorHandler, +) : GestureAwareFrameLayout(reactContext), + OnStreetViewPanoramaChangeListenerNullSafe, + StreetViewPanorama.OnStreetViewPanoramaCameraChangeListener, + StreetViewPanorama.OnStreetViewPanoramaClickListener, + ComponentCallbacks2 { + private var lifecycleObserver: ViewLifecycleEventObserver? = null + private var lifecycle: Lifecycle? = null + + private var streetViewInitialized = false + private var destroyed = false + + private var streetViewPanoramaView: StreetViewPanoramaView? = null + private var streetViewPanorama: StreetViewPanorama? = null + + init { + MapsInitializer.initialize(reactContext) + reactContext.registerComponentCallbacks(this) + } + + fun initStreetView() = + onUi { + if (streetViewInitialized) return@onUi + streetViewInitialized = true + + val result = playServiceHandler.playServicesAvailability() + val errorCode = result.toRNMapErrorCodeOrNull() + if (errorCode != null) { + mapErrorHandler.report(errorCode, "play services unavailable") + if (errorCode == RNMapErrorCode.PLAY_SERVICES_MISSING || + errorCode == RNMapErrorCode.PLAY_SERVICES_INVALID + ) { + return@onUi + } + } + + streetViewPanoramaView = + StreetViewPanoramaView(reactContext, streetViewPanoramaOptions).also { + lifecycleObserver = + ViewLifecycleEventObserver( + locationHandler = locationHandler, + onCreateView = it::onCreate, + onStartView = it::onStart, + onResumeView = it::onResume, + onPauseView = it::onPause, + onStopView = it::onStop, + onDestroyView = it::onDestroy, + ) + super.addView(it) + it.getStreetViewPanoramaAsync { panorama -> + if (destroyed) return@getStreetViewPanoramaAsync + streetViewPanorama = panorama + streetViewPanorama?.setOnStreetViewPanoramaChangeListener(this@StreetViewPanoramaViewImpl) + streetViewPanorama?.setOnStreetViewPanoramaCameraChangeListener(this@StreetViewPanoramaViewImpl) + streetViewPanorama?.setOnStreetViewPanoramaClickListener(this@StreetViewPanoramaViewImpl) + applyProps() + initLocationCallbacks() + onPanoramaReady?.invoke(true) + } + } + } + + override fun onStreetViewPanoramaChangeNullable(location: StreetViewPanoramaLocation?) { + onUi { + if (location == null) { + mapErrorHandler.report(RNMapErrorCode.PANORAMA_NOT_FOUND, "panorama not found") + } else { + val links = + location.links + .map { link -> + RNStreetViewPanoramaLink( + bearing = link.bearing.toDouble(), + panoramaId = link.panoId, + ) + }.toTypedArray() + onPanoramaChange?.invoke( + RNStreetViewPanoramaLocation( + position = location.position.toRnLatLng(), + panoramaId = location.panoId, + links = links, + ), + ) + } + } + } + + override fun onStreetViewPanoramaCameraChange(camera: StreetViewPanoramaCamera) { + onUi { + onCameraChange?.invoke( + RNStreetViewCamera( + bearing = camera.bearing.toDouble(), + tilt = camera.tilt.toDouble(), + zoom = camera.zoom.toDouble(), + ), + ) + } + } + + override fun onStreetViewPanoramaClick(orientation: StreetViewPanoramaOrientation) { + onUi { + onPanoramaPress?.invoke( + RNStreetViewOrientation( + bearing = orientation.bearing.toDouble(), + tilt = orientation.tilt.toDouble(), + ), + ) + } + } + + fun initLocationCallbacks() { + locationHandler.onUpdate = { location -> + onUi { onLocationUpdate?.invoke(location.toRnLocation()) } + } + locationHandler.onError = { error -> + onUi { onLocationError?.invoke(error) } + } + } + + fun applyProps() { + uiSettings = uiSettings + } + + val currentCamera: StreetViewPanoramaCamera? + get() = onUiSync { streetViewPanorama?.panoramaCamera } + + var streetViewPanoramaOptions: StreetViewPanoramaOptions = StreetViewPanoramaOptions() + + var uiSettings: RNStreetViewUiSettings? = null + set(value) { + field = value + onUi { + streetViewPanorama?.apply { + isStreetNamesEnabled = value?.streetNamesEnabled ?: true + isUserNavigationEnabled = value?.userNavigationEnabled ?: true + isPanningGesturesEnabled = value?.panningGesturesEnabled ?: true + isZoomGesturesEnabled = value?.zoomGesturesEnabled ?: true + } + } + } + + var onPanoramaReady: ((Boolean) -> Unit)? = null + var onLocationUpdate: ((RNLocation) -> Unit)? = null + var onLocationError: ((RNLocationErrorCode) -> Unit)? = null + var onPanoramaChange: ((RNStreetViewPanoramaLocation) -> Unit)? = null + var onCameraChange: ((RNStreetViewCamera) -> Unit)? = null + var onPanoramaPress: ((RNStreetViewOrientation) -> Unit)? = null + + fun setPosition( + latLng: LatLng, + radius: Int?, + source: StreetViewSource, + ) = onUi { + radius?.let { streetViewPanorama?.setPosition(latLng, it, source) } + ?: streetViewPanorama?.setPosition(latLng, source) + } + + fun setPositionById(panoramaId: String) = + onUi { + streetViewPanorama?.setPosition(panoramaId) + } + + fun setPanoramaCamera( + camera: StreetViewPanoramaCamera, + animated: Boolean, + durationMs: Int, + ) = onUi { + if (animated) { + streetViewPanorama?.animateTo(camera, durationMs.toLong()) + } else { + streetViewPanorama?.animateTo(camera, 0) + } + } + + fun destroyInternal() = + onUi { + if (destroyed) return@onUi + destroyed = true + lifecycleObserver?.toDestroyedState() + lifecycleObserver = null + streetViewPanorama?.apply { + setOnStreetViewPanoramaChangeListener(null) + setOnStreetViewPanoramaCameraChangeListener(null) + setOnStreetViewPanoramaClickListener(null) + } + streetViewPanorama = null + streetViewPanoramaView?.removeAllViews() + streetViewPanoramaView = null + super.removeAllViews() + reactContext.unregisterComponentCallbacks(this) + } + + override fun requestLayout() { + super.requestLayout() + post { + measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY), + ) + layout(left, top, right, bottom) + } + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + initStreetView() + lifecycle = streetViewPanoramaView?.findViewTreeLifecycleOwner()?.lifecycle + lifecycleObserver?.let { observer -> + lifecycle?.addObserver(observer) + } + } + + override fun onDetachedFromWindow() { + setParentTouchInterceptDisallowed(false) + lifecycleObserver?.let { lifecycle?.removeObserver(it) } + lifecycle = null + super.onDetachedFromWindow() + } + + override val panGestureEnabled get() = uiSettings?.panningGesturesEnabled == true + override val multiTouchGestureEnabled get() = uiSettings?.zoomGesturesEnabled == true + + override fun onConfigurationChanged(newConfig: Configuration) {} + + override fun onLowMemory() { + streetViewPanoramaView?.onLowMemory() + } + + override fun onTrimMemory(level: Int) { + streetViewPanoramaView?.onLowMemory() + } +} diff --git a/android/src/main/java/com/rngooglemapsplus/MapLifecycleEventObserver.kt b/android/src/main/java/com/rngooglemapsplus/ViewLifecycleEventObserver.kt similarity index 70% rename from android/src/main/java/com/rngooglemapsplus/MapLifecycleEventObserver.kt rename to android/src/main/java/com/rngooglemapsplus/ViewLifecycleEventObserver.kt index c9c035f..5fd7c99 100644 --- a/android/src/main/java/com/rngooglemapsplus/MapLifecycleEventObserver.kt +++ b/android/src/main/java/com/rngooglemapsplus/ViewLifecycleEventObserver.kt @@ -4,11 +4,15 @@ import android.os.Bundle import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner -import com.google.android.gms.maps.MapView -class MapLifecycleEventObserver( - private val mapView: MapView?, +class ViewLifecycleEventObserver( private val locationHandler: LocationHandler, + private val onCreateView: (Bundle) -> Unit, + private val onStartView: () -> Unit, + private val onResumeView: () -> Unit, + private val onPauseView: () -> Unit, + private val onStopView: () -> Unit, + private val onDestroyView: () -> Unit, ) : LifecycleEventObserver { private var currentState: Lifecycle.State = Lifecycle.State.INITIALIZED @@ -17,7 +21,10 @@ class MapLifecycleEventObserver( event: Lifecycle.Event, ) { when (event) { + // Host destroy does not necessarily mean the RN view is dropped. + // The actual MapView destroy is driven explicitly from view cleanup. Lifecycle.Event.ON_DESTROY -> toCreatedState() + else -> toState(event.targetState) } } @@ -45,43 +52,39 @@ class MapLifecycleEventObserver( } private fun downFromCurrentState() { - Lifecycle.Event.downFrom(currentState)?.also { - invokeEvent(it) - } + Lifecycle.Event.downFrom(currentState)?.also { invokeEvent(it) } } private fun upFromCurrentState() { - Lifecycle.Event.upFrom(currentState)?.also { - invokeEvent(it) - } + Lifecycle.Event.upFrom(currentState)?.also { invokeEvent(it) } } private fun invokeEvent(event: Lifecycle.Event) { when (event) { Lifecycle.Event.ON_CREATE -> { - mapView?.onCreate(Bundle()) + onCreateView(Bundle()) } Lifecycle.Event.ON_START -> { - mapView?.onStart() + onStartView() } Lifecycle.Event.ON_RESUME -> { locationHandler.start() - mapView?.onResume() + onResumeView() } Lifecycle.Event.ON_PAUSE -> { - mapView?.onPause() + onPauseView() locationHandler.stop() } Lifecycle.Event.ON_STOP -> { - mapView?.onStop() + onStopView() } Lifecycle.Event.ON_DESTROY -> { - mapView?.onDestroy() + onDestroyView() } Lifecycle.Event.ON_ANY -> {} diff --git a/android/src/main/java/com/rngooglemapsplus/extensions/RNLineCapTypeExtension.kt b/android/src/main/java/com/rngooglemapsplus/extensions/RNLineCapTypeExtension.kt index 77042aa..5181296 100644 --- a/android/src/main/java/com/rngooglemapsplus/extensions/RNLineCapTypeExtension.kt +++ b/android/src/main/java/com/rngooglemapsplus/extensions/RNLineCapTypeExtension.kt @@ -10,5 +10,6 @@ fun RNLineCapType?.toMapLineCap(): Cap = when (this) { RNLineCapType.ROUND -> RoundCap() RNLineCapType.SQUARE -> SquareCap() - else -> ButtCap() + RNLineCapType.BUTT -> ButtCap() + null -> ButtCap() } diff --git a/android/src/main/java/com/rngooglemapsplus/extensions/RNStreetViewCameraExtension.kt b/android/src/main/java/com/rngooglemapsplus/extensions/RNStreetViewCameraExtension.kt new file mode 100644 index 0000000..5cde659 --- /dev/null +++ b/android/src/main/java/com/rngooglemapsplus/extensions/RNStreetViewCameraExtension.kt @@ -0,0 +1,12 @@ +package com.rngooglemapsplus.extensions + +import com.google.android.gms.maps.model.StreetViewPanoramaCamera +import com.rngooglemapsplus.RNStreetViewCamera + +fun RNStreetViewCamera.toStreetViewPanoramaCamera(current: StreetViewPanoramaCamera? = null): StreetViewPanoramaCamera = + StreetViewPanoramaCamera + .Builder() + .bearing(bearing?.toFloat() ?: current?.bearing ?: 0f) + .tilt(tilt?.toFloat() ?: current?.tilt ?: 0f) + .zoom(zoom?.toFloat() ?: current?.zoom ?: 0f) + .build() diff --git a/android/src/main/java/com/rngooglemapsplus/extensions/RNStreetViewSourceExtension.kt b/android/src/main/java/com/rngooglemapsplus/extensions/RNStreetViewSourceExtension.kt new file mode 100644 index 0000000..0a96e1d --- /dev/null +++ b/android/src/main/java/com/rngooglemapsplus/extensions/RNStreetViewSourceExtension.kt @@ -0,0 +1,11 @@ +package com.rngooglemapsplus.extensions + +import com.google.android.gms.maps.model.StreetViewSource +import com.rngooglemapsplus.RNStreetViewSource + +fun RNStreetViewSource?.toStreetViewSource(): StreetViewSource = + when (this) { + RNStreetViewSource.OUTDOOR -> StreetViewSource.OUTDOOR + RNStreetViewSource.DEFAULT -> StreetViewSource.DEFAULT + null -> StreetViewSource.DEFAULT + } diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 0bef178..54ed73f 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -2453,7 +2453,7 @@ SPEC CHECKSUMS: ReactCommon: fe2a3af8975e63efa60f95fca8c34dc85deee360 ReactNativeDependencies: 14c6834d269d317465c4d938cc4148c9c5f3d549 RNGestureHandler: 0d9f93773e381105773bc6c47646abbcf60faab5 - RNGoogleMapsPlus: 650c938c19615272bfb4af9e8ae38ddfb5adf32e + RNGoogleMapsPlus: 897f50e8471c20e18f0c76554440bd4bf73e8984 RNReanimated: 121c52535a4276e09b9235e067811b222a681760 RNScreens: c58f17578c73435d8c00998cac0d89ad8105263c RNWorklets: dd3b2cb0750090d78d85cd3b3ec0fdbeab5ce118 diff --git a/example/src/App.tsx b/example/src/App.tsx index feb46c4..fca2fc0 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -28,6 +28,7 @@ import PolygonsScreen from '@src/screens/PolygonsScreen'; import PolylinesScreen from '@src/screens/PolylinesScreen'; import ScrollViewScreen from '@src/screens/ScrollViewScreen'; import SnapshotTestScreen from '@src/screens/SnaptshotTestScreen'; +import StreetViewScreen from '@src/screens/StreetViewScreen'; import StressTestScreen from '@src/screens/StressTestScreen'; import SvgMarkersScreen from '@src/screens/SvgMarkersScreen'; import UrlTileOverlayScreen from '@src/screens/UrlTileOverlayScreen'; @@ -151,6 +152,11 @@ export default function App() { component={StressTestScreen} options={{ title: 'Stress test' }} /> + void }; type Props = { - mapRef: React.RefObject; + viewRef: React.RefObject; buttons: ButtonItem[]; }; -export default function ControlPanel({ mapRef, buttons }: Props) { +export default function ControlPanel({ viewRef, buttons }: Props) { const theme = useAppTheme(); const navigation = useNavigation(); const progress = useSharedValue(0); @@ -57,28 +60,28 @@ export default function ControlPanel({ mapRef, buttons }: Props) { { title: 'Request location permission', onPress: async () => { - const res = await mapRef.current?.requestLocationPermission(); + const res = await viewRef?.current?.requestLocationPermission(); console.log('Permission result', res); }, }, { title: 'Show location dialog', - onPress: () => console.log(mapRef.current?.showLocationDialog()), + onPress: () => viewRef?.current?.showLocationDialog(), }, { title: 'Open location settings', - onPress: () => console.log(mapRef.current?.openLocationSettings()), + onPress: () => viewRef?.current?.openLocationSettings(), }, { title: 'Check Google Play Services', onPress: () => console.log( 'Google Play Services result', - mapRef.current?.isGooglePlayServicesAvailable() + viewRef?.current?.isGooglePlayServicesAvailable() ), }, ], - [buttons, mapRef, navigation] + [buttons, viewRef, navigation] ); const buttonHeight = 52; diff --git a/example/src/screens/BasicMapScreen.tsx b/example/src/screens/BasicMapScreen.tsx index 0fea33c..6fae6bd 100644 --- a/example/src/screens/BasicMapScreen.tsx +++ b/example/src/screens/BasicMapScreen.tsx @@ -65,7 +65,7 @@ export default function BasicMapScreen() { <> {init && ( - + )} diff --git a/example/src/screens/CameraTestScreen.tsx b/example/src/screens/CameraTestScreen.tsx index 25599ee..540792d 100644 --- a/example/src/screens/CameraTestScreen.tsx +++ b/example/src/screens/CameraTestScreen.tsx @@ -77,7 +77,7 @@ export default function CameraTestScreen() { return ( - + ); } diff --git a/example/src/screens/ClsuteringScreen.tsx b/example/src/screens/ClsuteringScreen.tsx index a003697..2ea0519 100644 --- a/example/src/screens/ClsuteringScreen.tsx +++ b/example/src/screens/ClsuteringScreen.tsx @@ -120,7 +120,7 @@ export default function ClusteringScreen() { onMapLoaded={handleMapLoaded} onCameraChange={handleCameraChange} > - + ); } diff --git a/example/src/screens/CustomStyleScreen.tsx b/example/src/screens/CustomStyleScreen.tsx index f852585..86d133c 100644 --- a/example/src/screens/CustomStyleScreen.tsx +++ b/example/src/screens/CustomStyleScreen.tsx @@ -27,7 +27,7 @@ export default function CustomStyleScreen() { return ( - + ); } diff --git a/example/src/screens/HomeScreen.tsx b/example/src/screens/HomeScreen.tsx index e77c909..ff0ac96 100644 --- a/example/src/screens/HomeScreen.tsx +++ b/example/src/screens/HomeScreen.tsx @@ -34,6 +34,7 @@ const screens = [ { name: 'Snapshot', title: 'Snapshot Test' }, { name: 'Clustering', title: 'Clustering' }, { name: 'Stress', title: 'Stress Test' }, + { name: 'StreetView', title: 'Street View' }, { name: 'Module', title: 'Module Test' }, ]; diff --git a/example/src/screens/IndoorLevelMapScreen.tsx b/example/src/screens/IndoorLevelMapScreen.tsx index 0865db9..66bd0e1 100644 --- a/example/src/screens/IndoorLevelMapScreen.tsx +++ b/example/src/screens/IndoorLevelMapScreen.tsx @@ -22,7 +22,7 @@ export default function IndoorLevelMapScreen() { indoorEnabled={true} buildingEnabled={true} > - + ); } diff --git a/example/src/screens/LocationScreen.tsx b/example/src/screens/LocationScreen.tsx index c257091..0dba10f 100644 --- a/example/src/screens/LocationScreen.tsx +++ b/example/src/screens/LocationScreen.tsx @@ -41,7 +41,7 @@ export default function LocationScreen() { myLocationEnabled locationConfig={locationConfig} > - + diff --git a/example/src/screens/MarkersScreen.tsx b/example/src/screens/MarkersScreen.tsx index a27be78..fb5f853 100644 --- a/example/src/screens/MarkersScreen.tsx +++ b/example/src/screens/MarkersScreen.tsx @@ -113,7 +113,7 @@ export default function MarkersScreen() { markers={markers ? markers : []} onMarkerPress={handleMarkerPress} > - + visible={dialogVisible} diff --git a/example/src/screens/SnaptshotTestScreen.tsx b/example/src/screens/SnaptshotTestScreen.tsx index b07727f..e4f9f57 100644 --- a/example/src/screens/SnaptshotTestScreen.tsx +++ b/example/src/screens/SnaptshotTestScreen.tsx @@ -64,7 +64,7 @@ export default function SnapshotTestScreen() { return ( - + diff --git a/example/src/screens/StreetViewScreen.tsx b/example/src/screens/StreetViewScreen.tsx new file mode 100644 index 0000000..029d5de --- /dev/null +++ b/example/src/screens/StreetViewScreen.tsx @@ -0,0 +1,237 @@ +import React, { useCallback, useRef, useState } from 'react'; + +import { StyleSheet, View } from 'react-native'; + +import { useNavigation } from '@react-navigation/native'; +import { + GoogleMapsStreetView, + type GoogleMapsStreetViewRef, + type RNLocation, + RNLocationErrorCode, + type RNMapErrorCode, + type RNStreetViewCamera, + type RNStreetViewOrientation, + type RNStreetViewPanoramaLocation, +} from 'react-native-google-maps-plus'; + +import ControlPanel from '@src/components/ControlPanel'; +import MapConfigDialog from '@src/components/MapConfigDialog'; +import { useHeaderButton } from '@src/hooks/useHeaderButton'; +import { useNitroCallback } from '@src/hooks/useNitroCallback'; +import type { RNStreetViewConfig } from '@src/types/streetViewConfig'; +import { RNStreetViewConfigValidator } from '@src/utils/validator'; + +export default function StreetViewScreen() { + const streetViewRef = useRef(null); + const navigation = useNavigation(); + + const [init, setInit] = useState(false); + const [config, setConfig] = useState({ + initialProps: { + panoramaId: undefined, + position: { latitude: 37.8090233, longitude: -122.4742005 }, + radius: 50, + source: 'default', + camera: { bearing: 315, tilt: 0, zoom: 0 }, + }, + uiSettings: { + streetNamesEnabled: true, + userNavigationEnabled: true, + panningGesturesEnabled: true, + zoomGesturesEnabled: true, + }, + }); + const [dialogVisible, setDialogVisible] = useState(true); + const [currentCamera, setCurrentCamera] = useState( + null + ); + + useHeaderButton(navigation, 'Edit', () => setDialogVisible(true)); + + const hybridRef = useNitroCallback( + useCallback( + (ref: GoogleMapsStreetViewRef) => { + streetViewRef.current = ref; + }, + [streetViewRef] + ) + ); + + const onPanoramaReady = useNitroCallback( + useCallback((ready: boolean) => console.log('Panorama ready:', ready), []) + ); + + const onLocationUpdate = useNitroCallback( + useCallback((l: RNLocation) => console.log('Location:', l), []) + ); + + const onLocationError = useNitroCallback( + useCallback( + (e: RNLocationErrorCode) => console.log('Location error:', e), + [] + ) + ); + + const onPanoramaChange = useNitroCallback( + useCallback( + (location: RNStreetViewPanoramaLocation) => + console.log( + 'Panorama changed:', + location.panoramaId, + location.position + ), + [] + ) + ); + + const onCameraChange = useNitroCallback( + useCallback((camera: RNStreetViewCamera) => { + setCurrentCamera(camera); + console.log('Camera changed:', camera); + }, []) + ); + + const onPanoramaPress = useNitroCallback( + useCallback( + (orientation: RNStreetViewOrientation) => + console.log('Panorama press:', orientation), + [] + ) + ); + + const onPanoramaError = useNitroCallback( + useCallback( + (code: RNMapErrorCode, msg: string) => + console.log('Map error:', code, msg), + [] + ) + ); + + const buttons = [ + { + title: 'Set Position (Times Square)', + onPress: () => { + streetViewRef.current?.setPosition( + { latitude: 40.758, longitude: -73.9855 }, + 50 + ); + }, + }, + { + title: 'Set Position by ID', + onPress: () => { + streetViewRef.current?.setPositionById('mTexJ35IDdbS-ajyRfp2wg'); + }, + }, + { + title: 'Tilt Up', + onPress: () => { + const tilt = Math.min((currentCamera?.tilt ?? 0) + 15, 90); + streetViewRef.current?.setCamera( + { bearing: currentCamera?.bearing, tilt, zoom: currentCamera?.zoom }, + true, + 400 + ); + }, + }, + { + title: 'Tilt Down', + onPress: () => { + const tilt = Math.max((currentCamera?.tilt ?? 0) - 15, -90); + streetViewRef.current?.setCamera( + { bearing: currentCamera?.bearing, tilt, zoom: currentCamera?.zoom }, + true, + 400 + ); + }, + }, + { + title: 'Rotate Left', + onPress: () => { + const bearing = ((currentCamera?.bearing ?? 0) - 15 + 360) % 360; + streetViewRef.current?.setCamera( + { bearing, tilt: currentCamera?.tilt, zoom: currentCamera?.zoom }, + true, + 400 + ); + }, + }, + { + title: 'Rotate Right', + onPress: () => { + const bearing = ((currentCamera?.bearing ?? 0) + 15) % 360; + streetViewRef.current?.setCamera( + { bearing, tilt: currentCamera?.tilt, zoom: currentCamera?.zoom }, + true, + 400 + ); + }, + }, + { + title: 'Zoom In', + onPress: () => { + const zoom = Math.min((currentCamera?.zoom ?? 0) + 1, 5); + streetViewRef.current?.setCamera( + { bearing: currentCamera?.bearing, tilt: currentCamera?.tilt, zoom }, + true, + 400 + ); + }, + }, + { + title: 'Zoom Out', + onPress: () => { + const zoom = Math.max((currentCamera?.zoom ?? 0) - 1, 0); + streetViewRef.current?.setCamera( + { bearing: currentCamera?.bearing, tilt: currentCamera?.tilt, zoom }, + true, + 400 + ); + }, + }, + ]; + + return ( + <> + {init && ( + + + + + )} + + + visible={dialogVisible} + title="Street View Settings" + initialData={config} + validator={RNStreetViewConfigValidator} + onClose={() => { + setInit(true); + setDialogVisible(false); + }} + onSave={(c) => setConfig(c)} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + streetView: { + flex: 1, + }, +}); diff --git a/example/src/screens/StressTestScreen.tsx b/example/src/screens/StressTestScreen.tsx index c26fb69..3a61cd1 100644 --- a/example/src/screens/StressTestScreen.tsx +++ b/example/src/screens/StressTestScreen.tsx @@ -65,7 +65,7 @@ export default function StressTestScreen() { return ( - + ); } diff --git a/example/src/types/navigation.ts b/example/src/types/navigation.ts index 706a12d..1ff13fd 100644 --- a/example/src/types/navigation.ts +++ b/example/src/types/navigation.ts @@ -20,6 +20,7 @@ export type RootStackParamList = { Snapshot: undefined; Clustering: undefined; Stress: undefined; + StreetView: undefined; Module: undefined; }; diff --git a/example/src/types/streetViewConfig.ts b/example/src/types/streetViewConfig.ts new file mode 100644 index 0000000..c880876 --- /dev/null +++ b/example/src/types/streetViewConfig.ts @@ -0,0 +1,9 @@ +import type { + RNStreetViewInitialProps, + RNStreetViewUiSettings, +} from 'react-native-google-maps-plus'; + +export type RNStreetViewConfig = { + initialProps?: RNStreetViewInitialProps; + uiSettings?: RNStreetViewUiSettings; +}; diff --git a/example/src/utils/validator.ts b/example/src/utils/validator.ts index 2c24090..0392c15 100644 --- a/example/src/utils/validator.ts +++ b/example/src/utils/validator.ts @@ -337,6 +337,37 @@ export const RNBasicMapConfigValidator = object({ locationConfig: optional(RNLocationConfigValidator), }); +export const RNStreetViewCameraValidator = object({ + bearing: optional(number()), + tilt: optional(number()), + zoom: optional(number()), +}); + +export const RNStreetViewSourceValidator = unionWithValues( + 'default', + 'outdoor' +); + +export const RNStreetViewUiSettingsValidator = object({ + streetNamesEnabled: optional(boolean()), + userNavigationEnabled: optional(boolean()), + panningGesturesEnabled: optional(boolean()), + zoomGesturesEnabled: optional(boolean()), +}); + +export const RNStreetViewConfigValidator = object({ + initialProps: optional( + object({ + panoramaId: optional(string()), + position: optional(RNLatLngValidator), + radius: optional(number()), + source: optional(RNStreetViewSourceValidator), + camera: optional(RNStreetViewCameraValidator), + }) + ), + uiSettings: optional(RNStreetViewUiSettingsValidator), +}); + const schema: any = (RNBasicMapConfigValidator as any).schema; if (schema.mapType?.type === 'union' && !schema.mapType._schema) { diff --git a/ios/RNGoogleMapsPlusStreetView.swift b/ios/RNGoogleMapsPlusStreetView.swift new file mode 100644 index 0000000..1eea079 --- /dev/null +++ b/ios/RNGoogleMapsPlusStreetView.swift @@ -0,0 +1,94 @@ +import CoreLocation +import Foundation +import GoogleMaps +import NitroModules + +final class RNGoogleMapsPlusStreetView: HybridRNGoogleMapsPlusStreetViewSpec { + + private let mapErrorHandler: MapErrorHandler + private let permissionHandler: PermissionHandler + private let locationHandler: LocationHandler + private let impl: StreetViewPanoramaViewImpl + + var view: UIView { + return impl + } + + override init() { + self.permissionHandler = PermissionHandler() + self.locationHandler = LocationHandler() + self.mapErrorHandler = MapErrorHandler() + self.impl = StreetViewPanoramaViewImpl( + mapErrorHandler: mapErrorHandler, + locationHandler: locationHandler + ) + } + + func onDropView() { + impl.deinitInternal() + } + + var initialProps: RNStreetViewInitialProps? { + didSet { impl.streetViewInitialProps = initialProps } + } + + var uiSettings: RNStreetViewUiSettings? { + didSet { impl.uiSettings = uiSettings } + } + var onPanoramaReady: ((Bool) -> Void)? { + didSet { impl.onPanoramaReady = onPanoramaReady } + } + var onLocationUpdate: ((RNLocation) -> Void)? { + didSet { impl.onLocationUpdate = onLocationUpdate } + } + var onLocationError: ((RNLocationErrorCode) -> Void)? { + didSet { impl.onLocationError = onLocationError } + } + var onPanoramaChange: ((RNStreetViewPanoramaLocation) -> Void)? { + didSet { impl.onPanoramaChange = onPanoramaChange } + } + var onCameraChange: ((RNStreetViewCamera) -> Void)? { + didSet { impl.onCameraChange = onCameraChange } + } + var onPanoramaPress: ((RNStreetViewOrientation) -> Void)? { + didSet { impl.onPanoramaPress = onPanoramaPress } + } + var onPanoramaError: ((RNMapErrorCode, String) -> Void)? { + didSet { mapErrorHandler.callback = onPanoramaError } + } + + func setCamera(camera: RNStreetViewCamera, animated: Bool?, durationMs: Double?) { + onMain { + let cam = camera.toGMSPanoramaCamera(current: self.impl.currentCamera) + self.impl.setCamera(cam, animated: animated ?? false, durationMs: durationMs ?? 1000) + } + } + + func setPosition(position: RNLatLng, radius: Double?, source: RNStreetViewSource?) { + impl.setPosition( + position.toCLLocationCoordinate2D(), + radius: radius.map { UInt($0) }, + source: source?.toGMSPanoramaSource ?? .default + ) + } + + func setPositionById(panoramaId: String) { + impl.setPositionById(panoramaId) + } + + func showLocationDialog() { + locationHandler.showLocationDialog() + } + + func openLocationSettings() { + locationHandler.openLocationSettings() + } + + func requestLocationPermission() -> NitroModules.Promise { + return permissionHandler.requestLocationPermission() + } + + func isGooglePlayServicesAvailable() -> Bool { + return false + } +} diff --git a/ios/StreetViewPanoramaViewImpl.swift b/ios/StreetViewPanoramaViewImpl.swift new file mode 100644 index 0000000..b4b3544 --- /dev/null +++ b/ios/StreetViewPanoramaViewImpl.swift @@ -0,0 +1,239 @@ +import CoreLocation +import GoogleMaps +import NitroModules +import UIKit + +final class StreetViewPanoramaViewImpl: UIView, GMSPanoramaViewDelegate { + + private let mapErrorHandler: MapErrorHandler + private let locationHandler: LocationHandler + private var panoramaView: GMSPanoramaView? + private var panoramaViewInitialized = false + private var deInitialized = false + + init( + frame: CGRect = .zero, + mapErrorHandler: MapErrorHandler, + locationHandler: LocationHandler + ) { + self.mapErrorHandler = mapErrorHandler + self.locationHandler = locationHandler + super.init(frame: frame) + } + + private var lifecycleAttached = false + private var lifecycleTasks = [Task]() + + private func attachLifecycleObservers() { + if lifecycleAttached { return } + lifecycleAttached = true + lifecycleTasks.append( + Task { @MainActor in + for await _ in NotificationCenter.default.notifications( + named: UIApplication.didBecomeActiveNotification + ) { + appDidBecomeActive() + } + } + ) + lifecycleTasks.append( + Task { @MainActor in + for await _ in NotificationCenter.default.notifications( + named: UIApplication.didEnterBackgroundNotification + ) { + appDidEnterBackground() + } + } + ) + } + + private func detachLifecycleObservers() { + if !lifecycleAttached { return } + lifecycleAttached = false + lifecycleTasks.forEach { $0.cancel() } + lifecycleTasks.removeAll() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func initPanoramaView() { + onMain { + if self.panoramaViewInitialized { return } + self.panoramaViewInitialized = true + + let props = self.streetViewInitialProps + if let position = props?.position { + let coordinate = position.toCLLocationCoordinate2D() + let source = props?.source?.toGMSPanoramaSource ?? .default + if let radius = props?.radius { + self.panoramaView = GMSPanoramaView.panorama(withFrame: self.bounds, nearCoordinate: coordinate, radius: UInt(radius), source: source) + } else { + self.panoramaView = GMSPanoramaView.panorama(withFrame: self.bounds, nearCoordinate: coordinate, source: source) + } + } else { + self.panoramaView = GMSPanoramaView(frame: self.bounds) + } + + props?.panoramaId.map { self.panoramaView?.move(toPanoramaID: $0) } + props?.camera.map { self.panoramaView?.camera = $0.toGMSPanoramaCamera() } + + self.panoramaView?.delegate = self + self.panoramaView?.autoresizingMask = [.flexibleWidth, .flexibleHeight] + self.panoramaView.map { self.addSubview($0) } + + self.applyProps() + self.initLocationCallbacks() + self.onPanoramaReady?(true) + } + } + + private func initLocationCallbacks() { + locationHandler.onUpdate = { [weak self] loc in + onMain { [weak self] in + self?.onLocationUpdate?(loc.toRnLocation()) + } + } + + locationHandler.onError = { [weak self] error in + onMain { [weak self] in + self?.onLocationError?(error) + } + } + } + + private func applyProps() { + ({ self.uiSettings = self.uiSettings })() + } + + var currentCamera: GMSPanoramaCamera? { panoramaView?.camera } + + var streetViewInitialProps: RNStreetViewInitialProps? + + var uiSettings: RNStreetViewUiSettings? { + didSet { + onMain { + self.panoramaView?.streetNamesHidden = !(self.uiSettings?.streetNamesEnabled ?? true) + self.panoramaView?.navigationLinksHidden = !(self.uiSettings?.userNavigationEnabled ?? true) + self.panoramaView?.orientationGestures = self.uiSettings?.panningGesturesEnabled ?? true + self.panoramaView?.zoomGestures = self.uiSettings?.zoomGesturesEnabled ?? true + } + } + } + + var onPanoramaReady: ((Bool) -> Void)? + var onLocationUpdate: ((RNLocation) -> Void)? + var onLocationError: ((RNLocationErrorCode) -> Void)? + var onPanoramaChange: ((RNStreetViewPanoramaLocation) -> Void)? + var onCameraChange: ((RNStreetViewCamera) -> Void)? + var onPanoramaPress: ((RNStreetViewOrientation) -> Void)? + + func setCamera(_ camera: GMSPanoramaCamera, animated: Bool, durationMs: Double) { + onMain { + if animated { + withCATransaction(disableActions: false, duration: durationMs / 1000.0) { + self.panoramaView?.camera = camera + } + } else { + self.panoramaView?.camera = camera + } + } + } + + func setPosition(_ coordinate: CLLocationCoordinate2D, radius: UInt?, source: GMSPanoramaSource) { + onMain { + if let radius { + self.panoramaView?.moveNearCoordinate(coordinate, radius: radius, source: source) + } else { + self.panoramaView?.moveNearCoordinate(coordinate, source: source) + } + } + } + + func setPositionById(_ panoramaId: String) { + onMain { + self.panoramaView?.move(toPanoramaID: panoramaId) + } + } + + func deinitInternal() { + guard !deInitialized else { return } + deInitialized = true + detachLifecycleObservers() + onMain { + self.locationHandler.stop() + self.panoramaView?.delegate = nil + self.panoramaView = nil + } + } + + private func appDidBecomeActive() { + if window != nil { + locationHandler.start() + } + } + + private func appDidEnterBackground() { + locationHandler.stop() + } + + override func didMoveToWindow() { + super.didMoveToWindow() + if window != nil { + attachLifecycleObservers() + locationHandler.start() + initPanoramaView() + } else { + locationHandler.stop() + detachLifecycleObservers() + } + } + + deinit { + deinitInternal() + } + + func panoramaView(_ panoramaView: GMSPanoramaView, didMoveTo panorama: GMSPanorama?) { + onMain { + guard let panorama else { return } + let links = panorama.links.map { link in + RNStreetViewPanoramaLink(bearing: link.heading, panoramaId: link.panoramaID) + } + self.onPanoramaChange?( + RNStreetViewPanoramaLocation( + position: panorama.coordinate.toRNLatLng(), + panoramaId: panorama.panoramaID, + links: links + ) + ) + } + } + + func panoramaView(_ panoramaView: GMSPanoramaView, error: Error, onMoveNearCoordinate coordinate: CLLocationCoordinate2D) { + mapErrorHandler.report(.panoramaNotFound, error.localizedDescription, error) + } + + func panoramaView(_ panoramaView: GMSPanoramaView, error: Error, onMoveToPanoramaID panoramaID: String) { + mapErrorHandler.report(.panoramaNotFound, error.localizedDescription, error) + } + + func panoramaView(_ panoramaView: GMSPanoramaView, didMove camera: GMSPanoramaCamera) { + onMain { + self.onCameraChange?( + RNStreetViewCamera( + bearing: camera.orientation.heading, + tilt: camera.orientation.pitch, + zoom: Double(camera.zoom) + ) + ) + } + } + + func panoramaView(_ panoramaView: GMSPanoramaView, didTap point: CGPoint) { + onMain { + let orientation = panoramaView.orientation(for: point) + self.onPanoramaPress?(RNStreetViewOrientation(bearing: orientation.heading, tilt: orientation.pitch)) + } + } +} diff --git a/ios/extensions/RNStreetViewCamera+Extension.swift b/ios/extensions/RNStreetViewCamera+Extension.swift new file mode 100644 index 0000000..c42aa66 --- /dev/null +++ b/ios/extensions/RNStreetViewCamera+Extension.swift @@ -0,0 +1,11 @@ +import GoogleMaps + +extension RNStreetViewCamera { + func toGMSPanoramaCamera(current: GMSPanoramaCamera? = nil) -> GMSPanoramaCamera { + return GMSPanoramaCamera( + heading: bearing ?? current?.orientation.heading ?? 0, + pitch: tilt ?? current?.orientation.pitch ?? 0, + zoom: Float(zoom ?? current.map { Double($0.zoom) } ?? 0) + ) + } +} diff --git a/ios/extensions/RNStreetViewSource+Extension.swift b/ios/extensions/RNStreetViewSource+Extension.swift new file mode 100644 index 0000000..49cbf92 --- /dev/null +++ b/ios/extensions/RNStreetViewSource+Extension.swift @@ -0,0 +1,10 @@ +import GoogleMaps + +extension RNStreetViewSource { + var toGMSPanoramaSource: GMSPanoramaSource { + switch self { + case .default: return .default + case .outdoor: return .outside + } + } +} diff --git a/nitro.json b/nitro.json index 3088a26..6413585 100644 --- a/nitro.json +++ b/nitro.json @@ -12,6 +12,10 @@ { "name": "RNGoogleMapsPlusView", "platforms": ["ios", "android"] + }, + { + "name": "RNGoogleMapsPlusStreetView", + "platforms": ["ios", "android"] } ], "autolinking": { @@ -25,6 +29,16 @@ "implementationClassName": "RNGoogleMapsPlusView" } }, + "RNGoogleMapsPlusStreetView": { + "ios": { + "language": "swift", + "implementationClassName": "RNGoogleMapsPlusStreetView" + }, + "android": { + "language": "kotlin", + "implementationClassName": "RNGoogleMapsPlusStreetView" + } + }, "RNGoogleMapsPlusModule": { "ios": { "language": "swift", diff --git a/package.json b/package.json index 6409e67..a4e5ac9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "react-native-google-maps-plus", "version": "1.12.1", - "description": "React Native wrapper for Android & iOS Google Maps SDK", + "description": "React Native wrapper for Android & iOS Google Maps SDK with Street View support", "main": "./lib/module/index.js", "module": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -35,6 +35,9 @@ "react-native-google-maps", "react-native-google-maps-plus", "google-maps-sdk", + "street-view", + "google-street-view", + "streetview", "android", "ios", "maps", diff --git a/src/GoogleMapsPlus.tsx b/src/GoogleMapsPlus.tsx index 61d3bd3..2a75eb3 100644 --- a/src/GoogleMapsPlus.tsx +++ b/src/GoogleMapsPlus.tsx @@ -1,8 +1,13 @@ import { NitroModules, getHostComponent } from 'react-native-nitro-modules'; -import ViewConfig from '../nitrogen/generated/shared/json/RNGoogleMapsPlusViewConfig.json' with { type: 'json' }; +import StreetViewConfig from '../nitrogen/generated/shared/json/RNGoogleMapsPlusStreetViewConfig.json' with { type: 'json' }; +import MapViewConfig from '../nitrogen/generated/shared/json/RNGoogleMapsPlusViewConfig.json' with { type: 'json' }; import type { RNGoogleMapsPlusModule } from './RNGoogleMapsPlusModule.nitro.js'; +import type { + RNGoogleMapsPlusStreetViewMethods, + RNGoogleMapsPlusStreetViewProps, +} from './RNGoogleMapsPlusStreetView.nitro'; import type { RNGoogleMapsPlusViewMethods, RNGoogleMapsPlusViewProps, @@ -10,16 +15,47 @@ import type { /** * Native Google Maps view. - * Direct bindings to the underlying Google Maps SDKs. + * Uses the native Google Maps SDKs on Android and iOS. + * @example Map View + * ```tsx + * + * ``` */ export const GoogleMapsView = getHostComponent< RNGoogleMapsPlusViewProps, RNGoogleMapsPlusViewMethods ->('RNGoogleMapsPlusView', () => ViewConfig); +>('RNGoogleMapsPlusView', () => MapViewConfig); + +/** + * Native Google Maps Street View component. + * Uses the native Google Maps SDKs on Android and iOS. + * @example Street View + * ```tsx + * + * ``` + */ +export const GoogleMapsStreetView = getHostComponent< + RNGoogleMapsPlusStreetViewProps, + RNGoogleMapsPlusStreetViewMethods +>('RNGoogleMapsPlusStreetView', () => StreetViewConfig); /** - * Platform-level module. - * Exposes system APIs such as permissions and Play Services checks. + * Platform module. + * Exposes system APIs such as location permissions, location settings, and Play Services checks. */ export const GoogleMapsModule = NitroModules.createHybridObject( diff --git a/src/RNGoogleMapsPlusModule.nitro.ts b/src/RNGoogleMapsPlusModule.nitro.ts index 909c41d..0693235 100644 --- a/src/RNGoogleMapsPlusModule.nitro.ts +++ b/src/RNGoogleMapsPlusModule.nitro.ts @@ -3,7 +3,7 @@ import type { HybridObject } from 'react-native-nitro-modules'; /** * Platform utilities for react-native-google-maps-plus. - * Provides system-level operations unrelated to a specific map instance. + * Provides system operations unrelated to a specific map instance. */ export interface RNGoogleMapsPlusModule extends HybridObject<{ ios: 'swift'; @@ -14,18 +14,26 @@ export interface RNGoogleMapsPlusModule extends HybridObject<{ /** * Opens the OS location settings. + * * iOS: opens the app settings. * Android: opens system location settings. */ openLocationSettings(): void; - /** Requests runtime location permission. */ + /** + * Requests runtime location permission. + * + * @returns The permission result per platform. See {@link RNLocationPermissionResult}. + */ requestLocationPermission(): Promise; /** * Checks Google Play Services availability. - * iOS: always returns false. + * + * iOS: always returns `false`. * Android: performs a real system check. + * + * @returns `true` if Google Play Services are available, otherwise `false`. */ isGooglePlayServicesAvailable(): boolean; } diff --git a/src/RNGoogleMapsPlusStreetView.nitro.ts b/src/RNGoogleMapsPlusStreetView.nitro.ts new file mode 100644 index 0000000..8ce7565 --- /dev/null +++ b/src/RNGoogleMapsPlusStreetView.nitro.ts @@ -0,0 +1,133 @@ +import type { + RNLatLng, + RNLocation, + RNLocationErrorCode, + RNLocationPermissionResult, + RNMapErrorCode, + RNStreetViewCamera, + RNStreetViewInitialProps, + RNStreetViewOrientation, + RNStreetViewPanoramaLocation, + RNStreetViewSource, + RNStreetViewUiSettings, +} from './types'; +import type { + HybridView, + HybridViewMethods, + HybridViewProps, +} from 'react-native-nitro-modules'; + +/** + * Native Google Maps Street View props. + */ +export interface RNGoogleMapsPlusStreetViewProps extends HybridViewProps { + /** Initial panorama configuration. See {@link RNStreetViewInitialProps}. */ + initialProps?: RNStreetViewInitialProps; + + /** UI and gesture settings. See {@link RNStreetViewUiSettings}. */ + uiSettings?: RNStreetViewUiSettings; + + /** Panorama is initialized and ready to use. */ + onPanoramaReady?: (ready: boolean) => void; + + /** Location update. */ + onLocationUpdate?: (location: RNLocation) => void; + + /** Location subsystem error. */ + onLocationError?: (error: RNLocationErrorCode) => void; + + /** + * User or programmatic navigation moved to a new panorama. + * Provides full location info including panoramaId and adjacent links. + */ + onPanoramaChange?: (location: RNStreetViewPanoramaLocation) => void; + + /** + * Camera orientation changed. + * Fires continuously while the user rotates or tilts the view. + */ + onCameraChange?: (camera: RNStreetViewCamera) => void; + + /** + * Tap on the panorama. + * Orientation indicates where on the sphere the user tapped. + */ + onPanoramaPress?: (orientation: RNStreetViewOrientation) => void; + + /** Native error. See {@link RNMapErrorCode}. */ + onPanoramaError?: (error: RNMapErrorCode, msg: string) => void; +} + +/** + * Imperative Street View methods. + */ +export interface RNGoogleMapsPlusStreetViewMethods extends HybridViewMethods { + /** + * Sets the Street View camera orientation. + * + * @param camera - Target bearing, tilt, and zoom. + * @param animated - Whether to animate. + * @defaultValue `false` + * @param durationMs - Animation duration in milliseconds. + * @defaultValue `1000` + */ + setCamera( + camera: RNStreetViewCamera, + animated?: boolean, + durationMs?: number + ): void; + + /** + * Navigates to a panorama near the given position. + * Use this for post initialization navigation with full control over the search. + * + * @param position - Target coordinate used to search for a Street View panorama. See {@link RNLatLng}. + * @param radius - Search radius in meters. + * @defaultValue `50` + * @param source - Panorama source filter. + * @defaultValue `'default'` + */ + setPosition( + position: RNLatLng, + radius?: number, + source?: RNStreetViewSource + ): void; + + /** + * Navigates to a specific panorama by its ID. + * + * @param panoramaId - Street View panorama identifier. + */ + setPositionById(panoramaId: string): void; + + /** Shows a native system dialog prompting the user to enable location services. */ + showLocationDialog(): void; + + /** + * Opens the OS location settings. + * + * iOS: opens the app settings. + * Android: opens system location settings. + */ + openLocationSettings(): void; + + /** + * Requests runtime location permission. + * @returns The permission result per platform. See {@link RNLocationPermissionResult}. + */ + requestLocationPermission(): Promise; + + /** + * Checks Google Play Services availability. + * iOS: always returns `false`. + * Android: performs a real system check. + * @returns `true` if Google Play Services are available, otherwise `false`. + */ + isGooglePlayServicesAvailable(): boolean; +} + +/** Typed hybrid Street View. */ +export type RNGoogleMapsPlusStreetView = HybridView< + RNGoogleMapsPlusStreetViewProps, + RNGoogleMapsPlusStreetViewMethods +>; diff --git a/src/RNGoogleMapsPlusView.nitro.ts b/src/RNGoogleMapsPlusView.nitro.ts index 981cb1e..f64984e 100644 --- a/src/RNGoogleMapsPlusView.nitro.ts +++ b/src/RNGoogleMapsPlusView.nitro.ts @@ -44,19 +44,34 @@ export interface RNGoogleMapsPlusViewProps extends HybridViewProps { /** UI and gesture settings. See {@link RNMapUiSettings}. */ uiSettings?: RNMapUiSettings; - /** Enables "My Location" blue dot. */ + /** + * Enables "My Location" blue dot. + * @defaultValue `false` + */ myLocationEnabled?: boolean; - /** Enables 3D buildings. */ + /** + * Enables 3D buildings. + * @defaultValue `true` + */ buildingEnabled?: boolean; - /** Enables traffic layer. */ + /** + * Enables traffic layer. + * @defaultValue `false` + */ trafficEnabled?: boolean; - /** Enables indoor maps. */ + /** + * Enables indoor maps. + * @defaultValue `true` + */ indoorEnabled?: boolean; - /** Enables transit layer. */ + /** + * Enables transit layer. + * @defaultValue `false` + */ transitEnabled?: boolean; /** @@ -74,7 +89,11 @@ export interface RNGoogleMapsPlusViewProps extends HybridViewProps { /** Map padding. See {@link RNMapPadding}. */ mapPadding?: RNMapPadding; - /** Base map type. See {@link RNMapType}. */ + /** + * Base map type. + * @defaultValue `'normal'` + * See {@link RNMapType}. + */ mapType?: RNMapType; /** Markers. See {@link RNMarker}. */ @@ -194,13 +213,31 @@ export interface RNGoogleMapsPlusViewProps extends HybridViewProps { * Direct calls into native Google Maps SDK. */ export interface RNGoogleMapsPlusViewMethods extends HybridViewMethods { + /** + * Shows the info window for a marker. + * + * @param id - Marker identifier. + */ showMarkerInfoWindow(id: string): void; + + /** + * Hides the info window for a marker. + * + * @param id - Marker identifier. + */ hideMarkerInfoWindow(id: string): void; /** * Sets the camera. + * * iOS: adds an explicit animation phase for parity. * Android: uses native timing. + * + * @param camera - Target camera update. See {@link RNCameraUpdate}. + * @param animated - Whether to animate. + * @defaultValue `false` + * @param durationMs - Animation duration in milliseconds. + * @defaultValue `1000` */ setCamera( camera: RNCameraUpdate, @@ -210,8 +247,16 @@ export interface RNGoogleMapsPlusViewMethods extends HybridViewMethods { /** * Fits the camera to the given coordinates. + * * iOS: adds an explicit animation phase for parity. * Android: uses native timing. + * + * @param coordinates - Coordinates the camera should fit. + * @param padding - Padding around the fitted area. See {@link RNMapPadding}. + * @param animated - Whether to animate. + * @defaultValue `false` + * @param durationMs - Animation duration in milliseconds. + * @defaultValue `1000` */ setCameraToCoordinates( coordinates: RNLatLng[], @@ -220,13 +265,27 @@ export interface RNGoogleMapsPlusViewMethods extends HybridViewMethods { durationMs?: number ): void; - /** Restricts camera target bounds. */ + /** + * Restricts the camera target bounds. + * Pass `undefined` to clear the restriction. + * + * @param bounds - Bounds to restrict the camera target to. See {@link RNLatLngBounds}. + */ setCameraBounds(bounds?: RNLatLngBounds): void; /** - * Animates camera to bounds. + * Animates the camera to fit the given bounds. + * * iOS: adds an explicit animation phase for parity. * Android: uses native timing. + * + * @param bounds - Target bounds the camera should fit. See {@link RNLatLngBounds}. + * @param padding - Padding in logical units. + * @defaultValue `0` + * @param durationMs - Animation duration in milliseconds. + * @defaultValue `1000` + * @param lockBounds - Restricts the camera to these bounds after animating. + * @defaultValue `false` */ animateToBounds( bounds: RNLatLngBounds, @@ -235,26 +294,36 @@ export interface RNGoogleMapsPlusViewMethods extends HybridViewMethods { lockBounds?: boolean ): void; - /** Snapshot of current frame. */ + /** + * Captures a snapshot of the current map frame. + * + * @param options - Snapshot output configuration. See {@link RNSnapshotOptions}. + * @returns Base64 string or file URI, depending on {@link RNSnapshotOptions.resultType}. + */ snapshot(options: RNSnapshotOptions): Promise; - /** Native location-settings dialog. */ + /** Shows a native system dialog prompting the user to enable location services. */ showLocationDialog(): void; /** * Opens the OS location settings. + * * iOS: opens the app settings. * Android: opens system location settings. */ openLocationSettings(): void; - /** Requests runtime location permission. */ + /** + * Requests runtime location permission. + * @returns The permission result per platform. See {@link RNLocationPermissionResult}. + */ requestLocationPermission(): Promise; /** * Checks Google Play Services availability. - * iOS: always returns false. + * iOS: always returns `false`. * Android: performs a real system check. + * @returns `true` if Google Play Services are available, otherwise `false`. */ isGooglePlayServicesAvailable(): boolean; } diff --git a/src/index.tsx b/src/index.tsx index d113b4b..38d459c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -70,8 +70,7 @@ * Android: AndroidSVG * * - * # Usage Example - * + * @example Map View * ```tsx * * ``` * + * @example Street View + * ```tsx + * + * ``` + * * Check out the example app in the [example directory](https://github.com/pinpong/react-native-google-maps-plus/tree/main/example). */ -import { GoogleMapsModule, GoogleMapsView } from './GoogleMapsPlus'; +import { + GoogleMapsModule, + GoogleMapsView, + GoogleMapsStreetView, +} from './GoogleMapsPlus'; import type { RNGoogleMapsPlusModule } from './RNGoogleMapsPlusModule.nitro'; +import type { + RNGoogleMapsPlusStreetViewMethods, + RNGoogleMapsPlusStreetViewProps, +} from './RNGoogleMapsPlusStreetView.nitro'; import type { RNGoogleMapsPlusViewMethods, RNGoogleMapsPlusViewProps, @@ -100,7 +118,9 @@ export * from './types'; export type { RNGoogleMapsPlusViewMethods, RNGoogleMapsPlusViewProps, + RNGoogleMapsPlusStreetViewMethods, + RNGoogleMapsPlusStreetViewProps, RNGoogleMapsPlusModule, }; -export { GoogleMapsView, GoogleMapsModule }; +export { GoogleMapsView, GoogleMapsStreetView, GoogleMapsModule }; diff --git a/src/types.ts b/src/types.ts index ffd0d0b..64d557b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,12 +5,17 @@ * Behavior follows the underlying SDKs unless explicitly documented otherwise. */ +import type { RNGoogleMapsPlusStreetViewMethods } from './RNGoogleMapsPlusStreetView.nitro'; import type { RNGoogleMapsPlusViewMethods } from './RNGoogleMapsPlusView.nitro'; import type { HybridView } from 'react-native-nitro-modules'; /** Reference to the native Google Maps view. */ export type GoogleMapsViewRef = HybridView; +/** Reference to the native Google Maps Street View. */ +export type GoogleMapsStreetViewRef = + HybridView; + /** Initial map configuration. */ export type RNInitialProps = { /** Map instance identifier. */ @@ -21,6 +26,7 @@ export type RNInitialProps = { * * Android: supported. * iOS: not supported. + * @defaultValue `false` */ liteMode?: boolean; @@ -36,13 +42,22 @@ export type RNInitialProps = { /** UI and gesture settings. */ export type RNMapUiSettings = { - /** Enables or disables all gestures. */ + /** + * Enables or disables all gestures. + * @defaultValue `true` + */ allGesturesEnabled?: boolean; - /** Shows the compass. */ + /** + * Shows the compass. + * @defaultValue `true` + */ compassEnabled?: boolean; - /** Enables the indoor level picker. */ + /** + * Enables the indoor level picker. + * @defaultValue `true` + */ indoorLevelPickerEnabled?: boolean; /** @@ -50,22 +65,38 @@ export type RNMapUiSettings = { * * Android: supported. * iOS: not supported. + * @defaultValue `true` */ mapToolbarEnabled?: boolean; - /** Enables the "My Location" button. */ + /** + * Enables the "My Location" button. + * @defaultValue `true` + */ myLocationButtonEnabled?: boolean; - /** Enables rotation gestures. */ + /** + * Enables rotation gestures. + * @defaultValue `true` + */ rotateEnabled?: boolean; - /** Enables scroll gestures. */ + /** + * Enables scroll gestures. + * @defaultValue `true` + */ scrollEnabled?: boolean; - /** Enables scroll during rotate/zoom gestures. */ + /** + * Enables scroll during rotate/zoom gestures. + * @defaultValue `true` + */ scrollDuringRotateOrZoomEnabled?: boolean; - /** Enables tilt gestures. */ + /** + * Enables tilt gestures. + * @defaultValue `true` + */ tiltEnabled?: boolean; /** @@ -73,35 +104,31 @@ export type RNMapUiSettings = { * * Android: supported. * iOS: not supported. + * @defaultValue `true` */ zoomControlsEnabled?: boolean; - /** Enables pinch zoom gestures. */ + /** + * Enables pinch zoom gestures. + * @defaultValue `true` + */ zoomGesturesEnabled?: boolean; /** * Consumes the press event. - * - * When enabled: - * - Native map UI does NOT execute its default behavior. - * - Only the JS callback is triggered. - * - * When disabled: - * - JS callback is triggered. - * - Native default behavior runs (e.g. camera move, info window, selection). + * @remarks + * When enabled: native map UI does not execute its default behavior; only the JS callback fires. + * When disabled: JS callback fires and native behavior runs (camera move, info window, selection). + * @defaultValue `false` */ consumeOnMarkerPress?: boolean; /** * Consumes the My-Location-button press event. - * - * When enabled: - * - Native map does NOT perform its default camera move. - * - Only the JS callback is triggered. - * - * When disabled: - * - JS callback is triggered. - * - Native behavior runs (camera animates to user location). + * @remarks + * When enabled: native map UI does not execute its default behavior; only the JS callback fires. + * When disabled: JS callback fires and native behavior runs (camera animates to user location). + * @defaultValue `false` */ consumeOnMyLocationButtonPress?: boolean; }; @@ -132,19 +159,21 @@ export type RNLatLngBounds = { /** Snapshot configuration. */ export type RNSnapshotOptions = { - /** Output size. + /** + * Output size. + * @defaultValue Uses the full view size. * * See {@link RNSize}. */ size?: RNSize; - /** Image format. */ + /** Image format. See {@link RNSnapshotFormat}. */ format: RNSnapshotFormat; /** Image quality (0–100). */ quality: number; - /** Result return type. */ + /** Result return type. See {@link RNSnapshotResultType}. */ resultType: RNSnapshotResultType; }; @@ -212,19 +241,29 @@ export type RNCamera = { /** Partial camera update. */ export type RNCameraUpdate = { - /** Camera target coordinate. - * + /** + * Camera target coordinate. + * @defaultValue Current center. * See {@link RNLatLng}. */ center?: RNLatLng; - /** Zoom level. */ + /** + * Zoom level. + * @defaultValue Current zoom. + */ zoom?: number; - /** Bearing in degrees. */ + /** + * Bearing in degrees. + * @defaultValue Current bearing. + */ bearing?: number; - /** Tilt angle in degrees. */ + /** + * Tilt angle in degrees. + * @defaultValue Current tilt. + */ tilt?: number; }; @@ -272,10 +311,16 @@ export type RNPosition = { /** Zoom configuration. */ export type RNMapZoomConfig = { - /** Minimum zoom level. */ + /** + * Minimum zoom level. + * @defaultValue SDK minimum, usually around `2`. + */ min?: number; - /** Maximum zoom level. */ + /** + * Maximum zoom level. + * @defaultValue SDK maximum, usually around `21`. + */ max?: number; }; @@ -304,7 +349,10 @@ export type RNMarker = { /** Unique marker identifier. */ id: string; - /** Z-index used for rendering order. */ + /** + * Z-index used for rendering order. + * @defaultValue `0` + */ zIndex?: number; /** Marker coordinate. @@ -316,6 +364,7 @@ export type RNMarker = { /** * Anchor point relative to the marker icon. * (0,0) = top-left, (1,1) = bottom-right. + * @defaultValue `(0.5, 1.0)` = bottom-center * * See {@link RNPosition}. */ @@ -327,30 +376,43 @@ export type RNMarker = { /** Marker snippet / subtitle. */ snippet?: string; - /** Icon opacity in the range [0, 1]. */ + /** + * Icon opacity in the range [0, 1]. + * @defaultValue `1.0` + */ opacity?: number; - /** Draws the marker flat against the map. */ + /** + * Draws the marker flat against the map. + * @defaultValue `false` + */ flat?: boolean; - /** Enables marker dragging. */ + /** + * Enables marker dragging. + * @defaultValue `false` + */ draggable?: boolean; - /** Rotation angle in degrees. */ + /** + * Rotation angle in degrees. + * @defaultValue `0` + */ rotation?: number; /** * Info window anchor relative to the marker. * (0,0) = top-left, (1,1) = bottom-right. + * @defaultValue `(0.5, 0.0)` = top-center * * See {@link RNPosition}. */ infoWindowAnchor?: RNPosition; - /** Marker icon rendered from an SVG string. */ + /** Marker icon rendered from an SVG string. See {@link RNMarkerSvg}. */ iconSvg?: RNMarkerSvg; - /** Info window content rendered from an SVG string. */ + /** Info window content rendered from an SVG string. See {@link RNMarkerSvg}. */ infoWindowIconSvg?: RNMarkerSvg; }; @@ -391,10 +453,16 @@ export type RNPolygon = { /** Unique polygon identifier. */ id: string; - /** Z-index used for rendering order. */ + /** + * Z-index used for rendering order. + * @defaultValue `0` + */ zIndex?: number; - /** Enables polygon press events. */ + /** + * Enables polygon press events. + * @defaultValue `false` + */ pressable?: boolean; /** Polygon vertices. @@ -425,7 +493,10 @@ export type RNPolygon = { */ holes?: RNPolygonHole[]; - /** Draws geodesic edges. */ + /** + * Draws geodesic edges. + * @defaultValue `false` + */ geodesic?: boolean; }; @@ -443,10 +514,16 @@ export type RNPolyline = { /** Unique polyline identifier. */ id: string; - /** Z-index used for rendering order. */ + /** + * Z-index used for rendering order. + * @defaultValue `0` + */ zIndex?: number; - /** Enables polyline press events. */ + /** + * Enables polyline press events. + * @defaultValue `false` + */ pressable?: boolean; /** Polyline vertices. @@ -455,13 +532,24 @@ export type RNPolyline = { */ coordinates: RNLatLng[]; - /** Line cap style. */ + /** + * Line cap style. + * @defaultValue `'butt'` + * See {@link RNLineCapType}. + */ lineCap?: RNLineCapType; - /** Line join style. */ + /** + * Line join style. + * @defaultValue `'miter'` + * See {@link RNLineJoinType}. + */ lineJoin?: RNLineJoinType; - /** Draws a geodesic path. */ + /** + * Draws a geodesic path. + * @defaultValue `false` + */ geodesic?: boolean; /** Line color. */ @@ -483,10 +571,16 @@ export type RNCircle = { /** Unique circle identifier. */ id: string; - /** Enables circle press events. */ + /** + * Enables circle press events. + * @defaultValue `false` + */ pressable?: boolean; - /** Z-index used for rendering order. */ + /** + * Z-index used for rendering order. + * @defaultValue `0` + */ zIndex?: number; /** Circle center. @@ -520,10 +614,16 @@ export type RNHeatmap = { /** Unique heatmap identifier. */ id: string; - /** Enables heatmap press events. */ + /** + * Enables heatmap press events. + * @defaultValue `false` + */ pressable?: boolean; - /** Z-index used for rendering order. */ + /** + * Z-index used for rendering order. + * @defaultValue `0` + */ zIndex?: number; /** Weighted heatmap points. @@ -532,10 +632,16 @@ export type RNHeatmap = { */ weightedData: RNHeatmapPoint[]; - /** Radius used for each point. */ + /** + * Radius used for each point. + * @defaultValue `20` + */ radius?: number; - /** Overall heatmap opacity. */ + /** + * Overall heatmap opacity. + * @defaultValue `0.7` + */ opacity?: number; /** Gradient configuration. @@ -583,7 +689,10 @@ export type RNUrlTileOverlay = { /** Unique tile overlay identifier. */ id: string; - /** Z-index used for rendering order. */ + /** + * Z-index used for rendering order. + * @defaultValue `0` + */ zIndex?: number; /** URL template for tiles. */ @@ -592,10 +701,16 @@ export type RNUrlTileOverlay = { /** Tile size in pixels. */ tileSize: number; - /** Overlay opacity in the range [0, 1]. */ + /** + * Overlay opacity in the range [0, 1]. + * @defaultValue `1.0` + */ opacity?: number; - /** Enables fade-in animation when tiles load. */ + /** + * Enables fade-in animation when tiles load. + * @defaultValue `true` + */ fadeIn?: boolean; }; @@ -653,14 +768,21 @@ export type RNLocationConfig = { export type RNAndroidLocationConfig = { /** * Requested location priority. + * @defaultValue `PRIORITY_HIGH_ACCURACY`. * See {@link RNAndroidLocationPriority}. */ priority?: RNAndroidLocationPriority; - /** Desired update interval in milliseconds. */ + /** + * Desired update interval in milliseconds. + * @defaultValue `5000` + */ interval?: number; - /** Minimum update interval in milliseconds. */ + /** + * Minimum update interval in milliseconds. + * @defaultValue `0` + */ minUpdateInterval?: number; }; @@ -680,15 +802,20 @@ export enum RNAndroidLocationPriority { export type RNIOSLocationConfig = { /** * Desired accuracy level. + * @defaultValue `ACCURACY_BEST` * See {@link RNIOSLocationAccuracy}. */ desiredAccuracy?: RNIOSLocationAccuracy; - /** Minimum distance in meters before a new update is delivered. */ + /** + * Minimum distance in meters before a new update is delivered. + * @defaultValue `0` (no filter) + */ distanceFilterMeters?: number; /** * Activity type used to optimize location updates. + * @defaultValue `OTHER` * See {@link RNIOSLocationActivityType}. */ activityType?: RNIOSLocationActivityType; @@ -845,6 +972,122 @@ export type RNLocationIOS = { timestamp?: number; }; +/** UI and gesture settings for Street View. */ +export type RNStreetViewUiSettings = { + /** + * Show street name overlays. + * @defaultValue `true` + */ + streetNamesEnabled?: boolean; + + /** + * Allow navigating between panoramas by tapping arrows. + * @defaultValue `true` + */ + userNavigationEnabled?: boolean; + + /** + * Allow panning (rotating) the panorama. + * @defaultValue `true` + */ + panningGesturesEnabled?: boolean; + + /** + * Allow zooming in/out. + * @defaultValue `true` + */ + zoomGesturesEnabled?: boolean; +}; + +/** Street View panorama source filter. */ +export type RNStreetViewSource = 'default' | 'outdoor'; + +/** Initial Street View configuration. Set once before the view mounts. */ +export type RNStreetViewInitialProps = { + /** + * Load a specific panorama by ID. + * Takes priority over position when set. + */ + panoramaId?: string; + + /** Initial panorama position. Ignored when panoramaId is set. + * See {@link RNLatLng}. + */ + position?: RNLatLng; + + /** + * Search radius in meters around position. + * @defaultValue `50` + */ + radius?: number; + + /** + * Restricts panoramas to the given source. + * @defaultValue `'default'` + * See {@link RNStreetViewSource}. + */ + source?: RNStreetViewSource; + + /** Initial camera orientation. Applied once at mount time. + * See {@link RNStreetViewCamera}. + */ + camera?: RNStreetViewCamera; +}; + +/** Street View tap/long-press orientation on the panorama sphere. */ +export type RNStreetViewOrientation = { + /** Compass heading in degrees (0–360). */ + bearing: number; + /** Vertical angle in degrees (−90 up, +90 down). */ + tilt: number; +}; + +/** Link to an adjacent panorama. */ +export type RNStreetViewPanoramaLink = { + /** Direction to the linked panorama in degrees (0–360). */ + bearing: number; + /** Panorama ID of the linked panorama. */ + panoramaId: string; +}; + +/** Full location info delivered by onPanoramaChange. */ +export type RNStreetViewPanoramaLocation = { + /** Geographic coordinate of the panorama. + * See {@link RNLatLng}. + */ + position: RNLatLng; + /** Panorama ID. */ + panoramaId: string; + /** Links to adjacent panoramas. + * See {@link RNStreetViewPanoramaLink}. + */ + links: RNStreetViewPanoramaLink[]; +}; + +/** Street View point of view. */ +export type RNStreetViewCamera = { + /** + * Compass heading in degrees (0–360). + * 0 = north, 90 = east, 180 = south, 270 = west. + * @defaultValue Current bearing., or `0` if no camera is set + */ + bearing?: number; + + /** + * Tilt angle in degrees. + * 0 = horizontal, positive = up, negative = down. + * Clamped to -90..90 by the SDK. + * @defaultValue Current tilt., or `0` if no camera is set + */ + tilt?: number; + + /** + * Zoom level. 0 = default field of view. + * @defaultValue Current zoom., or `0` if no camera is set + */ + zoom?: number; +}; + /** Location error codes. */ export enum RNLocationErrorCode { /** Location permission was denied by the user. */ @@ -906,4 +1149,7 @@ export enum RNMapErrorCode { /** KML layer failed to load or parse. */ KML_LAYER_FAILED = 12, + + /** Street View panorama not found at the given position or ID. */ + PANORAMA_NOT_FOUND = 13, }