From 82ccbb13d09e7ee17d63b8192ee5daf65fc5a1b3 Mon Sep 17 00:00:00 2001 From: Jovanni Lo Date: Wed, 8 Jul 2026 06:34:16 +0800 Subject: [PATCH 1/3] fix: respect edgeInsets in static mode like a live map iOS shifts the static projection by the same visible-center offset a live map applies, frames the Google warmup view with matching padding, and keys the snapshot cache on insets. Android lite mode ignores map padding, so the camera target is shifted instead. --- .../com/luggmaps/core/GoogleMapProvider.kt | 42 ++++++++++++++++++- docs/MAPVIEW.md | 3 ++ ios/LuggMapView.mm | 15 +++---- ios/core/GoogleStaticMapProvider.mm | 3 ++ ios/core/StaticMapProviderBase.h | 1 + ios/core/StaticMapProviderBase.mm | 32 ++++++++++++++ 6 files changed, 88 insertions(+), 8 deletions(-) diff --git a/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt b/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt index 1e37240..9ac6d7b 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) : @@ -214,7 +216,7 @@ class GoogleMapProvider(private val context: Context) : } } - val position = LatLng(initialLatitude, initialLongitude) + val position = if (staticMode) staticCameraTarget() else LatLng(initialLatitude, initialLongitude) map.moveCamera(CameraUpdateFactory.newLatLngZoom(position, initialZoom)) map.setOnCameraMoveStartedListener(this) @@ -530,6 +532,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 +550,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 @@ -1274,6 +1286,34 @@ class GoogleMapProvider(private val context: Context) : applyWatermarkTranslation(combined, duration) } + // Lite mode ignores GoogleMap.setPadding, so insets are applied by + // shifting the camera target so the coordinate centers in the inset + // viewport like a live map would + 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 -> diff --git a/docs/MAPVIEW.md b/docs/MAPVIEW.md index 9652f44..a3c0348 100644 --- a/docs/MAPVIEW.md +++ b/docs/MAPVIEW.md @@ -92,6 +92,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/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 From 0a8712767bf5440aaf02a9b2b704df20b8315145 Mon Sep 17 00:00:00 2001 From: Jovanni Lo Date: Wed, 8 Jul 2026 06:34:17 +0800 Subject: [PATCH 2/3] chore(example): marker detail map with translucent card, transparent headers --- example/bare/package.json | 1 + example/bare/src/App.tsx | 8 +- example/bare/src/screens/StaticMapsScreen.tsx | 4 + example/expo/app/_layout.tsx | 8 ++ example/expo/app/static-maps.tsx | 3 + .../shared/src/screens/MarkerDetailScreen.tsx | 99 ++++++++++++++++--- .../shared/src/screens/StaticMapsScreen.tsx | 14 ++- yarn.lock | 1 + 8 files changed, 120 insertions(+), 18 deletions(-) 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/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" From 584e9705865ce4dc0a62139e9030200965784fdb Mon Sep 17 00:00:00 2001 From: Jovanni Lo Date: Thu, 9 Jul 2026 03:35:49 +0800 Subject: [PATCH 3/3] fix(android): static maps larger than the lite bitmap cap fall back to a full map Lite mode renders the map as a single bitmap capped at ~2048px per dimension and centers it, letterboxing bigger views (e.g. full-screen static maps). Map creation now waits for the first layout and uses a full map with all gestures disabled when the view exceeds the cap. Also skip GoogleMap.setPadding in static mode - lite mode does apply padding, shrinking the rendered image on top of the camera-target shift that already compensates for insets. --- .../java/com/luggmaps/LuggMapWrapperView.kt | 7 ++ .../com/luggmaps/core/GoogleMapProvider.kt | 65 +++++++++++++++---- docs/MAPVIEW.md | 4 +- 3 files changed, 62 insertions(+), 14 deletions(-) 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 9ac6d7b..581a3fc 100644 --- a/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt +++ b/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt @@ -80,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 @@ -101,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 @@ -148,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() @@ -206,12 +226,12 @@ 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() } } @@ -463,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 } @@ -771,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) @@ -803,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 @@ -1242,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 @@ -1282,13 +1312,18 @@ 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) } - // Lite mode ignores GoogleMap.setPadding, so insets are applied by - // shifting the camera target so the coordinate centers in the inset - // viewport like a live map would + // 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 @@ -1390,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 a3c0348..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