One Logo Pool Ball
wwwwwwwwwwwwwwwwwww

Map

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

One.UI.Map on iOS: MapKit with markers, a walking route, and a radius over San Francisco

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.

Usage

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.

How it works

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.

API reference

Every prop is optional.

PropsTypeDescription
styleStyleProp<ViewStyle>Sizes the map.
StyleProp<ViewStyle>Sizes the map.
testIDstringTest identifier for the native view.
stringTest identifier for the native view.
accessibilityLabelstringLabel read by screen readers.
stringLabel read by screen readers.
cameraPositionCameraPositionCenter and Google zoom. Applied on mount and whenever the value changes.
CameraPositionCenter 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.
EventsTypeDescription
onCameraMove(event: { coordinates: Coordinates; zoom: number }) => voidOnce when a pan or zoom gesture ends, with the new camera.
(event: { coordinates: Coordinates; zoom: number }) => voidOnce 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) => voidA 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 }) => voidThe map was tapped away from a marker. Marker taps never also fire this.

Shapes

CameraPositionTypeDescription
coordinatesCoordinatesThe center.
CoordinatesThe center.
zoomnumberGoogle zoom level, the same scale on both platforms.
numberGoogle zoom level, the same scale on both platforms.
CoordinatesTypeDescription
latitudenumberDegrees north.
numberDegrees north.
longitudenumberDegrees east.
numberDegrees east.
MapMarkerTypeDescription
idstringUnique among markers.
stringUnique among markers.
coordinatesCoordinatesWhere the pin sits.
CoordinatesWhere the pin sits.
titlestringShown with the pin.
stringShown with the pin.
tintColorstringPin color.
stringPin color.
MapPolylineTypeDescription
idstringUnique among polylines.
stringUnique among polylines.
coordinatesreadonly Coordinates[]The points, in order.
readonly Coordinates[]The points, in order.
colorstringLine color.
stringLine color.
widthnumberLine width in points.
numberLine width in points.
MapPolygonTypeDescription
idstringUnique among polygons.
stringUnique among polygons.
coordinatesreadonly Coordinates[]The corners, in order.
readonly Coordinates[]The corners, in order.
colorstringFill color.
stringFill color.
lineColorstringOutline color.
stringOutline color.
lineWidthnumberOutline width in points.
numberOutline width in points.
MapCircleTypeDescription
idstringUnique among circles.
stringUnique among circles.
centerCoordinatesThe center.
CoordinatesThe center.
radiusnumberRadius in meters.
numberRadius in meters.
colorstringFill color.
stringFill color.
lineColorstringOutline color.
stringOutline color.
lineWidthnumberOutline width in points.
numberOutline width in points.

Unset colors and widths use each platform’s defaults: a red pin, black outlines 2 points wide, and no fill.

Search and directions

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')
}
MapPlaceTypeDescription
namestringThe place name, or empty.
stringThe place name, or empty.
addressstringThe full address line, or empty.
stringThe full address line, or empty.
coordinateMapCoordinateWhere it is.
MapCoordinateWhere it is.
MapSuggestionTypeDescription
idstringPass to resolveSuggestion.
stringPass to resolveSuggestion.
titlestringThe main line.
stringThe main line.
subtitlestringThe secondary line, often the address.
stringThe secondary line, often the address.
MapRouteTypeDescription
distanceMetersnumberThe route length.
numberThe route length.
expectedTravelTimeSecondsnumberMapKit's estimate.
numberMapKit'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.
MapRouteStepTypeDescription
instructionsstringWhat to do at this step.
stringWhat to do at this step.
distanceMetersnumberThe length of this step.
numberThe length of this step.

Failures reject with an error whose code names the cause:

CodeWhen
E_MAP_INPUTBad coordinates, radius, empty query, or an old suggestion id
E_MAP_SEARCHSearch or suggestion lookup failed
E_MAP_AUTOCOMPLETEThe completer failed
E_MAP_CANCELEDA newer autocomplete call replaced this one
E_MAP_TIMEOUTAutocomplete gave no result in 20 seconds
E_MAP_DIRECTIONSNo 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.

Platform differences

iOSAndroidWeb
MapMapKit, SwiftUIGoogle Maps, ComposeNothing
KeyNoneGoogle Maps key
Marker titleLabel under the pinNot shown on tap
Pin colorThe exact colorIts hue only
SearchMapKitPlatform geocoderEmpty
SuggestionsMapKitEmptyEmpty
DirectionsMapKitRejectsRejects

Limits and gotchas

Examples

Search as you type

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.

Draw a walking route

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.

Drop a pin where people tap

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.

Look Around on iOS

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.