Search UI
Every geocoding SDK ships an optional UI layer on top of its headless controller: a rendered search box with a results list, recent searches, favourites, category chips and keyboard navigation.
The headless controllers give you state and leave the markup to you. That is the right default for a design system, and the wrong default for the ninety percent of apps that want a search box that works. This layer is that search box.
Three layers, pick one
| Layer | Use when |
|---|---|
MapAtlas + Session | You're on a server, or building something that isn't a search box. |
GeocodeSearchController / useAutocomplete | You want your own markup but not your own session, debounce and race handling. |
| Search UI (this page) | You want a search box. |
All four implementations were built against the same specification, so the concepts below mean the same thing in every language. Only the spelling changes.
Shared vocabulary
One flat row list, four kinds
The panel renders a single flat list of rows, never nested sections. Each row declares its kind:
| Kind | Meaning |
|---|---|
favourite | A place the user explicitly starred. Persisted. |
history | A place the user previously selected. Persisted, capped. |
suggestion | A live result from the gateway for the current query. |
category | A live result produced by tapping a category chip. |
A flat list is what makes keyboard navigation, screen-reader announcement and onPlaceSelected uniform: "row 3 of 12" is meaningful, and one arrow-key handler covers every kind.
Rows carry a display label, an optional secondary label, the originating Suggestion where there was one, and the SavedPlace where there was one.
SavedPlace is a resolved place
SavedPlace (MapAtlasSavedPlace in Swift) is deliberately not a stored suggestion. It holds real coordinates, captured at the moment the user picked the place.
This matters because v2 suggestions carry no coordinates and their ord handles are not stable across time or sessions. Persisting a suggestion would give you a history list that needs a billable network round trip to render, and whose entries silently rot. Persisting the resolved place means history and favourites render instantly, offline, with no gateway call and no session billing.
Fields: a display label, an optional secondary label, longitude/latitude, and optional layer / country / locality, plus a savedAt timestamp.
The store
Persistence sits behind a four-method interface so you can redirect it to your own backend, a keychain, or an encrypted database:
loadHistory() / saveHistory(places)
loadFavourites() / saveFavourites(places)Defaults per platform:
| SDK | Default store |
|---|---|
| Flutter | SharedPreferencesSearchStore (namespace mapatlas.search) |
| Swift | MapAtlasUserDefaultsSearchStore (UserDefaults.standard) |
| Kotlin | DataStoreSearchStore (Preferences DataStore, mapatlas_geocoder_ui) |
| React | createLocalStorageStore() (localStorage) |
Each also ships an in-memory implementation (InMemorySearchStore, MapAtlasEphemeralSearchStore) for tests and for privacy modes where nothing should be written to disk.
Deduplication: one identity tuple
The gateway returns near-duplicate rows routinely — the same address indexed twice, the same POI under two source ids. All four SDKs collapse them using the same identity tuple:
(text, placeName, locality, layer, housenumber)Each part is trimmed, lowercased and whitespace-collapsed, then joined. First occurrence wins, and the original ordering is preserved, so relevance ranking survives deduplication. Rows with no visible text at all opt out rather than collapsing into each other.
Dedup is pluggable — pass noDeduplication to keep everything, or supply your own key function.
id is not a key — so ids get de-collided
Deduplication is not the same problem as key collision, and the SDKs handle them separately.
The gateway's id field is not unique within one response: q=amsterdam&country=nl returns two rows with "id": "place.0", and on the OSM tier poi.0 is shared by genuinely different places. Fed into a keyed list this crashes Compose (IllegalArgumentException: Key … was already used) and quietly corrupts row state in React and SwiftUI.
So after deduplication — which removes actual duplicates — a uniquifier runs over whatever remains and suffixes any repeated id:
| SDK | Helper |
|---|---|
| Flutter | uniquifyRowIds(rows) |
| Swift | internal uniquingIDs(_:) |
| Kotlin | SearchRowKeys.uniquify(rows) |
| React | uniquifyRows(rows) |
Two distinct places that happen to share an id both survive, with distinct row ids. Nothing is dropped to make keys unique.
Category chips
Six categories ship by default, in this order:
restaurant · fuel · hotel · parking · cafe · supermarket
The chips work by rewriting the query text
There is no category parameter on /v2/autocomplete/ — category= and friends are silently ignored there. So tapping a chip prepends the category id to the query text (amsterdam → hotel amsterdam) and re-runs an ordinary search. Tapping the active chip again strips the token back out.
This is a ranking bias, not a filter. Non-matching rows still appear, and quality varies by category — hotel and supermarket rank well, fuel poorly. Do not present chip results to users as an exhaustive list of that category.
For real filtering — a hard category restriction, proximity-ranked, with distance_m on every feature — supply the categorySearch / onCategorySearch override and point it at /v2/category/. Every implementation accepts a callback of the shape (category, proximity) -> rows, and uses it instead of the query-text trick when present.
Selection
One callback, everywhere:
onPlaceSelected(place, label)The label is passed separately because the resolved place carries coordinates and administrative context but no display name — the name the user actually saw lives on the row. Passing it alongside means you can write it straight into your UI without re-deriving a label from the place.
Selecting a row also, by default, records it in history.
Debounce
None of these UI layers debounce. They pass keystrokes straight through and the client coalesces them — see Where the debounce lives. Do not add a timer around the text field.
Flutter
Exported from a separate entry point so a non-Flutter Dart app never pulls in the widget layer:
import 'package:mapatlas_geocoder/ui.dart';MapAtlasSearchField
The complete search box.
MapAtlasSearchField(
client: _client,
country: 'nl',
proximity: kAmsterdam,
hintText: 'Search a place in the Netherlands…',
onPlaceSelected: (place, label) {
setState(() { _place = place; _label = label; });
},
)client and onPlaceSelected are required. Notable optional parameters: country, proximity, minLength (2), hintText, decoration, theme, rowBuilder, deduplicate, labelBuilder, store, historyLimit (8), favouritesLimit (20), showHistory / showFavourites (both true), categories, onCategorySearch, textController, focusNode, autofocus.
typedef MapAtlasPlaceSelected =
FutureOr<void> Function(RetrievedPlace place, String label);MapAtlasSearchAnchor
Bring-your-own-markup. Same options, plus rememberSelections; instead of rendering a field it hands a MapAtlasSearchScope to your builder:
MapAtlasSearchAnchor(
client: _client,
onPlaceSelected: (place, label) { /* … */ },
builder: (context, scope) {
// scope.rows, scope.isLoading, scope.error, scope.setQuery(…),
// scope.select(row), scope.highlightNext(), scope.toggleFavourite(row) …
return MyOwnSearchLayout(scope: scope);
},
)Wrap your layout in MapAtlasSearchShortcuts(scope: scope, child: …) to get Arrow Up/Down, Enter and Escape bound to the scope for free.
Other exports
MapAtlasSearchRow / MapAtlasSearchRowKind, SavedPlace, MapAtlasSearchStore (+ SharedPreferencesSearchStore, InMemorySearchStore), MapAtlasSearchTheme / MapAtlasSearchThemeScope, MapAtlasSearchCategory + categoryQueryText, uniquifyRowIds, defaultDeduplicateSuggestions / noDeduplication / suggestionIdentity.
MapAtlasSearchTheme covers colours (surfaceColor, onSurfaceColor, mutedColor, errorColor, highlightColor, accentColor, dividerColor), borderRadius, elevation, three text styles, maxListHeight (320), rowMinHeight (48, floored at 44 for touch targets) and contentPadding.
Swift
A separate product in the same package, so the core client stays usable below iOS 17:
.product(name: "MapAtlasGeocoderUI", package: "Swift"),Requires macOS 14 / iOS 17
The package manifest declares iOS 15 — SwiftPM has no per-target platform, so that floor applies to every target. Everything in MapAtlasGeocoderUI is annotated @available(macOS 14, iOS 17, *) because it is built on @Observable. The manifest's iOS 15 is not a promise that this product runs there. See Platform floors.
import MapAtlasGeocoderUI
MapAtlasSearchField(
client: client,
country: "nl",
proximity: proximity,
configuration: MapAtlasSearchConfiguration()
) { place, label in
selectedPlace = place
selectedLabel = label
}MapAtlasSearchConfiguration holds the tuning knobs, all with defaults: placeholder, minLength (2), deduplicate, showsHistory, showsFavourites, allowsFavouriting, historyLimit (10), store, categories, categorySearch, clearsQueryOnSelect, recordsSelectionsInHistory.
There are four initialisers: the onPlaceSelected form above; an onSelect form receiving a richer MapAtlasSearchSelection; a form taking a pre-built MapAtlasSearchModel; and a @ViewBuilder rowContent form for custom row rendering.
MapAtlasSearchModel is the @Observable state object — construct one yourself (try MapAtlasSearchModel(client:country:proximity:configuration:)) when you need to drive the search from outside the view.
Theme via the environment:
MapAtlasSearchField(client: client) { place, label in /* … */ }
.mapAtlasSearchTheme(.system)Category chips use SF Symbols (fork.knife, fuelpump.fill, bed.double.fill, parkingsign.circle.fill, cup.and.saucer.fill, cart.fill).
Kotlin / Android
A new Gradle module, separate from the pure-JVM core so a server-side JVM consumer never pulls in Compose:
implementation("net.mapmetrics:mapatlas-geocoder-ui:1.0.0") // brings the core in transitivelyCompose + Material 3, minSdk 24, compileSdk 35, Compose BOM 2024.12.01. Persistence uses Preferences DataStore.
val client = remember(tier) { MapAtlas(token = TOKEN, tier = tier) }
DisposableEffect(client) { onDispose { client.close() } }
val anchor = rememberMapAtlasSearchAnchor(
client = client,
options = MapAtlasSearchOptions(country = "nl"),
)
MapAtlasSearchField(
anchor = anchor,
onPlaceSelected = { place, label -> selected = place to label },
)rememberMapAtlasSearchAnchor wires the DataStore-backed store, picks the right engine for the client's tier, and disposes the anchor for you. Its parameters: client, options, deduplicate, minLength (2), maxHistoryEntries (10), categories, categorySearch, initialQuery.
MapAtlasSearchField takes anchor, onPlaceSelected, plus modifier, strings, theme, showCategoryChips (true) and an optional rowContent lambda scoped to MapAtlasSearchScope.
Lower-level composables are public too, so you can assemble your own layout: MapAtlasSearchTextField, MapAtlasSearchPanel, MapAtlasSearchRowItem, MapAtlasCategoryChips, and the MapAtlasSearchTheme provider.
MapAtlasSearchAnchor exposes state: StateFlow<MapAtlasSearchState> and suspending select, toggleFavourite, removeFromHistory, clearHistory — usable directly from a ViewModel.
All user-visible strings come from mapatlas_search_* string resources via rememberMapAtlasSearchStrings(), so they localise with your app.
No desugaring needed
This module and the core client use only APIs present in android.jar. If you are carrying coreLibraryDesugaring purely for this SDK, you can drop it — see the Kotlin page.
React
Exported from the existing react subpath, alongside useAutocomplete:
import { SearchBox } from '@mapmetrics/geocoder/react';
import '@mapmetrics/geocoder/react/styles.css';The stylesheet is opt-in — import it for the default look, or skip it and style via classNames. react (>=18) stays an optional peer dependency; the core entry point never pulls React into your bundle.
<SearchBox>
const client = useMemo(() => new MapAtlas({ token: TOKEN }), []);
const [place, setPlace] = useState<SelectedPlace | null>(null);
<SearchBox
client={client}
country="nl"
proximity={[4.89, 52.37]}
placeholder="Search an address or place…"
onPlaceSelected={(p) => setPlace(p)}
onError={(error) => console.error(error.name, error.message)}
/>Only client is required. It accepts every useSearchBox option plus presentation props: placeholder, className, classNames, id, name, required, disabled, showClearButton, showPinButton, emptyMessage, loadingMessage, and renderOption / renderError / renderEmpty / renderPopover escape hatches.
classNames is a per-element map (root, input, popover, listbox, option, optionActive, category, categoryActive, sectionHeader, …) for Tailwind or CSS-modules styling without fighting the default stylesheet.
<AddressAutofill>
Wraps your own existing form fields rather than rendering a field. It attaches to the input inside it and fills the surrounding form on selection:
<AddressAutofill client={client} country="nl" onRetrieve={({ components, filled }) => { /* … */ }}>
<input name="address" autoComplete="address-line1" />
</AddressAutofill>onRetrieve receives the resolved place, a normalised components object (addressLine1, city, region, postcode, countryCode, street, houseNumber, …) and the filled map it applied.
useSearchBox
The headless hook behind both components, when you want the behaviour and none of the markup. It returns state (query, rows, mode, isLoading, error, isOpen, activeIndex, selected, categories, activeCategory), actions (setQuery, select, clear, open, close, toggleFavourite, selectCategory), the history / favourites sub-stores, and three ready-made ARIA prop bags:
const search = useSearchBox({ client, country: 'nl' });
<input {...search.inputProps} /> // role=combobox, aria-expanded, aria-activedescendant…
<ul {...search.listboxProps}> // role=listbox
{search.rows.map((row, i) => (
<li key={row.id} {...search.getOptionProps(i)}>{rowLabel(row)}</li>
))}
</ul>Spreading those three keeps the combobox accessible without you hand-writing the ARIA relationships.
Also exported: useSearchHistory, useFavourites, createLocalStorageStore, DEFAULT_CATEGORIES, defaultDeduplicate / noDeduplication / deduplicateBy, rowIdentity, rowLabel, uniquifyRows, and the SearchRow / SavedPlace / SelectedPlace types.
See also
- Geocoding SDKs — the shared model and the sharp edges this layer hides.
- Autocomplete (v2) — including why
idcan't be a key and whycategory=does nothing. - Category Search (v2) — the endpoint to wire
categorySearchto. - OSM tier — the free tier these components also support.