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}
/>
+