diff --git a/android/src/main/java/com/luggmaps/LuggMapView.kt b/android/src/main/java/com/luggmaps/LuggMapView.kt index 4c66b59..3a72dc3 100644 --- a/android/src/main/java/com/luggmaps/LuggMapView.kt +++ b/android/src/main/java/com/luggmaps/LuggMapView.kt @@ -131,7 +131,7 @@ class LuggMapView(private val reactContext: ThemedReactContext) : // region Provider Initialization - private fun initializeProvider() { + private fun initializeProvider(latitude: Double = initialLatitude, longitude: Double = initialLongitude, zoom: Float = initialZoom) { if (provider != null || mapWrapperView == null) return if (staticMode) { @@ -146,7 +146,7 @@ class LuggMapView(private val reactContext: ThemedReactContext) : applyProps() - google.initializeMap(mapWrapperView!!, initialLatitude, initialLongitude, initialZoom) + google.initializeMap(mapWrapperView!!, latitude, longitude, zoom) // Flush children mounted before provider was created for (i in 0 until childCount) { @@ -371,6 +371,19 @@ class LuggMapView(private val reactContext: ThemedReactContext) : provider?.fitCoordinates(coordinates, edgeInsetsTop, edgeInsetsLeft, edgeInsetsBottom, edgeInsetsRight, duration) } + fun reload() { + val current = provider ?: return + val latitude = current.cameraLatitude + val longitude = current.cameraLongitude + val zoom = current.cameraZoom + + current.destroy() + provider = null + if (isAttachedToWindow) { + initializeProvider(latitude, longitude, zoom) + } + } + // endregion companion object { diff --git a/android/src/main/java/com/luggmaps/LuggMapViewManager.kt b/android/src/main/java/com/luggmaps/LuggMapViewManager.kt index 1ef6873..c82c91c 100644 --- a/android/src/main/java/com/luggmaps/LuggMapViewManager.kt +++ b/android/src/main/java/com/luggmaps/LuggMapViewManager.kt @@ -273,6 +273,10 @@ class LuggMapViewManager : ) } + override fun reload(view: LuggMapView) { + view.reload() + } + companion object { const val NAME = "LuggMapView" } diff --git a/android/src/main/java/com/luggmaps/LuggMapWrapperView.kt b/android/src/main/java/com/luggmaps/LuggMapWrapperView.kt index 3d52c7d..86d0fd6 100644 --- a/android/src/main/java/com/luggmaps/LuggMapWrapperView.kt +++ b/android/src/main/java/com/luggmaps/LuggMapWrapperView.kt @@ -2,6 +2,7 @@ package com.luggmaps import android.annotation.SuppressLint import android.view.MotionEvent +import android.view.View import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.views.view.ReactViewGroup @@ -34,6 +35,14 @@ class LuggMapWrapperView(context: ThemedReactContext) : ReactViewGroup(context) } } + override fun onViewAdded(child: View) { + super.onViewAdded(child) + // A replacement map needs sizing even when Yoga's layout is unchanged. + if (indexOfChild(child) == 0) { + layoutChild(width, height) + } + } + override fun onLayout( changed: Boolean, left: Int, diff --git a/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt b/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt index 370ba96..5c664ee 100644 --- a/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt +++ b/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt @@ -128,6 +128,7 @@ class GoogleMapProvider(private val context: Context) : // Theme private var theme: String = "system" + private var mapType: String = "standard" // Edge Insets private var edgeInsets: EdgeInsets = EdgeInsets() @@ -183,7 +184,7 @@ class GoogleMapProvider(private val context: Context) : view.onCreate(null) view.onResume() view.getMapAsync(this) - wrapper.addView(view) + wrapper.addView(view, 0) } wrapper.onLayoutReady = null } @@ -211,6 +212,9 @@ class GoogleMapProvider(private val context: Context) : groundOverlayToViewMap.clear() markerToViewMap.clear() wrapperView?.touchEventHandler = null + // The wrapper outlives the provider on reload; don't leave the dead map + // view in it + mapView?.let { wrapperView?.removeView(it) } wrapperView = null googleMap?.setOnCameraMoveStartedListener(null) googleMap?.setOnCameraMoveListener(null) @@ -276,6 +280,7 @@ class GoogleMapProvider(private val context: Context) : applyZoomLimits() applyInsetAdjustment() applyTheme() + setMapType(mapType) applyUserLocation() processPendingMarkers() processPendingPolylines() @@ -542,6 +547,7 @@ class GoogleMapProvider(private val context: Context) : } override fun setMapType(value: String) { + mapType = value googleMap?.mapType = when (value) { "satellite" -> GoogleMap.MAP_TYPE_SATELLITE "terrain" -> GoogleMap.MAP_TYPE_TERRAIN @@ -1201,6 +1207,24 @@ class GoogleMapProvider(private val context: Context) : // endregion + // region Camera + + // A static map's camera is inset-shifted (staticCameraTarget), so report + // the requested camera instead + private val liveCameraPosition: CameraPosition? + get() = if (staticMode) null else googleMap?.cameraPosition + + override val cameraLatitude: Double + get() = liveCameraPosition?.target?.latitude ?: initialLatitude + + override val cameraLongitude: Double + get() = liveCameraPosition?.target?.longitude ?: initialLongitude + + override val cameraZoom: Float + get() = liveCameraPosition?.zoom ?: initialZoom + + // endregion + // region Lifecycle override fun pauseAnimations() { diff --git a/android/src/main/java/com/luggmaps/core/MapProviderDelegate.kt b/android/src/main/java/com/luggmaps/core/MapProviderDelegate.kt index 7c0a132..0b8701c 100644 --- a/android/src/main/java/com/luggmaps/core/MapProviderDelegate.kt +++ b/android/src/main/java/com/luggmaps/core/MapProviderDelegate.kt @@ -58,6 +58,11 @@ interface MapProvider { fun addTileOverlayView(tileOverlayView: LuggTileOverlayView) fun removeTileOverlayView(tileOverlayView: LuggTileOverlayView) + // Current camera (the initial camera until the map is ready) + val cameraLatitude: Double + val cameraLongitude: Double + val cameraZoom: Float + // Lifecycle fun pauseAnimations() fun resumeAnimations() diff --git a/docs/content/docs/components/map-view.mdx b/docs/content/docs/components/map-view.mdx index 4544e38..e342df4 100644 --- a/docs/content/docs/components/map-view.mdx +++ b/docs/content/docs/components/map-view.mdx @@ -115,6 +115,11 @@ Notes: re-render from props). The camera and map settings are part of the cache key automatically. Changing `staticKey` discards the current image and re-renders. The cache is bounded by count and total memory. +- On iOS (Google), the base map is only captured and cached once the SDK + reports the map stable with all tiles loaded. If tiles fail (e.g. + offline), the live warmup map stays in place instead and renders again + the next time the row appears, so a bad render can't get stuck behind + `staticKey`. To force it, call `reload()` on the ref. - Alternatively, keeping rows mounted (larger `windowSize` with `removeClippedSubviews`) avoids re-rendering entirely at the cost of holding every row's snapshot in memory. @@ -273,6 +278,9 @@ mapRef.current?.setEdgeInsets( { top: 0, left: 0, bottom: 200, right: 0 }, { duration: 300 } ); + +// Load the map again (e.g. after a network outage) +mapRef.current?.reload(); ``` ### moveCamera @@ -313,6 +321,26 @@ interface SetEdgeInsetsOptions { } ``` +### reload + +Load the map again, e.g. to recover from missing tiles after a network +outage. None of the map SDKs can reload tiles in place, so: + +- A static map re-renders its base map. On iOS the cached snapshot for its + `staticKey` is discarded and the new render replaces the current image + when ready; on Android the lite map is recreated. +- A live map is recreated at its current coordinate and zoom. Heading and + pitch reset, and `onReady` fires again. Markers and overlays remain on + the map after reload. + +Reload preserves the map type and edge insets. On web, `onReady` fires once +for each new map instance. On iOS, an unfinished static reload restarts +when the map leaves the window and returns. + +```ts +reload(): void +``` + ## Events ### onPress / onLongPress diff --git a/example/shared/src/components/Map.tsx b/example/shared/src/components/Map.tsx index 06938ab..856ce63 100644 --- a/example/shared/src/components/Map.tsx +++ b/example/shared/src/components/Map.tsx @@ -297,6 +297,7 @@ export const Map = memo( moveCamera: (...args) => mapRef.current?.moveCamera(...args), fitCoordinates: (...args) => mapRef.current?.fitCoordinates(...args), setEdgeInsets: (...args) => mapRef.current?.setEdgeInsets(...args), + reload: () => mapRef.current?.reload(), showMarkerCallout: (markerId) => markerRefsMap.current.get(markerId)?.showCallout(), hideMarkerCallout: (markerId) => diff --git a/example/shared/src/screens/HomeScreen.tsx b/example/shared/src/screens/HomeScreen.tsx index 7d87e6a..b7d14d7 100644 --- a/example/shared/src/screens/HomeScreen.tsx +++ b/example/shared/src/screens/HomeScreen.tsx @@ -95,13 +95,15 @@ const HomeContent = ({ ); const handleMapReady = useCallback(() => { + lockStatus(); + setStatus({ text: 'Map ready', error: false }); const position = controlSheetRef.current?.animatedPosition; if (!position) return; const bottom = screenHeight - position.value; if (bottom > 0) { mapRef.current?.setEdgeInsets(bottomEdgeInsets(bottom)); } - }, [screenHeight]); + }, [lockStatus, screenHeight]); const handleSheetEvent = useCallback( (event: DetentChangeEvent) => { @@ -279,6 +281,7 @@ const HomeContent = ({ onClearMarkers={clear} onMoveCamera={moveToRandomMarker} onFitMarkers={fitAllMarkers} + onReload={() => mapRef.current?.reload()} onToggleMap={() => setShowMap((prev) => !prev)} onToggleProvider={() => setProvider((p) => (p === 'google' ? 'apple' : 'google')) diff --git a/example/shared/src/screens/StaticMapsScreen.tsx b/example/shared/src/screens/StaticMapsScreen.tsx index 17f1726..85401e5 100644 --- a/example/shared/src/screens/StaticMapsScreen.tsx +++ b/example/shared/src/screens/StaticMapsScreen.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { FlatList, Platform, @@ -262,6 +262,7 @@ const PlaceCard = ({ }) => { const { colors } = useTheme(); const { width } = useWindowDimensions(); + const mapRef = useRef(null); const camera = fittedCamera( placeCoordinates(place), @@ -281,6 +282,7 @@ const PlaceCard = ({ > + {/* Re-renders the base map, dropping its cached snapshot - e.g. to + recover a map that rendered with missing tiles */} + [ + styles.reload, + { backgroundColor: colors.backgroundElevated }, + pressed && styles.cardPressed, + ]} + onPress={() => mapRef.current?.reload()} + > + Reload + {place.name} @@ -361,6 +375,14 @@ const styles = StyleSheet.create({ map: { height: MAP_HEIGHT, }, + reload: { + position: 'absolute', + top: sizes.sm, + right: sizes.sm, + paddingHorizontal: sizes.md, + paddingVertical: sizes.xs, + borderRadius: sizes.radiusFull, + }, cardContent: { padding: sizes.lg, gap: sizes.xs, diff --git a/example/shared/src/sheets/ControlSheet.tsx b/example/shared/src/sheets/ControlSheet.tsx index 4d0577f..0b770f0 100644 --- a/example/shared/src/sheets/ControlSheet.tsx +++ b/example/shared/src/sheets/ControlSheet.tsx @@ -29,6 +29,7 @@ interface ControlSheetProps { onClearMarkers: () => void; onMoveCamera: () => void; onFitMarkers: () => void; + onReload: () => void; onToggleMap: () => void; onToggleProvider: () => void; onLoadGeojson: () => void; @@ -56,6 +57,7 @@ export const ControlSheet = forwardRef( onClearMarkers, onMoveCamera, onFitMarkers, + onReload, onToggleMap, onToggleProvider, onLoadGeojson, @@ -122,6 +124,12 @@ export const ControlSheet = forwardRef( onPress={onFitMarkers} disabled={markerCount === 0} /> +