Skip to main content

Search

Last updated: June 16, 2026 | 6 minutes read

This example demonstrates two complementary ways to find places and present them in a list. The user can run a free text search by typing a query (search-as-you-type), or run a POI category search by tapping one of the category chips above the list. Each result shows the place name, a short description and its distance from a reference point - the user's current location when available, or the center of London otherwise.

The example follows an MVVM structure: MainActivity is a thin UI layer that binds the views and reacts to SDK lifecycle events, while SearchViewModel holds the search state and logic so it survives configuration changes.

Free text search by typing a query
POI category search by tapping a chip

Search Service and View Model

SearchViewModel retains a single SearchService instance and exposes the screen state as LiveData: the result list, the available POI categories, the currently selected category and an isSearching flag that drives the progress bar. Results are modelled as a small SearchItem data class holding only the information shown in the list.

SearchViewModel.ktView on Github
// Represents a single search result displayed in the list.
data class SearchItem(
val image: Bitmap? = null,
val name: String = "",
val description: String = "",
val distance: String = "",
val unit: String = "",
)

// Represents a POI category chip in the horizontal bar.
data class CategoryItem(
val name: String,
val icon: Bitmap?,
val landmarkStoreId: Int,
val categoryId: Int,
)

private val searchService = SearchService()

val results = MutableLiveData<List<SearchItem>>(emptyList())
val categories = MutableLiveData<List<CategoryItem>>(emptyList())

// Index of the selected category chip, or NO_CATEGORY when none is selected.
val selectedCategory = MutableLiveData(NO_CATEGORY)

// True while a search coroutine is running (used to drive the progress bar).
val isSearching = MutableLiveData(false)

The SearchView reports every keystroke through onQueryTextChange, and the activity forwards the trimmed query to viewModel.search(...).

MainActivity.ktView on Github
setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
clearFocus()
return true
}

override fun onQueryTextChange(newText: String?): Boolean {
if (!isProgrammaticQuery) viewModel.search((newText ?: "").trim())
return true
}
})

In the view model, search() debounces rapid keystrokes and cancels any in-flight search before starting a new one. It then calls searchService.searchByFilter(textFilter, reference, onCompleted). The reference point is the current GPS position (falling back to a fixed location in London when none is available); it is used to compute each result's distance and influences the relevance ordering of the results. Address search is enabled and any category filter left over from a previous category search is cleared, so a text query matches both addresses and POIs.

SearchViewModel.ktView on Github
fun search(filter: String) {
val newFilter = filter.trim()
if ((currentFilter == newFilter) && (activeCategoryIndex == NO_CATEGORY)) {
return
}

currentFilter = newFilter

cancelSearch()

if (activeCategoryIndex != NO_CATEGORY) {
activeCategoryIndex = NO_CATEGORY
selectedCategory.postValue(NO_CATEGORY)
}

// Only skip when the filter is blank AND no category is active.
if (filter.isBlank()) {
results.postValue(emptyList())
isSearching.postValue(false)
return
}

isSearching.postValue(true)
searchJob = viewModelScope.launch(Dispatchers.IO) {
delay(SEARCH_DEBOUNCE_MS)

Util.postOnMain {
SdkCall.postAsync {
setSearchReferencePoint()
isSearchingOnSdk = true

if (!searchService.preferences.searchAddressesEnabled) {
searchService.preferences.removeAllCategoryFilters()
searchService.preferences.searchAddressesEnabled = true
}

searchService.searchByFilter(
textFilter = filter,
reference = reference,
onCompleted = { landmarks, errorCode, _ ->
isSearchingOnSdk = false
if (errorCode == GemError.Cancel) return@searchByFilter
SdkCall.execute {
results.postValue(buildSearchItems(landmarks))
isSearching.postValue(false)
}
},
)
}
}
}
}

Loading POI Categories

