One map on iOS and Android, drawn by MapKit and Google Maps, with search and directions on iOS

One.UI.Map is one map component for both platforms. On iOS it is the
SwiftUI Map from MapKit; on Android it is Google Maps for Compose. You
describe the camera, markers, lines, shapes, and circles as plain props, and
get the same camera and tap events on both. It renders nothing on web.
One.MapServices adds place search, typeahead suggestions, and directions
from MapKit on iOS. For MapKit features beyond this shared API, such as Look
Around, use One.iOS.Map.
import { One } from 'one'
const ferry = { latitude: 37.7955, longitude: -122.3937 }
function FerryMap() { return ( <One.UI.Map style={{ flex: 1 }} cameraPosition={{ coordinates: ferry, zoom: 14 }} markers={[{ id: 'ferry', coordinates: ferry, title: 'Ferry Building' }]} /> )}cameraPosition sets where the map starts. People pan and zoom freely after
that, and the camera only moves again when you pass a different value.
Android needs a Google Maps key, set in your app config before prebuild:
import { one } from 'one/vite'
export default { plugins: [ one({ native: { app: { name: 'Places', android: { applicationId: 'com.example.places', googleMapsApiKey: process.env.GOOGLE_MAPS_API_KEY, }, }, }, }), ],}iOS needs no key.
Validation. Before anything reaches native code, the props are checked in
JavaScript. Coordinates outside the valid range, colors that are not
#rrggbb or #rrggbbaa, negative widths, circles without a positive radius,
empty line or shape coordinates, and duplicate ids within one array all throw
during render with a message naming the bad value. Without cameraPosition
the map opens on San Francisco at zoom 12.
Zoom. Both platforms use Google’s zoom scale, where each step doubles the
scale and the whole world is 256 points wide at zoom 0. Google Maps uses that
scale natively. MapKit has no zoom level, so iOS converts: at zoom z, a view w
points wide shows 360 ÷ 2^z × w ÷ 256 degrees of longitude, and the latitude
span is set from the view’s aspect ratio so MapKit has nothing to adjust. The
reported camera runs the same formula backward, so a zoom read from
onCameraMove and passed back lands on the same view.
Camera. The native view remembers the last camera it was given. When the props hold the same center and zoom on the next render, it leaves the camera where the person put it; only a new value moves it. On iOS a change of the view’s width also applies the camera again, since the span depends on it.
Events. onCameraMove fires once when a gesture ends: on iOS through the
map’s camera change callback at the end of the move, and on Android when a
gesture-started move stops. Camera moves you cause from props do not report
on Android. Native code reports a marker tap as the marker’s id, and the
component hands you the matching marker object from your props. A tap within
24 points of an iOS pin belongs to the pin, so it never also fires
onMapClick; on Android the marker consumes its own tap.
Android build. Setting googleMapsApiKey makes prebuild write the key
into the app manifest and switch One’s Android library to the build that
includes Google Maps for Compose. Without the key, a stub view compiles
instead, so the Maps SDK stays out of the app, and mounting a map stops the
app with a native error that names the missing setting. Changing the key setting needs a new
prebuild and native build. The map view takes its lifecycle from the
activity, so it pauses and resumes with the app.
Every prop is optional.
| Props | Type | Description |
|---|---|---|
styleStyleProp<ViewStyle>Sizes the map. | StyleProp<ViewStyle> | Sizes the map. |
testIDstringTest identifier for the native view. | string | Test identifier for the native view. |
accessibilityLabelstringLabel read by screen readers. | string | Label read by screen readers. |
cameraPositionCameraPositionCenter and Google zoom. Applied on mount and whenever the value changes. | CameraPosition | Center and Google zoom. Applied on mount and whenever the value changes. |
markersreadonly MapMarker[]Pins. Ids must be unique. | readonly MapMarker[] | Pins. Ids must be unique. |
polylinesreadonly MapPolyline[]Open lines through coordinates. | readonly MapPolyline[] | Open lines through coordinates. |
polygonsreadonly MapPolygon[]Closed, filled shapes. | readonly MapPolygon[] | Closed, filled shapes. |
circlesreadonly MapCircle[]Filled circles with a radius in meters. | readonly MapCircle[] | Filled circles with a radius in meters. |
| Events | Type | Description |
|---|---|---|
onCameraMove(event: { coordinates: Coordinates; zoom: number }) => voidOnce when a pan or zoom gesture ends, with the new camera. | (event: { coordinates: Coordinates; zoom: number }) => void | Once when a pan or zoom gesture ends, with the new camera. |
onMarkerClick(marker: MapMarker) => voidA marker was tapped. Receives the marker object from props, matched by id. | (marker: MapMarker) => void | A marker was tapped. Receives the marker object from props, matched by id. |
onMapClick(event: { coordinates: Coordinates }) => voidThe map was tapped away from a marker. Marker taps never also fire this. | (event: { coordinates: Coordinates }) => void | The map was tapped away from a marker. Marker taps never also fire this. |
| CameraPosition | Type | Description |
|---|---|---|
coordinatesCoordinatesThe center. | Coordinates | The center. |
zoomnumberGoogle zoom level, the same scale on both platforms. | number | Google zoom level, the same scale on both platforms. |
| Coordinates | Type | Description |
|---|---|---|
latitudenumberDegrees north. | number | Degrees north. |
longitudenumberDegrees east. | number | Degrees east. |
| MapMarker | Type | Description |
|---|---|---|
idstringUnique among markers. | string | Unique among markers. |
coordinatesCoordinatesWhere the pin sits. | Coordinates | Where the pin sits. |
titlestringShown with the pin. | string | Shown with the pin. |
tintColorstringPin color. | string | Pin color. |
| MapPolyline | Type | Description |
|---|---|---|
idstringUnique among polylines. | string | Unique among polylines. |
coordinatesreadonly Coordinates[]The points, in order. | readonly Coordinates[] | The points, in order. |
colorstringLine color. | string | Line color. |
widthnumberLine width in points. | number | Line width in points. |
| MapPolygon | Type | Description |
|---|---|---|
idstringUnique among polygons. | string | Unique among polygons. |
coordinatesreadonly Coordinates[]The corners, in order. | readonly Coordinates[] | The corners, in order. |
colorstringFill color. | string | Fill color. |
lineColorstringOutline color. | string | Outline color. |
lineWidthnumberOutline width in points. | number | Outline width in points. |
| MapCircle | Type | Description |
|---|---|---|
idstringUnique among circles. | string | Unique among circles. |
centerCoordinatesThe center. | Coordinates | The center. |
radiusnumberRadius in meters. | number | Radius in meters. |
colorstringFill color. | string | Fill color. |
lineColorstringOutline color. | string | Outline color. |
lineWidthnumberOutline width in points. | number | Outline width in points. |
Unset colors and widths use each platform’s defaults: a red pin, black outlines 2 points wide, and no fill.
One.MapServices runs MapKit’s local search, search completer, and
directions on iOS. It needs a network connection but no location permission,
since you pass the center yourself. Results use the same coordinate shape as
the map props, so a place can become a marker and a route a polyline.
import { One } from 'one'
const center = { latitude: 37.7955, longitude: -122.3937 }
async function walkToCoffee() { const [place] = await One.MapServices.search('coffee', center, 2000) if (!place) return null return One.MapServices.directions(center, place.coordinate, 'walking')}| MapPlace | Type | Description |
|---|---|---|
namestringThe place name, or empty. | string | The place name, or empty. |
addressstringThe full address line, or empty. | string | The full address line, or empty. |
coordinateMapCoordinateWhere it is. | MapCoordinate | Where it is. |
| MapSuggestion | Type | Description |
|---|---|---|
idstringPass to resolveSuggestion. | string | Pass to resolveSuggestion. |
titlestringThe main line. | string | The main line. |
subtitlestringThe secondary line, often the address. | string | The secondary line, often the address. |
| MapRoute | Type | Description |
|---|---|---|
distanceMetersnumberThe route length. | number | The route length. |
expectedTravelTimeSecondsnumberMapKit's estimate. | number | MapKit's estimate. |
polylineMapCoordinate[]The path, ready for a map polyline. | MapCoordinate[] | The path, ready for a map polyline. |
stepsMapRouteStep[]Turn by turn instructions. | MapRouteStep[] | Turn by turn instructions. |
| MapRouteStep | Type | Description |
|---|---|---|
instructionsstringWhat to do at this step. | string | What to do at this step. |
distanceMetersnumberThe length of this step. | number | The length of this step. |
Failures reject with an error whose code names the cause:
| Code | When |
|---|---|
| E_MAP_INPUT | Bad coordinates, radius, empty query, or an old suggestion id |
| E_MAP_SEARCH | Search or suggestion lookup failed |
| E_MAP_AUTOCOMPLETE | The completer failed |
| E_MAP_CANCELED | A newer autocomplete call replaced this one |
| E_MAP_TIMEOUT | Autocomplete gave no result in 20 seconds |
| E_MAP_DIRECTIONS | No route, or routing failed |
On Android only search runs natively, through the platform geocoder with
the same input checks and up to 10 results. Autocomplete resolves to an empty
array, and suggestions and directions reject, since Android has no platform
service for them. On web, search and autocomplete resolve to empty arrays and
the other two reject.
| iOS | Android | Web | |
|---|---|---|---|
| Map | MapKit, SwiftUI | Google Maps, Compose | Nothing |
| Key | None | Google Maps key | |
| Marker title | Label under the pin | Not shown on tap | |
| Pin color | The exact color | Its hue only | |
| Search | MapKit | Platform geocoder | Empty |
| Suggestions | MapKit | Empty | Empty |
| Directions | MapKit | Rejects | Rejects |
onCameraMove into cameraPosition. The camera moves only
when the prop changes, so writing each gesture’s end back into the prop
makes every pan apply the camera again and fight the next gesture. Keep the
reported camera in a ref or separate state.tintColor are dropped there.onMarkerClick fires, and that skips Google’s default
info window, so the title does not show on tap.E_MAP_CANCELED as you type. Every autocomplete call cancels the
pending one, which rejects. Ignore that code rather than showing an error.One.UI.Map returns nothing on web; render a web
map library there.import { useState } from 'react'import { Pressable, Text, TextInput, View } from 'react-native'import { One } from 'one'const center = { latitude: 37.7955, longitude: -122.3937 }
type Suggestion = { id: string; title: string; subtitle: string }type Place = Awaited<ReturnType<typeof One.MapServices.resolveSuggestion>>
function PlaceSearch({ onPick }: { onPick: (place: Place) => void }) { const [query, setQuery] = useState('') const [suggestions, setSuggestions] = useState<Suggestion[]>([])
async function update(text: string) { setQuery(text) if (text.trim().length < 2) return setSuggestions([]) try { setSuggestions(await One.MapServices.autocomplete(text, center, 5000)) } catch (error) { if ((error as { code?: string }).code !== 'E_MAP_CANCELED') setSuggestions([]) } }
return ( <View> <TextInput value={query} onChangeText={update} placeholder="Search places" /> {suggestions.map((item) => ( <Pressable key={item.id} onPress={async () => onPick(await One.MapServices.resolveSuggestion(item.id))}> <Text>{item.title}</Text> <Text>{item.subtitle}</Text> </Pressable> ))} </View> )}A canceled call means a newer one is on its way, so it leaves the list alone. The ids are only good until the next result, which is why the press resolves the suggestion straight away.
import { useEffect, useState } from 'react'import { One } from 'one'
const start = { latitude: 37.7955, longitude: -122.3937 }const end = { latitude: 37.7786, longitude: -122.3893 }
function WalkingRoute() { const [path, setPath] = useState<{ latitude: number; longitude: number }[]>([])
useEffect(() => { One.MapServices.directions(start, end, 'walking').then((route) => setPath(route.polyline), () => setPath([])) }, [])
return ( <One.UI.Map style={{ flex: 1 }} cameraPosition={{ coordinates: start, zoom: 14 }} markers={[ { id: 'start', coordinates: start, title: 'Start', tintColor: '#34c759' }, { id: 'end', coordinates: end, title: 'End' }, ]} polylines={path.length ? [{ id: 'route', coordinates: path, color: '#007aff', width: 4 }] : []} /> )}The route’s polyline uses the map’s coordinate shape, so it goes straight into
polylines. The constant camera means the route can arrive without moving the
map.
import { useState } from 'react'import { One } from 'one'
type Point = { latitude: number; longitude: number }const city = { coordinates: { latitude: 37.7749, longitude: -122.4194 }, zoom: 12 }
function PinDropper() { const [pins, setPins] = useState<Point[]>([]) return ( <One.UI.Map style={{ flex: 1 }} cameraPosition={city} markers={pins.map((coordinates, index) => ({ id: String(index), coordinates }))} onMapClick={({ coordinates }) => setPins([...pins, coordinates])} onMarkerClick={(marker) => setPins(pins.filter((_, index) => String(index) !== marker.id))} /> )}Taps on the map add a pin and taps on a pin remove it, since a pin tap never
also counts as a map tap. city lives at module scope, so adding pins never
moves the camera.
import { useState } from 'react'import { Button, View } from 'react-native'import { One } from 'one'
function StreetLevel() { const [open, setOpen] = useState(false) return ( <View style={{ flex: 1 }}> <One.iOS.Map latitude={37.7955} longitude={-122.3937} markers={[]} style={{ flex: 1 }} swiftStyle={{ lookAroundViewer: { isPresented: { value: open, onChange: setOpen }, latitude: 37.7934, longitude: -122.395, }, }} /> <Button title="Look Around" onPress={() => setOpen(true)} /> </View> )}MapKit’s Look Around needs iOS 17 and a network connection. The viewer asks
MapKit for a scene at the coordinate; when there is none, or the request
fails, it calls onChange(false), so open goes back to false and the
button works again.
Edit this page on GitHub.