diff --git a/android/src/main/java/com/luggmaps/LuggMapWrapperView.kt b/android/src/main/java/com/luggmaps/LuggMapWrapperView.kt index b8064a0..3d52c7d 100644 --- a/android/src/main/java/com/luggmaps/LuggMapWrapperView.kt +++ b/android/src/main/java/com/luggmaps/LuggMapWrapperView.kt @@ -16,6 +16,10 @@ class LuggMapWrapperView(context: ThemedReactContext) : ReactViewGroup(context) // don't need this - their GL surface renders continuously once sized. var relayoutChildOnRequest: Boolean = false + // Invoked on layout once the view has a non-zero size, so the map + // provider can create a map sized to the view + var onLayoutReady: (() -> Unit)? = null + private val measureAndLayoutChild = Runnable { layoutChild(width, height) } override fun dispatchTouchEvent(event: MotionEvent): Boolean { @@ -39,6 +43,9 @@ class LuggMapWrapperView(context: ThemedReactContext) : ReactViewGroup(context) ) { super.onLayout(changed, left, top, right, bottom) layoutChild(right - left, bottom - top) + if (right - left > 0 && bottom - top > 0) { + onLayoutReady?.invoke() + } } private fun layoutChild(w: Int, h: Int) { diff --git a/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt b/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt index 1e37240..581a3fc 100644 --- a/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt +++ b/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt @@ -48,7 +48,9 @@ import com.luggmaps.LuggTileOverlayViewDelegate import com.luggmaps.extensions.findViewByTag import java.net.URL import kotlin.math.atan +import kotlin.math.ln import kotlin.math.pow +import kotlin.math.sin import kotlin.math.sinh class GoogleMapProvider(private val context: Context) : @@ -78,7 +80,8 @@ class GoogleMapProvider(private val context: Context) : var mapId: String = DEMO_MAP_ID - // Renders the map in lite mode (static bitmap). Creation-time only. + // Renders a non-interactive static map — lite mode when the view fits + // the lite bitmap cap, a gesture-less full map otherwise. Creation-time only. var staticMode: Boolean = false private var wrapperView: LuggMapWrapperView? = null @@ -99,7 +102,7 @@ class GoogleMapProvider(private val context: Context) : private val groundOverlayToViewMap = mutableMapOf() private val markerToViewMap = mutableMapOf() private val liveMarkerViews = mutableSetOf() - private var liteProjectionReady = false + private var staticProjectionReady = false private var activeNonBubbledMarker: Marker? = null private var tapLocation: LatLng? = null @@ -146,19 +149,38 @@ class GoogleMapProvider(private val context: Context) : context.applicationContext.registerComponentCallbacks(this) + // Static mode needs the view size to pick lite vs full map, so wait + // for the first layout if it hasn't happened yet + if (staticMode && (wrapper.width == 0 || wrapper.height == 0)) { + wrapper.onLayoutReady = { createMapView() } + } else { + createMapView() + } + } + + private fun createMapView() { + if (mapView != null) return + val wrapper = wrapperView ?: return + + // Lite mode renders the map as a single bitmap capped at ~2048px per + // dimension, centered in the view — bigger views get letterboxed. Fall + // back to a full map (gestures disabled) when the view exceeds the cap + val useLite = staticMode && maxOf(wrapper.width, wrapper.height) <= LITE_MODE_MAX_SIZE + val options = GoogleMapOptions().liteMode(useLite) // 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) + if (!useLite) options.mapId(mapId) mapView = MapView(context, options).also { view -> view.onCreate(null) view.onResume() view.getMapAsync(this) wrapper.addView(view) } + wrapper.onLayoutReady = null } override fun destroy() { + wrapperView?.onLayoutReady = null detachWindowInsetsListener() context.applicationContext.unregisterComponentCallbacks(this) dismissNonBubbledCallout() @@ -204,17 +226,17 @@ class GoogleMapProvider(private val context: Context) : _isMapReady = true if (staticMode) { - // Lite mode shows an "open in Google Maps" toolbar on tap by default + // Tapping the map otherwise shows an "open in Google Maps" toolbar map.uiSettings.isMapToolbarEnabled = false - // The projection is only valid once the lite map has rendered, and no + // The projection is only valid once the map has rendered, and no // camera events fire afterwards to correct live marker positions map.setOnMapLoadedCallback { - liteProjectionReady = true + staticProjectionReady = true positionLiveMarkers() } } - val position = LatLng(initialLatitude, initialLongitude) + val position = if (staticMode) staticCameraTarget() else LatLng(initialLatitude, initialLongitude) map.moveCamera(CameraUpdateFactory.newLatLngZoom(position, initialZoom)) map.setOnCameraMoveStartedListener(this) @@ -461,21 +483,25 @@ class GoogleMapProvider(private val context: Context) : override fun setZoomEnabled(enabled: Boolean) { zoomEnabled = enabled + if (staticMode) return googleMap?.uiSettings?.isZoomGesturesEnabled = enabled } override fun setScrollEnabled(enabled: Boolean) { scrollEnabled = enabled + if (staticMode) return googleMap?.uiSettings?.isScrollGesturesEnabled = enabled } override fun setRotateEnabled(enabled: Boolean) { rotateEnabled = enabled + if (staticMode) return googleMap?.uiSettings?.isRotateGesturesEnabled = enabled } override fun setPitchEnabled(enabled: Boolean) { pitchEnabled = enabled + if (staticMode) return googleMap?.uiSettings?.isTiltGesturesEnabled = enabled } @@ -530,6 +556,11 @@ class GoogleMapProvider(private val context: Context) : } override fun setEdgeInsets(edgeInsets: EdgeInsets) { + if (staticMode) { + setStaticEdgeInsets(edgeInsets) + return + } + val oldInsets = this.edgeInsets this.edgeInsets = edgeInsets applyEdgeInsets() @@ -543,6 +574,11 @@ class GoogleMapProvider(private val context: Context) : } override fun setEdgeInsets(edgeInsets: EdgeInsets, duration: Int) { + if (staticMode) { + setStaticEdgeInsets(edgeInsets) + return + } + val map = googleMap val oldInsets = this.edgeInsets this.edgeInsets = edgeInsets @@ -759,7 +795,7 @@ class GoogleMapProvider(private val context: Context) : // 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 + if (staticMode && !staticProjectionReady) View.INVISIBLE else View.VISIBLE (contentView.parent as? android.view.ViewGroup)?.removeView(contentView) wrapper.addView(contentView) liveMarkerViews.add(markerView) @@ -791,7 +827,7 @@ class GoogleMapProvider(private val context: Context) : private fun positionLiveMarker(markerView: LuggMarkerView) { val map = googleMap ?: return - if (staticMode && !liteProjectionReady) return + if (staticMode && !staticProjectionReady) return val contentView = markerView.contentView val point = map.projection.toScreenLocation(LatLng(markerView.latitude, markerView.longitude)) contentView.translationX = point.x - contentView.width * markerView.anchorX @@ -1230,6 +1266,12 @@ class GoogleMapProvider(private val context: Context) : private fun applyUiSettings() { googleMap?.uiSettings?.apply { + if (staticMode) { + // The non-lite fallback is a full map; keep it behaving like a + // static image (lite mode ignores gestures anyway) + setAllGesturesEnabled(false) + return + } isZoomGesturesEnabled = zoomEnabled isScrollGesturesEnabled = scrollEnabled isRotateGesturesEnabled = rotateEnabled @@ -1270,10 +1312,43 @@ class GoogleMapProvider(private val context: Context) : private fun applyEdgeInsets(duration: Int = 0) { val combined = combinedEdgeInsets() - googleMap?.setPadding(combined.left, combined.top, combined.right, combined.bottom) + // Lite mode misrenders padding: the static image is drawn only within + // the padded viewport instead of the full view. The camera target shift + // (staticCameraTarget) compensates for insets instead. + if (!staticMode) { + googleMap?.setPadding(combined.left, combined.top, combined.right, combined.bottom) + } applyWatermarkTranslation(combined, duration) } + // Insets are applied by shifting the camera target so the coordinate + // centers in the inset viewport like a live map would (see + // applyEdgeInsets for why padding can't be used) + private fun setStaticEdgeInsets(edgeInsets: EdgeInsets) { + val oldInsets = this.edgeInsets + this.edgeInsets = edgeInsets + applyEdgeInsets() + + val map = googleMap + if (map != null && oldInsets != edgeInsets) { + map.moveCamera(CameraUpdateFactory.newLatLngZoom(staticCameraTarget(), initialZoom)) + positionLiveMarkers() + } + } + + private fun staticCameraTarget(): LatLng { + val offsetX = (edgeInsets.left - edgeInsets.right) / 2.0 + val offsetY = (edgeInsets.top - edgeInsets.bottom) / 2.0 + if (offsetX == 0.0 && offsetY == 0.0) return LatLng(initialLatitude, initialLongitude) + + // Web mercator world size in px at the initial zoom + val worldSize = 256f.dpToPx().toDouble() * 2.0.pow(initialZoom.toDouble()) + val sinLat = sin(Math.toRadians(initialLatitude)) + val x = (initialLongitude + 180.0) / 360.0 - offsetX / worldSize + val y = 0.5 - ln((1.0 + sinLat) / (1.0 - sinLat)) / (4.0 * Math.PI) - offsetY / worldSize + return LatLng(Math.toDegrees(atan(sinh(Math.PI * (1.0 - 2.0 * y)))), x * 360.0 - 180.0) + } + private fun applyWatermarkTranslation(insets: EdgeInsets, duration: Int = 0) { val view = mapView ?: return view.findViewByTag("GoogleWatermark")?.let { watermark -> @@ -1350,5 +1425,9 @@ class GoogleMapProvider(private val context: Context) : companion object { const val DEMO_MAP_ID = "DEMO_MAP_ID" + + // Undocumented cap on the lite mode bitmap (2048 minus a 2px border, + // measured empirically) + private const val LITE_MODE_MAX_SIZE = 2046 } } diff --git a/docs/MAPVIEW.md b/docs/MAPVIEW.md index 9652f44..960e565 100644 --- a/docs/MAPVIEW.md +++ b/docs/MAPVIEW.md @@ -55,7 +55,9 @@ 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. + not support cloud-based styling, so `mapId` is ignored on lite static maps. + Lite mode caps its bitmap at ~2048px per dimension, so larger views (e.g. + full-screen maps) fall back to a full map with all gestures disabled. - **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 @@ -92,6 +94,9 @@ 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. +- `edgeInsets` shift the visible center like on a live map, so the + coordinate centers in the inset viewport. Changing insets after the + render re-renders the base map (iOS) or re-centers the camera (Android). - 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 diff --git a/example/bare/package.json b/example/bare/package.json index a3d5b68..5133166 100644 --- a/example/bare/package.json +++ b/example/bare/package.json @@ -13,6 +13,7 @@ "@lodev09/react-native-true-sheet": "3.11.0-beta.10", "@lugg/maps": "workspace:*", "@lugg/shared-example": "workspace:*", + "@react-navigation/elements": "^2.9.13", "@react-navigation/native": "^7.2.1", "@react-navigation/native-stack": "^7.14.9", "react": "19.2.3", diff --git a/example/bare/src/App.tsx b/example/bare/src/App.tsx index c1d4ac1..d236d63 100644 --- a/example/bare/src/App.tsx +++ b/example/bare/src/App.tsx @@ -56,11 +56,15 @@ export default function App() { component={Home} options={{ headerShown: false }} /> - + diff --git a/example/bare/src/screens/StaticMapsScreen.tsx b/example/bare/src/screens/StaticMapsScreen.tsx index 59d374c..37c2e91 100644 --- a/example/bare/src/screens/StaticMapsScreen.tsx +++ b/example/bare/src/screens/StaticMapsScreen.tsx @@ -1,4 +1,5 @@ import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { useHeaderHeight } from '@react-navigation/elements'; import { StaticMapsScreen as SharedStaticMapsScreen } from '@lugg/shared-example'; import type { RootStackParamList } from '../App'; @@ -6,8 +7,11 @@ import type { RootStackParamList } from '../App'; type Props = NativeStackScreenProps; export function StaticMapsScreen({ navigation }: Props) { + const headerHeight = useHeaderHeight(); + return ( navigation.navigate('Detail', { name: place.name })} /> ); diff --git a/example/expo/app/_layout.tsx b/example/expo/app/_layout.tsx index 7104e5d..f4e43ad 100644 --- a/example/expo/app/_layout.tsx +++ b/example/expo/app/_layout.tsx @@ -4,6 +4,14 @@ export default function Layout() { return ( + + ); } diff --git a/example/expo/app/static-maps.tsx b/example/expo/app/static-maps.tsx index 59d1527..78cc97a 100644 --- a/example/expo/app/static-maps.tsx +++ b/example/expo/app/static-maps.tsx @@ -1,11 +1,14 @@ import { useRouter } from 'expo-router'; +import { useHeaderHeight } from 'expo-router/react-navigation'; import { StaticMapsScreen } from '@lugg/shared-example'; export default function StaticMaps() { const router = useRouter(); + const headerHeight = useHeaderHeight(); return ( router.push({ pathname: '/detail', params: { name: place.name } }) } diff --git a/example/shared/src/screens/MarkerDetailScreen.tsx b/example/shared/src/screens/MarkerDetailScreen.tsx index ddfd7da..aaf465f 100644 --- a/example/shared/src/screens/MarkerDetailScreen.tsx +++ b/example/shared/src/screens/MarkerDetailScreen.tsx @@ -1,31 +1,104 @@ import { StyleSheet, View } from 'react-native'; +import { MapProvider, MapView, Marker } from '@lugg/maps'; import { ThemedText } from '../components'; -import { useTheme } from '../theme'; +import { INITIAL_MARKERS } from '../markers'; +import { CIRCLE_CENTER } from '../mapData'; +import { sizes, useTheme } from '../theme'; +import { PLACES } from './StaticMapsScreen'; interface MarkerDetailScreenProps { name: string; } +const CARD_HEIGHT = 200; +const CARD_BOTTOM = sizes.xl * 3; + +const formatCoordinate = (value: number) => value.toFixed(4); + export const MarkerDetailScreen = ({ name }: MarkerDetailScreenProps) => { const { colors } = useTheme(); + const apiKey = process.env.GOOGLE_MAPS_API_KEY; + + const marker = INITIAL_MARKERS.find((m) => m.name === name); + const place = PLACES.find((p) => p.name === name); + + const coordinate = marker?.coordinate ?? place?.coordinate ?? CIRCLE_CENTER; + const title = marker?.title ?? place?.name ?? name; + const description = + marker?.description ?? place?.description ?? 'Marker detail screen'; return ( - - {name} - - Marker detail screen - - - Go back and press the same marker again - + + + + + + + + {title} + {description} + + + {formatCoordinate(coordinate.latitude)},{' '} + {formatCoordinate(coordinate.longitude)} + + + Go back and press the same marker again + + ); }; const styles = StyleSheet.create({ - container: { flex: 1, justifyContent: 'center', alignItems: 'center' }, - title: { fontSize: 24, fontWeight: 'bold' }, - subtitle: { fontSize: 16, marginTop: 8 }, - hint: { fontSize: 14, marginTop: 16 }, + container: { + flex: 1, + }, + overlay: { + position: 'absolute', + left: sizes.lg, + right: sizes.lg, + bottom: CARD_BOTTOM, + height: CARD_HEIGHT, + padding: sizes.xl, + gap: sizes.sm, + borderRadius: sizes.radiusLg, + shadowOffset: sizes.shadowOffset, + shadowOpacity: sizes.shadowOpacity, + shadowRadius: sizes.shadowRadius, + elevation: sizes.elevation, + }, + divider: { + height: StyleSheet.hairlineWidth, + marginVertical: sizes.xs, + }, + coordinate: { + fontVariant: ['tabular-nums'], + }, + hint: { + fontSize: sizes.fontSm, + marginTop: 'auto', + }, }); diff --git a/example/shared/src/screens/StaticMapsScreen.tsx b/example/shared/src/screens/StaticMapsScreen.tsx index c83ea4f..e75b508 100644 --- a/example/shared/src/screens/StaticMapsScreen.tsx +++ b/example/shared/src/screens/StaticMapsScreen.tsx @@ -83,7 +83,7 @@ const BASE_PLACES: Omit< // Large list for performance testing - cycles the base places with // shifted coordinates so every map renders a unique region, and cycles // marker use-cases so snapshots cover default, custom, and live markers -const PLACES: StaticPlace[] = Array.from({ length: 100 }, (_, i) => { +export const PLACES: StaticPlace[] = Array.from({ length: 100 }, (_, i) => { const base = BASE_PLACES[i % BASE_PLACES.length]!; const shift = Math.floor(i / BASE_PLACES.length) * 0.015; return { @@ -175,6 +175,7 @@ const PlaceMarkers = ({ place }: { place: StaticPlace }) => { interface StaticMapsScreenProps { onSelect?: (place: StaticPlace) => void; + topInset?: number; } const PlaceCard = ({ @@ -222,7 +223,10 @@ const PlaceCard = ({ ); }; -export const StaticMapsScreen = ({ onSelect }: StaticMapsScreenProps) => { +export const StaticMapsScreen = ({ + onSelect, + topInset = 0, +}: StaticMapsScreenProps) => { const apiKey = process.env.GOOGLE_MAPS_API_KEY; const { colors } = useTheme(); @@ -234,7 +238,11 @@ export const StaticMapsScreen = ({ onSelect }: StaticMapsScreenProps) => { place.id} ListHeaderComponent={ diff --git a/ios/LuggMapView.mm b/ios/LuggMapView.mm index a764e21..5a97763 100644 --- a/ios/LuggMapView.mm +++ b/ios/LuggMapView.mm @@ -252,13 +252,14 @@ - (nullable NSString *)staticSnapshotCacheKey { const auto &viewProps = *std::static_pointer_cast(_props); - return [NSString stringWithFormat:@"%@|%d|%@|%d|%ld|%.0fx%.0f|%.6f,%.6f,%.2f", - _staticKey, (int)_providerType, _mapId, - (int)_mapType, (long)style, size.width, - size.height, - viewProps.initialCoordinate.latitude, - viewProps.initialCoordinate.longitude, - viewProps.initialZoom]; + return [NSString + stringWithFormat:@"%@|%d|%@|%d|%ld|%.0fx%.0f|%.6f,%.6f,%.2f|%@", + _staticKey, (int)_providerType, _mapId, (int)_mapType, + (long)style, size.width, size.height, + viewProps.initialCoordinate.latitude, + viewProps.initialCoordinate.longitude, + viewProps.initialZoom, + NSStringFromUIEdgeInsets(_edgeInsets)]; } - (void)startStaticContent { diff --git a/ios/core/GoogleStaticMapProvider.mm b/ios/core/GoogleStaticMapProvider.mm index 515701e..666295d 100644 --- a/ios/core/GoogleStaticMapProvider.mm +++ b/ios/core/GoogleStaticMapProvider.mm @@ -127,6 +127,9 @@ - (void)startWarmup { mapView.userInteractionEnabled = NO; mapView.preferredFrameRate = kGMSFrameRateConservative; mapView.paddingAdjustmentBehavior = kGMSMapViewPaddingAdjustmentBehaviorNever; + // Frames the camera like the inset-shifted projection (and clears stale + // padding on pooled views) + mapView.padding = self.edgeInsets; mapView.mapType = LuggGMSMapTypeFromMapType(self.mapType); mapView.overrideUserInterfaceStyle = LuggInterfaceStyleFromTheme(self.theme); diff --git a/ios/core/StaticMapProviderBase.h b/ios/core/StaticMapProviderBase.h index f77b8f2..b3d3f82 100644 --- a/ios/core/StaticMapProviderBase.h +++ b/ios/core/StaticMapProviderBase.h @@ -33,6 +33,7 @@ CGPoint LuggStaticPointForCoordinate(MKMapRect mapRect, CGSize size, @property(nonatomic, readonly) double zoom; @property(nonatomic, readonly) MKMapRect mapRect; @property(nonatomic, readonly) CGSize projectedSize; +@property(nonatomic, readonly) UIEdgeInsets edgeInsets; @property(nonatomic, readonly) BOOL projectionReady; @property(nonatomic, readonly) facebook::react::LuggMapViewMapType mapType; @property(nonatomic, readonly) facebook::react::LuggMapViewTheme theme; diff --git a/ios/core/StaticMapProviderBase.mm b/ios/core/StaticMapProviderBase.mm index c697e14..5022e43 100644 --- a/ios/core/StaticMapProviderBase.mm +++ b/ios/core/StaticMapProviderBase.mm @@ -348,6 +348,14 @@ - (void)updateProjection { _projectedSize = size; _mapRect = [self mapRectForSize:size]; + + // Edge insets shift the visible center like a live map: the coordinate + // lands at the center of the inset viewport instead of the view center + CGFloat offsetX = (_edgeInsets.left - _edgeInsets.right) / 2.0; + CGFloat offsetY = (_edgeInsets.top - _edgeInsets.bottom) / 2.0; + _mapRect.origin.x -= offsetX * _mapRect.size.width / size.width; + _mapRect.origin.y -= offsetY * _mapRect.size.height / size.height; + _projectionReady = YES; _shapeOverlayView.mapRect = _mapRect; @@ -471,10 +479,34 @@ - (void)setPoiFilterCategories:(NSArray *)categories { } - (void)setEdgeInsets:(UIEdgeInsets)edgeInsets oldEdgeInsets:(UIEdgeInsets)oldEdgeInsets { + if (UIEdgeInsetsEqualToEdgeInsets(_edgeInsets, edgeInsets)) + return; + _edgeInsets = edgeInsets; + + if (!_projectionReady) + return; + + [self updateProjection]; + + // Any in-flight or completed base render targets the old projection. The + // initial prop set lands before the first render, so the cached image + // (keyed on insets) stays valid. + [self cancelBaseRender]; + if (_baseRenderDone) { + _baseRenderDone = NO; + self.cachedBaseImage = nil; + } + __weak StaticMapProviderBase *weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + [weakSelf renderBaseMapIfNeeded]; + }); } + +// Static maps don't animate; apply the final insets directly - (void)setEdgeInsets:(UIEdgeInsets)edgeInsets oldEdgeInsets:(UIEdgeInsets)oldEdgeInsets duration:(double)duration { + [self setEdgeInsets:edgeInsets oldEdgeInsets:oldEdgeInsets]; } #pragma mark - Markers diff --git a/yarn.lock b/yarn.lock index cfccf31..c495c97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3319,6 +3319,7 @@ __metadata: "@react-native/babel-preset": "npm:0.85.3" "@react-native/metro-config": "npm:0.85.3" "@react-native/typescript-config": "npm:0.85.3" + "@react-navigation/elements": "npm:^2.9.13" "@react-navigation/native": "npm:^7.2.1" "@react-navigation/native-stack": "npm:^7.14.9" "@types/react": "npm:^19.2.0"