Once the map data is ready, the activity calls viewModel.loadCategories(). The generic POI categories are read from GenericCategories().categories and mapped to CategoryItems (name, icon and the store/category ids needed to filter the search). The list is published on the categories LiveData and rendered as the horizontal chip bar.

SearchViewModel.ktView on Github
fun loadCategories() {
if (categoriesLoaded) return
categoriesLoaded = true
viewModelScope.launch(Dispatchers.IO) {
val items = SdkCall.execute {
GenericCategories().categories?.mapNotNull { cat ->
CategoryItem(
name = cat.name ?: return@mapNotNull null,
icon = cat.image?.asBitmap(iconSize, iconSize),
landmarkStoreId = cat.landmarkStoreId,
categoryId = cat.id,
)
} ?: emptyList()
} ?: emptyList()
categories.postValue(items)
}
}

Tapping a chip calls selectCategory(index). Instead of a text query, the search is constrained to the chosen category: address search is disabled, the category filter is applied to the search preferences with landmarkStores?.addStoreCategoryId(...), and the places are retrieved with searchService.searchAroundPosition(reference, onCompleted). The selected chip name is also written back into the search field so the UI reflects the active category.

SearchViewModel.ktView on Github
fun selectCategory(index: Int) {
if (activeCategoryIndex == index) return
activeCategoryIndex = index
selectedCategory.postValue(index)

cancelSearch()

// Apply or clear the category filter on the search preferences.
SdkCall.postAsync {
if (index != NO_CATEGORY) {
searchService.preferences.removeAllCategoryFilters()
searchService.preferences.searchAddressesEnabled = false
setSearchReferencePoint()

categories.value?.getOrNull(index)?.let { cat ->
searchService.preferences.landmarkStores?.addStoreCategoryId(cat.landmarkStoreId, cat.categoryId)

isSearching.postValue(true)
isSearchingOnSdk = true

searchService.searchAroundPosition(
reference = reference,
onCompleted = { landmarks, errorCode, _ ->
isSearchingOnSdk = false
if (errorCode != GemError.Cancel) {
SdkCall.execute {
results.postValue(buildSearchItems(landmarks))
isSearching.postValue(false)
}
}
},
)
}
}
}
}

Displaying the Results

Both search paths complete by mapping the returned Landmarks into SearchItems. For each landmark the distance to the reference point is computed with landmark.coordinates?.getDistance(reference) and formatted into a value/unit pair, while the icon, name and description are taken from the landmark itself.

SearchViewModel.ktView on Github
private fun buildSearchItems(landmarks: ArrayList<Landmark>): List<SearchItem> {
return landmarks.map { landmark ->
val meters = reference?.let { landmark.coordinates?.getDistance(it)?.toInt() ?: 0 } ?: 0
val dist = GemUtil.getDistText(meters, EUnitSystem.Metric, true)
SearchItem(
image = landmark.imageAsBitmap(imageSize),
name = landmark.name.toString(),
description = GemUtil.getLandmarkDescription(landmark, true),
distance = dist.first,
unit = dist.second,
)
}
}

The activity observes the results LiveData and submits the items to the list adapter, scrolling back to the top. The "no results" message is shown when a search returns nothing - that is, when the list is empty and there is an active query or selected category (so it does not appear when the search field is simply empty). The isSearching flag drives the progress bar, and the categories / selectedCategory LiveData keep the chip bar in sync.

MainActivity.ktView on Github
viewModel.results.observe(this) { items ->
searchAdapter.submitList(items)
binding.listView.smoothScrollToPosition(0)
val query = binding.searchInput.query.toString().trim()
binding.noResultText.isVisible = items.isEmpty() && (query.isNotBlank() || viewModel.selectedCategory.value != SearchViewModel.NO_CATEGORY)
}

viewModel.isSearching.observe(this) { searching ->
binding.searchProgressBar.isInvisible = !searching
}

viewModel.categories.observe(this) { items ->
categoryAdapter.submitList(items)
}