diff --git a/android/src/main/java/com/luggmaps/LuggMapView.kt b/android/src/main/java/com/luggmaps/LuggMapView.kt index b60b7b4..4c66b59 100644 --- a/android/src/main/java/com/luggmaps/LuggMapView.kt +++ b/android/src/main/java/com/luggmaps/LuggMapView.kt @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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() + } } diff --git a/android/src/main/java/com/luggmaps/LuggMapViewManager.kt b/android/src/main/java/com/luggmaps/LuggMapViewManager.kt index eadd0eb..1ef6873 100644 --- a/android/src/main/java/com/luggmaps/LuggMapViewManager.kt +++ b/android/src/main/java/com/luggmaps/LuggMapViewManager.kt @@ -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) diff --git a/android/src/main/java/com/luggmaps/LuggMapWrapperView.kt b/android/src/main/java/com/luggmaps/LuggMapWrapperView.kt index 8139e44..b8064a0 100644 --- a/android/src/main/java/com/luggmaps/LuggMapWrapperView.kt +++ b/android/src/main/java/com/luggmaps/LuggMapWrapperView.kt @@ -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, @@ -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), diff --git a/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt b/android/src/main/java/com/luggmaps/core/GoogleMapProvider.kt index a1c5655..1e37240 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 @@ -96,6 +99,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 activeNonBubbledMarker: Marker? = null private var tapLocation: LatLng? = null @@ -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() @@ -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)) @@ -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) @@ -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 @@ -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() } } @@ -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() } diff --git a/docs/MAPVIEW.md b/docs/MAPVIEW.md index 6611e54..9652f44 100644 --- a/docs/MAPVIEW.md +++ b/docs/MAPVIEW.md @@ -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) | @@ -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 + 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`. +- `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 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..c83ea4f --- /dev/null +++ b/example/shared/src/screens/StaticMapsScreen.tsx @@ -0,0 +1,288 @@ +import { useState } from 'react'; +import { FlatList, Platform, Pressable, StyleSheet, View } from 'react-native'; +import { + MapProvider, + MapView, + Marker, + Polyline, + type Coordinate, + type MapProviderType, +} from '@lugg/maps'; + +import { + Button, + MarkerIcon, + MarkerImage, + MarkerText, + ThemedText, +} from '../components'; +import { sizes, useTheme } from '../theme'; + +type StaticMarkerType = 'default' | 'icon' | 'text' | 'image' | 'multiple'; + +const MARKER_TYPES: StaticMarkerType[] = [ + 'default', + 'icon', + 'text', + 'image', + 'multiple', +]; + +export interface StaticPlace { + id: string; + name: string; + description: string; + coordinate: Coordinate; + markerType: StaticMarkerType; + markerText: string; + imageUrl: string; +} + +const BASE_PLACES: Omit< + StaticPlace, + 'markerType' | 'markerText' | 'imageUrl' +>[] = [ + { + 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 }, + }, +]; + +// 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) => { + const base = BASE_PLACES[i % BASE_PLACES.length]!; + const shift = Math.floor(i / BASE_PLACES.length) * 0.015; + return { + ...base, + id: `${base.id}-${i}`, + name: `${i + 1}. ${base.name}`, + coordinate: { + latitude: base.coordinate.latitude + shift, + longitude: base.coordinate.longitude - shift, + }, + markerType: MARKER_TYPES[i % MARKER_TYPES.length]!, + markerText: `${i + 1}`, + imageUrl: `https://i.pravatar.cc/100?img=${(i % 70) + 1}`, + }; +}); + +const seedFromId = (id: string) => + id.split('').reduce((acc, ch) => acc + ch.charCodeAt(0), 0); + +// Deterministic pseudo-random scatter of 2-4 points, stable across +// re-renders so static snapshots stay valid. Clustered west of the +// coordinate so it doesn't overlap the center markers +const constellationPoints = ( + coordinate: Coordinate, + seed: number +): Coordinate[] => { + const count = 2 + (seed % 3); + return Array.from({ length: count }, (_, i) => { + const angle = seed + i * 2.4; + const radius = 0.002 + ((seed + i) % 4) * 0.001; + return { + latitude: coordinate.latitude + Math.sin(angle) * radius * 0.8, + longitude: coordinate.longitude - 0.009 + Math.cos(angle) * radius * 1.2, + }; + }); +}; + +const PlaceConstellation = ({ place }: { place: StaticPlace }) => { + const points = constellationPoints(place.coordinate, seedFromId(place.id)); + return ( + <> + + {points.map((point, index) => ( + + + + ))} + + ); +}; + +const PlaceMarkers = ({ place }: { place: StaticPlace }) => { + const { coordinate, markerType, markerText, imageUrl } = place; + + switch (markerType) { + case 'icon': + return ; + case 'text': + return ; + case 'image': + return ; + case 'multiple': + return ( + <> + + + + + ); + default: + return ; + } +}; + +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} + Marker: {place.markerType} + + + ); +}; + +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={ +