Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
682d5af
feat(mapview): add staticMode prop for static maps in lists
lodev09 Jul 6, 2026
69facf3
perf(ios): throttle static map warmup, opaque snapshots; expand examp…
lodev09 Jul 6, 2026
e2e65fc
perf(ios): async static snapshots, staticKey cache, warmup placeholder
lodev09 Jul 6, 2026
63dcba3
fix(ios): guard MKPolylineAnimator against background draw races
lodev09 Jul 6, 2026
b01a98d
fix(mapview): low-risk static mode fixes from review
lodev09 Jul 6, 2026
4b25e18
fix(ios): gate static snapshot on full tile render
lodev09 Jul 7, 2026
a20653e
fix(polyline): render animated polylines as static on static maps
lodev09 Jul 7, 2026
83f7dc3
fix(android): render lite mode static maps (no mapId, honor child req…
lodev09 Jul 7, 2026
483c7f3
perf(ios): destroy off-window static maps mid-warmup, cap warmup conc…
lodev09 Jul 7, 2026
dfacf20
perf(ios): render apple static maps async with MKMapSnapshotter
lodev09 Jul 7, 2026
73a2422
perf(ios): pool GMSMapViews for static map warmups
lodev09 Jul 7, 2026
f8bba1a
perf(ios): render google static map markers as live overlays, async b…
lodev09 Jul 7, 2026
359a140
fix(android): hide live markers until lite map projection is valid
lodev09 Jul 7, 2026
ff263fa
fix(ios): reveal static map overlays only once the base map displays
lodev09 Jul 7, 2026
b24222a
perf(ios): drop static map warmup throttle, raise pool to 6
lodev09 Jul 7, 2026
e497c7f
fix(ios): reveal google static overlays when tiles render, not at swap
lodev09 Jul 7, 2026
95d0215
chore(example): add constellation polyline with dot markers to static…
lodev09 Jul 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions android/src/main/java/com/luggmaps/LuggMapView.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.luggmaps

import android.annotation.SuppressLint
import android.content.res.Configuration
import android.view.View
import android.view.View.VISIBLE
import com.facebook.react.uimanager.ThemedReactContext
Expand Down Expand Up @@ -65,6 +66,7 @@ class LuggMapView(private val reactContext: ThemedReactContext) :
private var rotateEnabled: Boolean = true
private var pitchEnabled: Boolean = true
private var compassEnabled: Boolean = true
private var staticMode: Boolean = false
private var userLocationEnabled: Boolean = false
private var userLocationButtonEnabled: Boolean = false
private var poiEnabled: Boolean = true
Expand Down Expand Up @@ -132,8 +134,13 @@ class LuggMapView(private val reactContext: ThemedReactContext) :
private fun initializeProvider() {
if (provider != null || mapWrapperView == null) return

if (staticMode) {
applyStaticPlaceholder()
}

val google = GoogleMapProvider(context)
google.mapId = mapId
google.staticMode = staticMode
google.delegate = this
provider = google

Expand All @@ -154,6 +161,23 @@ class LuggMapView(private val reactContext: ThemedReactContext) :
}
}

// Shown while the map loads, until the map covers it
private fun applyStaticPlaceholder() {
// A user-provided background acts as the placeholder
if (background != null) return

val isDark = when (theme) {
"dark" -> true

"light" -> false

else ->
(context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
Configuration.UI_MODE_NIGHT_YES
}
mapWrapperView?.setBackgroundColor(if (isDark) COLOR_PLACEHOLDER_DARK else COLOR_PLACEHOLDER_LIGHT)
}

// endregion

// region MapProviderDelegate
Expand Down Expand Up @@ -251,6 +275,10 @@ class LuggMapView(private val reactContext: ThemedReactContext) :
provider?.setCompassEnabled(enabled)
}

fun setStaticMode(enabled: Boolean) {
staticMode = enabled
}

fun setUserLocationEnabled(enabled: Boolean) {
if (userLocationEnabled == enabled) return
userLocationEnabled = enabled
Expand Down Expand Up @@ -344,4 +372,9 @@ class LuggMapView(private val reactContext: ThemedReactContext) :
}

// endregion

companion object {
private const val COLOR_PLACEHOLDER_LIGHT = 0xFFF2F2F7.toInt()
private const val COLOR_PLACEHOLDER_DARK = 0xFF2C2C2E.toInt()
}
}
9 changes: 9 additions & 0 deletions android/src/main/java/com/luggmaps/LuggMapViewManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,15 @@ class LuggMapViewManager :
view.setCompassEnabled(value)
}

