From 682d5affb09b66e99131cd20b2f498b374bba45b Mon Sep 17 00:00:00 2001 From: Jovanni Lo Date: Mon, 6 Jul 2026 22:47:23 +0800 Subject: [PATCH 01/17] feat(mapview): add staticMode prop for static maps in lists --- .../src/main/java/com/luggmaps/LuggMapView.kt | 6 + .../java/com/luggmaps/LuggMapViewManager.kt | 5 + .../com/luggmaps/core/GoogleMapProvider.kt | 10 +- docs/MAPVIEW.md | 34 ++++ example/bare/ios/Podfile.lock | 6 +- example/bare/src/App.tsx | 18 +- example/bare/src/screens/StaticMapsScreen.tsx | 14 ++ example/expo/app/index.tsx | 11 +- example/expo/app/static-maps.tsx | 14 ++ example/shared/src/index.ts | 1 + example/shared/src/screens/HomeScreen.tsx | 14 +- .../shared/src/screens/StaticMapsScreen.tsx | 161 ++++++++++++++++++ example/shared/src/sheets/ControlSheet.tsx | 8 + ios/LuggMapView.mm | 4 + ios/core/AppleMapProvider.mm | 52 +++++- ios/core/GoogleMapProvider.mm | 52 +++++- ios/core/MapProviderDelegate.h | 4 + ios/extensions/UIView+Snapshot.h | 16 ++ ios/extensions/UIView+Snapshot.m | 24 +++ src/MapView.tsx | 2 + src/MapView.types.ts | 9 + src/MapView.web.tsx | 14 +- src/fabric/LuggMapViewNativeComponent.ts | 1 + 23 files changed, 463 insertions(+), 17 deletions(-) create mode 100644 example/bare/src/screens/StaticMapsScreen.tsx create mode 100644 example/expo/app/static-maps.tsx create mode 100644 example/shared/src/screens/StaticMapsScreen.tsx create mode 100644 ios/extensions/UIView+Snapshot.h create mode 100644 ios/extensions/UIView+Snapshot.m diff --git a/android/src/main/java/com/luggmaps/LuggMapView.kt b/android/src/main/java/com/luggmaps/LuggMapView.kt index b60b7b4..0f9ff83 100644 --- a/android/src/main/java/com/luggmaps/LuggMapView.kt +++ b/android/src/main/java/com/luggmaps/LuggMapView.kt @@ -65,6 +65,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 @@ -134,6 +135,7 @@ class LuggMapView(private val reactContext: ThemedReactContext) : val google = GoogleMapProvider(context) google.mapId = mapId + google.staticMode = staticMode google.delegate = this provider = google @@ -251,6 +253,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 diff --git a/android/src/main/java/com/luggmaps/LuggMapViewManager.kt b/android/src/main/java/com/luggmaps/LuggMapViewManager.kt index eadd0eb..5875f92 100644 --- a/android/src/main/java/com/luggmaps/LuggMapViewManager.kt +++ b/android/src/main/java/com/luggmaps/LuggMapViewManager.kt @@ -137,6 +137,11 @@ class LuggMapViewManager : view.setCompassEnabled(value) } + @ReactProp(name = "staticMode", defaultBoolean = false) + override fun setStaticMode(view: LuggMapView, value: Boolean) { + view.setStaticMode(value) + } + @ReactProp(name = "userLocationEnabled", defaultBoolean = false) override fun setUserLocationEnabled(view: LuggMapView, value: Boolean) { view.setUserLocationEnabled(value) diff --git a/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt b/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt index a1c5655..9f11994 100644 --- a/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt +++ b/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt @@ -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 @@ -141,7 +144,7 @@ class GoogleMapProvider(private val context: Context) : context.applicationContext.registerComponentCallbacks(this) - val options = GoogleMapOptions().mapId(mapId) + val options = GoogleMapOptions().mapId(mapId).liteMode(staticMode) mapView = MapView(context, options).also { view -> view.onCreate(null) view.onResume() @@ -195,6 +198,11 @@ 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 + } + val position = LatLng(initialLatitude, initialLongitude) map.moveCamera(CameraUpdateFactory.newLatLngZoom(position, initialZoom)) diff --git a/docs/MAPVIEW.md b/docs/MAPVIEW.md index 6611e54..e844ee1 100644 --- a/docs/MAPVIEW.md +++ b/docs/MAPVIEW.md @@ -33,6 +33,7 @@ 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 | | `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) | @@ -46,6 +47,39 @@ 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. +- **iOS (Apple/Google)** - the live map is replaced with a rendered snapshot + image once tiles finish loading, releasing the map's rendering resources. + +```tsx + openPlace(place)}> + + + + + + +``` + +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`. + ## Types ### MapProvider diff --git a/example/bare/ios/Podfile.lock b/example/bare/ios/Podfile.lock index dff7863..b227082 100644 --- a/example/bare/ios/Podfile.lock +++ b/example/bare/ios/Podfile.lock @@ -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 @@ -2427,7 +2427,7 @@ SPEC CHECKSUMS: FBLazyVector: 24e62c765683b8d89006a88a2c8f5cf019f0074d GoogleMaps: 0608099d4870cac8754bdba9b6953db543432438 hermes-engine: a43fcac5345a0a468667778019547c5fd282c6e2 - LuggMaps: 7f6539e8b1f2d1c328f6c9634cc0544a78b39076 + LuggMaps: 345524788dd8a9e80267d4b8a3daa23861ccedad RCTDeprecation: a4c521821fab57cbb125b36effe84d897d0dfa12 RCTRequired: 9f3a7e5645d4bc3f551593de7550bb66ab6e42bc RCTSwiftUI: 239ed2eb9e73de5a6f518810630f0c95e01c8702 @@ -2510,4 +2510,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: a8c918957730cfd546eb917e7563885b57095460 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/example/bare/src/App.tsx b/example/bare/src/App.tsx index 4df0a24..c1d4ac1 100644 --- a/example/bare/src/App.tsx +++ b/example/bare/src/App.tsx @@ -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(); @@ -31,7 +33,16 @@ function Home({ navigation }: HomeProps) { [navigation] ); - return ; + const handleShowStaticMaps = useCallback(() => { + navigation.navigate('StaticMaps'); + }, [navigation]); + + return ( + + ); } export default function App() { @@ -46,6 +57,11 @@ export default function App() { options={{ headerShown: false }} /> + ); diff --git a/example/bare/src/screens/StaticMapsScreen.tsx b/example/bare/src/screens/StaticMapsScreen.tsx new file mode 100644 index 0000000..59d374c --- /dev/null +++ b/example/bare/src/screens/StaticMapsScreen.tsx @@ -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; + +export function StaticMapsScreen({ navigation }: Props) { + return ( + navigation.navigate('Detail', { name: place.name })} + /> + ); +} diff --git a/example/expo/app/index.tsx b/example/expo/app/index.tsx index 753549b..23a9a2c 100644 --- a/example/expo/app/index.tsx +++ b/example/expo/app/index.tsx @@ -13,5 +13,14 @@ export default function MapScreen() { [router] ); - return ; + const handleShowStaticMaps = useCallback(() => { + router.push('/static-maps'); + }, [router]); + + return ( + + ); } diff --git a/example/expo/app/static-maps.tsx b/example/expo/app/static-maps.tsx new file mode 100644 index 0000000..59d1527 --- /dev/null +++ b/example/expo/app/static-maps.tsx @@ -0,0 +1,14 @@ +import { useRouter } from 'expo-router'; +import { StaticMapsScreen } from '@lugg/shared-example'; + +export default function StaticMaps() { + const router = useRouter(); + + return ( + + router.push({ pathname: '/detail', params: { name: place.name } }) + } + /> + ); +} diff --git a/example/shared/src/index.ts b/example/shared/src/index.ts index e5c844c..36e233b 100644 --- a/example/shared/src/index.ts +++ b/example/shared/src/index.ts @@ -4,3 +4,4 @@ export * from './utils'; export * from './markers'; export * from './screens/HomeScreen'; export * from './screens/MarkerDetailScreen'; +export * from './screens/StaticMapsScreen'; diff --git a/example/shared/src/screens/HomeScreen.tsx b/example/shared/src/screens/HomeScreen.tsx index c2012bb..7d87e6a 100644 --- a/example/shared/src/screens/HomeScreen.tsx +++ b/example/shared/src/screens/HomeScreen.tsx @@ -42,23 +42,30 @@ const bottomEdgeInsets = (bottom: number) => ({ interface HomeProps { onMarkerPress?: (e: MarkerPressEvent, marker: MarkerData) => void; + onShowStaticMaps?: () => void; } -export const HomeScreen = ({ onMarkerPress }: HomeProps) => { +export const HomeScreen = ({ onMarkerPress, onShowStaticMaps }: HomeProps) => { const apiKey = process.env.GOOGLE_MAPS_API_KEY; return ( - + ); }; -const HomeContent = ({ onMarkerPress: onMarkerPressProp }: HomeProps) => { +const HomeContent = ({ + onMarkerPress: onMarkerPressProp, + onShowStaticMaps, +}: HomeProps) => { const mapRef = useRef(null); const controlSheetRef = useRef(null); const mapTypeSheetRef = useRef(null); @@ -277,6 +284,7 @@ const HomeContent = ({ onMarkerPress: onMarkerPressProp }: HomeProps) => { setProvider((p) => (p === 'google' ? 'apple' : 'google')) } onLoadGeojson={() => geojsonSheetRef.current?.present()} + onShowStaticMaps={onShowStaticMaps} onDidPresent={handleSheetEvent} onDetentChange={handleSheetEvent} /> diff --git a/example/shared/src/screens/StaticMapsScreen.tsx b/example/shared/src/screens/StaticMapsScreen.tsx new file mode 100644 index 0000000..26ba60a --- /dev/null +++ b/example/shared/src/screens/StaticMapsScreen.tsx @@ -0,0 +1,161 @@ +import { useState } from 'react'; +import { FlatList, Platform, Pressable, StyleSheet, View } from 'react-native'; +import { + MapProvider, + MapView, + Marker, + type Coordinate, + type MapProviderType, +} from '@lugg/maps'; + +import { Button, ThemedText } from '../components'; +import { sizes, useTheme } from '../theme'; + +export interface StaticPlace { + id: string; + name: string; + description: string; + coordinate: Coordinate; +} + +const PLACES: StaticPlace[] = [ + { + id: 'golden-gate-bridge', + name: 'Golden Gate Bridge', + description: 'Iconic suspension bridge over the Golden Gate strait', + coordinate: { latitude: 37.8199, longitude: -122.4783 }, + }, + { + id: 'alcatraz', + name: 'Alcatraz Island', + description: 'Historic island prison in San Francisco Bay', + coordinate: { latitude: 37.827, longitude: -122.423 }, + }, + { + id: 'ferry-building', + name: 'Ferry Building', + description: 'Marketplace and transit hub on the Embarcadero', + coordinate: { latitude: 37.7955, longitude: -122.3937 }, + }, + { + id: 'golden-gate-park', + name: 'Golden Gate Park', + description: 'Large urban park with gardens and museums', + coordinate: { latitude: 37.7694, longitude: -122.4862 }, + }, + { + id: 'coit-tower', + name: 'Coit Tower', + description: 'Art deco tower on Telegraph Hill', + coordinate: { latitude: 37.8024, longitude: -122.4058 }, + }, + { + id: 'twin-peaks', + name: 'Twin Peaks', + description: 'Hills with panoramic views of the city', + coordinate: { latitude: 37.7544, longitude: -122.4477 }, + }, +]; + +interface StaticMapsScreenProps { + onSelect?: (place: StaticPlace) => void; +} + +const PlaceCard = ({ + place, + provider, + onSelect, +}: { + place: StaticPlace; + provider: MapProviderType; + onSelect?: (place: StaticPlace) => void; +}) => { + const { colors } = useTheme(); + + return ( + [ + styles.card, + { backgroundColor: colors.background, borderColor: colors.border }, + pressed && styles.cardPressed, + ]} + onPress={() => onSelect?.(place)} + > + + + + + + + + {place.name} + + {place.description} + + + ); +}; + +export const StaticMapsScreen = ({ onSelect }: StaticMapsScreenProps) => { + const apiKey = process.env.GOOGLE_MAPS_API_KEY; + const { colors } = useTheme(); + + const [provider, setProvider] = useState( + Platform.OS === 'ios' ? 'apple' : 'google' + ); + + return ( + + place.id} + ListHeaderComponent={ +