also looking at this
feat(native): share map tilt and colored pin components
#2083
6 files
+115
-7
| @@ -452,8 +452,27 @@ export interface ImageBackgroundProps extends ImageProps { | ||
| 452 | 452 | // Map Components |
| 453 | 453 | // ============================================================================ |
| 454 | 454 | |
| 455 | export interface MapCoordinate { | |
| 456 | latitude: number | |
| 457 | longitude: number | |
| 458 | } | |
| 459 | ||
| 460 | export interface MapTileOverlay { | |
| 461 | /** HTTPS raster template containing {z}, {x}, and {y}. */ | |
| 462 | urlTemplate: string | |
| 463 | /** Provider credit kept visible by the native host. */ | |
| 464 | attribution: string | |
| 465 | } | |
| 466 | ||
| 467 | export interface MapEdgePadding { | |
| 468 | top?: number | |
| 469 | right?: number | |
| 470 | bottom?: number | |
| 471 | left?: number | |
| 472 | } | |
| 473 | ||
| 455 | 474 | export interface MapRegion { |
| 456 | center: { latitude: number; longitude: number } | |
| 475 | center: MapCoordinate | |
| 457 | 476 | span: { latitudeDelta: number; longitudeDelta: number } |
| 458 | 477 | } |
| 459 | 478 | |
| @@ -463,9 +482,9 @@ export interface MapProps extends BaseProps { | ||
| 463 | 482 | /** Initial region for maps without recorded overlay geometry. */ |
| 464 | 483 | initialRegion?: MapRegion |
| 465 | 484 | /** A native raster basemap; credit remains visible on the map. */ |
| 466 | tileOverlay?: { urlTemplate: string, attribution: string } | |
| 485 | tileOverlay?: MapTileOverlay | |
| 467 | 486 | /** Logical points reserved for floating controls when fitting overlays. */ |
| 468 | edgePadding?: { top?: number, right?: number, bottom?: number, left?: number } | |
| 487 | edgePadding?: MapEdgePadding | |
| 469 | 488 | /** Fit the camera to all marker and polyline overlay geometry. */ |
| 470 | 489 | fitToBounds?: boolean |
| 471 | 490 | /** Show the user dot only when the app already has location permission. */ |
| @@ -473,6 +492,8 @@ export interface MapProps extends BaseProps { | ||
| 473 | 492 | scrollEnabled?: boolean |
| 474 | 493 | zoomEnabled?: boolean |
| 475 | 494 | rotateEnabled?: boolean |
| 495 | /** Enable native two-finger tilt gestures. Programmatic cameraPitch is independent. */ | |
| 496 | pitchEnabled?: boolean | |
| 476 | 497 | mapType?: 'standard' | 'satellite' | 'hybrid' | 'terrain' |
| 477 | 498 | showsCompass?: boolean |
| 478 | 499 | showsScale?: boolean |
| @@ -480,7 +501,7 @@ export interface MapProps extends BaseProps { | ||
| 480 | 501 | cameraPitch?: number |
| 481 | 502 | /** Clockwise camera heading from north. */ |
| 482 | 503 | cameraHeading?: number |
| 483 | onPress?: (event: { nativeEvent: { latitude: number, longitude: number } }) => void | |
| 504 | onPress?: (event: { nativeEvent: MapCoordinate }) => void | |
| 484 | 505 | /** Completed user camera gestures; programmatic camera changes do not echo. */ |
| 485 | 506 | onRegionChangeComplete?: (event: { nativeEvent: MapRegion & { heading: number, pitch: number } }) => void |
| 486 | 507 | onMarkerPress?: (event: { nativeEvent: { id: string } }) => void |
| @@ -498,6 +519,8 @@ export interface MapMarkerProps extends BaseProps { | ||
| 498 | 519 | geometry: number[] |
| 499 | 520 | id: string |
| 500 | 521 | title?: string |
| 522 | /** CSS color for a place pin or the position selected on an elevation profile. */ | |
| 523 | pinColor?: string | |
| 501 | 524 | /** Route-direction arrow, clockwise from north; absent uses a place marker. */ |
| 502 | 525 | bearing?: number |
| 503 | 526 | onPress?: (event: { nativeEvent: { id: string } }) => void |
| @@ -1,4 +1,5 @@ | ||
| 1 | {{-- Map: a native map host with overlay children. --}} | |
| 1 | {{-- Map: native MapKit on iOS or the configured Craft native Android provider. | |
| 2 | The compiler preserves typed geometry, camera props and overlay children. --}} | |
| 2 | 3 | <script server> |
| 3 | 4 | const classes = $props.class ?? $props.className ?? '' |
| 4 | 5 | </script> |
| @@ -19,7 +19,7 @@ const NATIVE_PROP_NAMES = [ | ||
| 19 | 19 | 'showsHorizontalScrollIndicator', 'showsVerticalScrollIndicator', 'statusBarTranslucent', |
| 20 | 20 | 'stickyHeaderIndices', 'testID', 'textContentType', 'thumbColor', 'thumbTintColor', 'titleColor', 'trackColor', |
| 21 | 21 | 'underlayColor', 'windowSize', |
| 22 | 'initialRegion', 'tileOverlay', 'edgePadding', 'fitToBounds', 'showsUserLocation', 'zoomEnabled', 'rotateEnabled', 'mapType', 'strokeColor', | |
| 22 | 'initialRegion', 'tileOverlay', 'edgePadding', 'fitToBounds', 'showsUserLocation', 'zoomEnabled', 'rotateEnabled', 'pitchEnabled', 'mapType', 'strokeColor', 'pinColor', | |
| 23 | 23 | 'showsCompass', 'showsScale', 'cameraPitch', 'cameraHeading', |
| 24 | 24 | 'autoScrollBottom', 'scrollKey', |
| 25 | 25 | // A video floating in Picture in Picture, and one element across an |
| @@ -37,7 +37,7 @@ export const NATIVE_BOOLEAN_PROPS = new Set([ | ||
| 37 | 37 | 'editable', 'hidesWhenStopped', 'horizontal', 'loop', 'multiline', 'muted', 'pagingEnabled', 'paused', |
| 38 | 38 | 'refreshing', 'scrollEnabled', 'secureTextEntry', 'selectTextOnFocus', 'showsHorizontalScrollIndicator', |
| 39 | 39 | 'showsVerticalScrollIndicator', 'transparent', 'visible', |
| 40 | 'fitToBounds', 'showsUserLocation', 'zoomEnabled', 'rotateEnabled', 'showsCompass', 'showsScale', | |
| 40 | 'fitToBounds', 'showsUserLocation', 'zoomEnabled', 'rotateEnabled', 'pitchEnabled', 'showsCompass', 'showsScale', | |
| 41 | 41 | 'pictureInPicture', |
| 42 | 42 | ]) |
| 43 | 43 | |
| @@ -0,0 +1,78 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import path from 'node:path' | |
| 3 | import vm from 'node:vm' | |
| 4 | import { compileSharedScreenBundle } from '../../src/native/compiler/bundle' | |
| 5 | import { compileScreenSource } from '../../src/native/compiler/render-screen' | |
| 6 | import { createTempDir } from '../../src/temp-dir' | |
| 7 | ||
| 8 | describe('portable native map components', () => { | |
| 9 | it('keeps native camera controls, provider credit, colored pins and direction signs through the component pipeline', async () => { | |
| 10 | const { document } = await compileScreenSource(`<Map class="h-[320px] w-full rounded-[20px]" pitch-enabled="false" rotate-enabled="true" shows-compass shows-scale camera-pitch="30" camera-heading="90" | |
| 11 | :tileOverlay="{urlTemplate:'https://example.org/{z}/{y}/{x}.png',attribution:'Test map data'}" | |
| 12 | :edgePadding="{top:32,bottom:180,left:24,right:24}" fit-to-bounds> | |
| 13 | <MapPolyline id="route" geometry="[37,-119,37.01,-119.01]" stroke-color="#24724b" stroke-width="5" /> | |
| 14 | <MapMarker id="start" geometry="[37,-119]" pin-color="#24724b" title="Trailhead" @press="selectMarker($event)" /> | |
| 15 | <MapMarker id="direction" geometry="[37.005,-119.005]" bearing="90" /> | |
| 16 | </Map>`, path.join(import.meta.dir, 'NativeMapComponents.stx')) | |
| 17 | expect(document.root.type).toBe('Map') | |
| 18 | expect(document.root.style).toMatchObject({ height: 320, width: '100%', borderRadius: 20 }) | |
| 19 | expect(document.root.props).toMatchObject({ | |
| 20 | pitchEnabled: false, rotateEnabled: true, showsCompass: true, showsScale: true, cameraPitch: 30, cameraHeading: 90, fitToBounds: true, | |
| 21 | tileOverlay: { urlTemplate: 'https://example.org/{z}/{y}/{x}.png', attribution: 'Test map data' }, | |
| 22 | edgePadding: { top: 32, bottom: 180, left: 24, right: 24 }, | |
| 23 | }) | |
| 24 | const children = document.root.children.filter(child => typeof child !== 'string') | |
| 25 | expect(children.map(child => child.type)).toEqual(['MapPolyline', 'MapMarker', 'MapMarker']) | |
| 26 | expect(children[0].props).toMatchObject({ geometry: [37, -119, 37.01, -119.01], strokeColor: '#24724b', strokeWidth: 5 }) | |
| 27 | expect(children[1].props).toMatchObject({ pinColor: '#24724b', title: 'Trailhead' }) | |
| 28 | expect(children[1].events).toEqual({ onPress: 'selectMarker($event)' }) | |
| 29 | expect(children[2].props.bearing).toBe(90) | |
| 30 | }) | |
| 31 | ||
| 32 | for (const platform of ['ios', 'android']) { | |
| 33 | it(`updates the ${platform} map through native mutations without browser globals or a replacement screen`, async () => { | |
| 34 | const directory = createTempDir('stx-native-map-') | |
| 35 | const file = path.join(directory, 'MapScreen.stx') | |
| 36 | await Bun.write(file, `<script client> | |
| 37 | const tilted = state(true) | |
| 38 | const pin = state('#24724b') | |
| 39 | const route = state([37,-119,37.01,-119.01]) | |
| 40 | function updateMap() { tilted.set(false); pin.set('#2878c8'); route.set([37,-119,37.02,-119.02]) } | |
| 41 | </script> | |
| 42 | <View> | |
| 43 | <Map class="h-[320px]" :pitchEnabled="tilted()" showsCompass> | |
| 44 | <MapPolyline :geometry="route()" strokeColor="#24724b" strokeWidth="5" /> | |
| 45 | <MapMarker id="start" geometry="[37,-119]" :pinColor="pin()" /> | |
| 46 | </Map> | |
| 47 | <Button @click="updateMap()">Update map</Button> | |
| 48 | </View>`) | |
| 49 | const { code } = await compileSharedScreenBundle(file) | |
| 50 | const sent: any[] = [] | |
| 51 | let receive: (message: string) => void = () => {} | |
| 52 | vm.runInNewContext(code, { | |
| 53 | console, Promise, Date, Map, Set, WeakMap, WeakSet, setTimeout, clearTimeout, | |
| 54 | __stxNativeBridge: { | |
| 55 | platform, | |
| 56 | mutationProtocolVersion: 1, | |
| 57 | postMessage: (raw: string) => sent.push(JSON.parse(raw)), | |
| 58 | onMessage: (callback: typeof receive) => { receive = callback }, | |
| 59 | }, | |
| 60 | }) | |
| 61 | await Bun.sleep(0) | |
| 62 | const root = sent.find(message => message.type === 'RENDER').payload.document | |
| 63 | const [map, button] = root.children | |
| 64 | const [line, marker] = map.children | |
| 65 | const operations = () => sent.filter(message => message.type === 'MUTATE').flatMap(message => message.payload.operations) | |
| 66 | expect(operations()).toContainEqual(expect.objectContaining({ id: map.id, patch: { props: { pitchEnabled: true } } })) | |
| 67 | expect(operations()).toContainEqual(expect.objectContaining({ id: marker.id, patch: { props: { pinColor: '#24724b' } } })) | |
| 68 | const handler = operations().find(operation => operation.id === button.id && operation.patch?.events?.onPress).patch.events.onPress | |
| 69 | sent.length = 0 | |
| 70 | receive(JSON.stringify({ type: 'EVENT', payload: { handlerId: handler, nativeEvent: {} } })) | |
| 71 | await Bun.sleep(0) | |
| 72 | expect(operations()).toContainEqual(expect.objectContaining({ id: map.id, patch: { props: { pitchEnabled: false } } })) | |
| 73 | expect(operations()).toContainEqual(expect.objectContaining({ id: marker.id, patch: { props: { pinColor: '#2878c8' } } })) | |
| 74 | expect(operations()).toContainEqual(expect.objectContaining({ id: line.id, patch: { props: { geometry: [37, -119, 37.02, -119.02] } } })) | |
| 75 | expect(sent.some(message => message.type === 'RENDER')).toBe(false) | |
| 76 | }) | |
| 77 | } | |
| 78 | }) | |