@ReactProp(name = "staticMode", defaultBoolean = false)
override fun setStaticMode(view: LuggMapView, value: Boolean) {
view.setStaticMode(value)
}

// iOS only - lite mode doesn't need snapshot caching
@ReactProp(name = "staticKey")
override fun setStaticKey(view: LuggMapView, value: String?) = Unit

@ReactProp(name = "userLocationEnabled", defaultBoolean = false)
override fun setUserLocationEnabled(view: LuggMapView, value: Boolean) {
view.setUserLocationEnabled(value)
Expand Down
22 changes: 20 additions & 2 deletions android/src/main/java/com/luggmaps/LuggMapWrapperView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,26 @@ class LuggMapWrapperView(context: ThemedReactContext) : ReactViewGroup(context)

var touchEventHandler: ((MotionEvent) -> Unit)? = null

// React Native never runs an Android layout pass on Yoga-managed views, so
// a child's requestLayout (e.g. a lite mode MapView displaying its bitmap
// once loaded) would otherwise never result in a measure/layout. Live maps
// don't need this - their GL surface renders continuously once sized.
var relayoutChildOnRequest: Boolean = false

private val measureAndLayoutChild = Runnable { layoutChild(width, height) }

override fun dispatchTouchEvent(event: MotionEvent): Boolean {
touchEventHandler?.invoke(event)
return super.dispatchTouchEvent(event)
}

override fun requestLayout() {
super.requestLayout()
if (relayoutChildOnRequest) {
post(measureAndLayoutChild)
}
}

override fun onLayout(
changed: Boolean,
left: Int,
Expand All @@ -23,8 +38,11 @@ class LuggMapWrapperView(context: ThemedReactContext) : ReactViewGroup(context)
bottom: Int
) {
super.onLayout(changed, left, top, right, bottom)
val w = right - left
val h = bottom - top
layoutChild(right - left, bottom - top)
}

private fun layoutChild(w: Int, h: Int) {
if (w <= 0 || h <= 0) return
getChildAt(0)?.let {
it.measure(
MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY),
Expand Down
32 changes: 29 additions & 3 deletions android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ class GoogleMapProvider(private val context: Context) :

var mapId: String = DEMO_MAP_ID

// Renders the map in lite mode (static bitmap). Creation-time only.
var staticMode: Boolean = false

private var wrapperView: LuggMapWrapperView? = null
private var currentNightMode: Int = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
private var mapView: MapView? = null
Expand All @@ -96,6 +99,7 @@ class GoogleMapProvider(private val context: Context) :
private val groundOverlayToViewMap = mutableMapOf<GroundOverlay, LuggGroundOverlayView>()
private val markerToViewMap = mutableMapOf<Marker, LuggMarkerView>()
private val liveMarkerViews = mutableSetOf<LuggMarkerView>()
private var liteProjectionReady = false
private var activeNonBubbledMarker: Marker? = null
private var tapLocation: LatLng? = null

Expand Down Expand Up @@ -137,11 +141,15 @@ class GoogleMapProvider(private val context: Context) :
initialZoom = zoom

val wrapper = wrapperView as LuggMapWrapperView
wrapper.relayoutChildOnRequest = staticMode
this.wrapperView = wrapper

context.applicationContext.registerComponentCallbacks(this)

val options = GoogleMapOptions().mapId(mapId)
// Lite mode doesn't support map IDs (cloud-based styling); setting one
// makes the static map render blank
val options = GoogleMapOptions().liteMode(staticMode)
if (!staticMode) options.mapId(mapId)
mapView = MapView(context, options).also { view ->
view.onCreate(null)
view.onResume()
Expand Down Expand Up @@ -195,6 +203,17 @@ class GoogleMapProvider(private val context: Context) :
googleMap = map
_isMapReady = true

if (staticMode) {
// Lite mode shows an "open in Google Maps" toolbar on tap by default
map.uiSettings.isMapToolbarEnabled = false
// The projection is only valid once the lite map has rendered, and no
// camera events fire afterwards to correct live marker positions
map.setOnMapLoadedCallback {
liteProjectionReady = true
positionLiveMarkers()
}
}

val position = LatLng(initialLatitude, initialLongitude)
map.moveCamera(CameraUpdateFactory.newLatLngZoom(position, initialZoom))

Expand Down Expand Up @@ -737,6 +756,10 @@ class GoogleMapProvider(private val context: Context) :

val contentView = markerView.contentView
contentView.pointerEvents = com.facebook.react.uimanager.PointerEvents.NONE
// Until the lite map's projection is valid, hide the marker instead of
// flashing it at a wrong position; onMapLoaded positions and shows it
contentView.visibility =
if (staticMode && !liteProjectionReady) View.INVISIBLE else View.VISIBLE
(contentView.parent as? android.view.ViewGroup)?.removeView(contentView)
wrapper.addView(contentView)
liveMarkerViews.add(markerView)
Expand Down Expand Up @@ -768,10 +791,12 @@ class GoogleMapProvider(private val context: Context) :

private fun positionLiveMarker(markerView: LuggMarkerView) {
val map = googleMap ?: return
if (staticMode && !liteProjectionReady) return
val contentView = markerView.contentView
val point = map.projection.toScreenLocation(LatLng(markerView.latitude, markerView.longitude))
contentView.translationX = point.x - contentView.width * markerView.anchorX
contentView.translationY = point.y - contentView.height * markerView.anchorY
contentView.visibility = View.VISIBLE
}

// endregion
Expand Down Expand Up @@ -813,7 +838,7 @@ class GoogleMapProvider(private val context: Context) :
strokeColors = polylineView.strokeColors
strokeWidth = polylineView.strokeWidth.dpToPx()
animatedOptions = polylineView.animatedOptions
animated = polylineView.animated
animated = polylineView.animated && !staticMode
update()
}
}
Expand Down Expand Up @@ -842,7 +867,8 @@ class GoogleMapProvider(private val context: Context) :
strokeColors = polylineView.strokeColors
strokeWidth = polylineView.strokeWidth.dpToPx()
animatedOptions = polylineView.animatedOptions
animated = polylineView.animated
// Static maps render once; show the full polyline instead of animating
animated = polylineView.animated && !staticMode
update()
}

Expand Down
68 changes: 68 additions & 0 deletions docs/MAPVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import { MapView } from '@lugg/maps';
| `rotateEnabled` | `boolean` | `true` | Enable rotation gestures |
| `pitchEnabled` | `boolean` | `true` | Enable pitch/tilt gestures |
| `compassEnabled` | `boolean` | `true` | Show compass on the map (rotate control on web) |
| `staticMode` | `boolean` | `false` | Render as a non-interactive [static map](#static-maps). Ideal for list views |
| `staticKey` | `string` | - | Stable identity for the static snapshot cache (iOS). See [Static Maps](#static-maps) |
| `edgeInsets` | `EdgeInsets` | - | Map content edge insets |
| `userLocationEnabled` | `boolean` | `false` | Show current user location on the map |
| `userLocationButtonEnabled` | `boolean` | `false` | Show native my-location button (Android only) |
Expand All @@ -46,6 +48,72 @@ import { MapView } from '@lugg/maps';
| `onCameraIdle` | `(event: MapCameraEvent) => void` | - | Called when camera stops moving |
| `onReady` | `() => void` | - | Called when map is loaded and ready |

## Static Maps

Set `staticMode` to render a non-interactive map optimized for list views, where
mounting many live maps is expensive:

- **Android (Google)** - uses [lite mode](https://developers.google.com/maps/documentation/android-sdk/lite),
which renders a static bitmap instead of a live GL surface. Lite mode does
not support cloud-based styling, so `mapId` is ignored on static maps.
- **iOS (Apple)** - the base map is rendered with `MKMapSnapshotter`, which
loads entirely off the main thread (no live map view is ever created), so
static maps keep loading while scrolling. Markers stay live views
positioned over the base map; polylines, polygons, circles, and ground
overlays are drawn onto it. Tile overlays are not supported.
- **iOS (Google)** - markers and shapes render as live overlay views,
exactly like Apple static maps. Only the base map needs the SDK (which
has no async snapshotter): a live, tiles-only map warms up briefly under
the overlays and is replaced with a rendered image once tiles finish
loading, releasing the map's rendering resources. Warmup work scales
with how many rows mount at once - bound it with your list's
`windowSize` / `initialNumToRender` / `maxToRenderPerBatch`.
- **Web (Google)** - gestures, POI clicks, keyboard interaction, and press
events are disabled; the map itself stays live.

```tsx
<Pressable onPress={() => openPlace(place)}>
<View style={{ height: 140 }} pointerEvents="none">
<MapView
staticMode
staticKey={place.id}
style={StyleSheet.absoluteFill}
initialCoordinate={place.coordinate}
initialZoom={14}
>
<Marker coordinate={place.coordinate} />
</MapView>
</View>
</Pressable>
```

Notes:

- `staticMode` is creation-time only and cannot be toggled after the map is created.
- The map renders once with its initial camera and children. Camera commands
and prop updates after the snapshot are ignored on iOS.
- For taps, wrap the map in a `Pressable` with `pointerEvents="none"` on the
map container (as above) instead of relying on `onPress`.
- `animated` polylines render as complete static polylines, so the snapshot
never freezes a mid-animation frame.
- On iOS, markers are live views over the base map, revealed together with
it - marker content that loads asynchronously (e.g. remote images)
appears when ready, and marker prop updates still apply.
- While a static map loads, a theme-aware placeholder background is shown.
Set a `backgroundColor` style on the `MapView` to use your own.
- Set `staticKey` (e.g. your list item's id) to cache base map images
across list recycling on iOS - scrolling back to a row reuses its image
instead of rendering the map again (markers and shapes stay live and
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.
- Alternatively, keeping rows mounted (larger `windowSize` with
`removeClippedSubviews`) avoids re-rendering entirely at the cost of
holding every row's snapshot in memory.
- In long lists, limit how many rows mount at once (e.g. `windowSize`,
`initialNumToRender`, `maxToRenderPerBatch` on `FlatList`) - every mounted
static map holds its snapshot image in memory.

## Types

### MapProvider
Expand Down
6 changes: 3 additions & 3 deletions example/bare/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ PODS:
- hermes-engine (250829098.0.10):
- hermes-engine/Pre-built (= 250829098.0.10)
- hermes-engine/Pre-built (250829098.0.10)
- LuggMaps (1.0.0-beta.16):
- LuggMaps (1.0.0-beta.17):
- GoogleMaps
- hermes-engine
- RCTRequired
Expand Down Expand Up @@ -2427,7 +2427,7 @@ SPEC CHECKSUMS:
FBLazyVector: 24e62c765683b8d89006a88a2c8f5cf019f0074d
GoogleMaps: 0608099d4870cac8754bdba9b6953db543432438
hermes-engine: a43fcac5345a0a468667778019547c5fd282c6e2
LuggMaps: 7f6539e8b1f2d1c328f6c9634cc0544a78b39076
LuggMaps: 345524788dd8a9e80267d4b8a3daa23861ccedad
RCTDeprecation: a4c521821fab57cbb125b36effe84d897d0dfa12
RCTRequired: 9f3a7e5645d4bc3f551593de7550bb66ab6e42bc
RCTSwiftUI: 239ed2eb9e73de5a6f518810630f0c95e01c8702
Expand Down Expand Up @@ -2510,4 +2510,4 @@ SPEC CHECKSUMS:

PODFILE CHECKSUM: a8c918957730cfd546eb917e7563885b57095460

COCOAPODS: 1.15.2
COCOAPODS: 1.16.2
18 changes: 17 additions & 1 deletion example/bare/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ import type { MarkerPressEvent } from '@lugg/maps';
import { HomeScreen, type MarkerData } from '@lugg/shared-example';

import { DetailScreen } from './screens/DetailScreen';
import { StaticMapsScreen } from './screens/StaticMapsScreen';

export type RootStackParamList = {
Home: undefined;
Detail: { name: string };
StaticMaps: undefined;
};

const Stack = createNativeStackNavigator<RootStackParamList>();
Expand All @@ -31,7 +33,16 @@ function Home({ navigation }: HomeProps) {
[navigation]
);

return <HomeScreen onMarkerPress={handleMarkerPress} />;
const handleShowStaticMaps = useCallback(() => {
navigation.navigate('StaticMaps');
}, [navigation]);

return (
<HomeScreen
onMarkerPress={handleMarkerPress}
onShowStaticMaps={handleShowStaticMaps}
/>
);
}

export default function App() {
Expand All @@ -46,6 +57,11 @@ export default function App() {
options={{ headerShown: false }}
/>
<Stack.Screen name="Detail" component={DetailScreen} />
<Stack.Screen
name="StaticMaps"
component={StaticMapsScreen}
options={{ title: 'Static Maps' }}
/>
</Stack.Navigator>
</NavigationContainer>
);
Expand Down
14 changes: 14 additions & 0 deletions example/bare/src/screens/StaticMapsScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
import { StaticMapsScreen as SharedStaticMapsScreen } from '@lugg/shared-example';

import type { RootStackParamList } from '../App';

type Props = NativeStackScreenProps<RootStackParamList, 'StaticMaps'>;

export function StaticMapsScreen({ navigation }: Props) {
return (
<SharedStaticMapsScreen
onSelect={(place) => navigation.navigate('Detail', { name: place.name })}
/>
);
}
Loading
Loading