Blog Infos
Author
Published
Topics
, , ,
Published

I’ve always wanted to build a utilitarian component in Jetpack Compose — something that’s not just shiny or clever, but actually useful. That opportunity came when I ran into a painfully common problem while filling out a form.

Let me paint the picture: the form asks for your country, and you get a dropdown menu with over 300 countries to scroll through. You click the menu, start scrolling, and keep scrolling until you find your country. It’s tedious, especially on mobile. It would’ve been a much smoother experience if I could just search the list — not necessarily by typing the exact word — and get close matches instead of staring down the full list of countries.

This article walks you through how I approached this problem by implementing a custom search dialog component, focusing on performance, simplicity, and no external dependencies.

Feature Highlights

Here’s what sets this component apart:

  1. No regex, no database, no third-party search libraries.
    It uses a smart indexing algorithm — the suffix array — to handle fast, in-memory search.
  2. Text highlighting.
    Matching parts of a result are highlighted, giving visual feedback on what exactly matched.
  3. Minimal UI.
    Simple, clean, and flexible. No bells and whistles.
  4. Debounced search.
    Prevents expensive operations on every keystroke. Efficient by design.
Suffix Array and Its Limitations

Now, a quick note on how the search works. Suffix array is a heavy duty prefix search data structure that can find a prefix macth in O(log n) time, but the major bottle neck operation is its construction. The standard suffix array indexes all suffixes of a single string, “Hello world” is a trivial example. A non trivial example is indexing the entire text in this article making operations like KWIC (key word in context) possible.

The website Algorithms 4th Edition from Princeton University hosts an implementation of a suffix array with a decently fast construction time called SuffxArrayX. But out of the box, it only supports a single continuous string. If we were to use that as-is, the client code would have to merge all strings into one giant string, with some delimiter to mark where one ends and the next begins. Not ideal.

So instead, I generalised that implementation to work with a list of strings, giving us the GSuffArray. You can check out the implementation here: GSuffArray.

Overview

 

The component is designed to behave like a searchable dropdown — click to open a dialog, search through suggestions, and select a result.

The SearchDialog UI component consists of two main view transition:

  • View: Displays a clickable surface showing the selected item or placeholder text
  • Search: Opens a dialog with a search field and filtered suggestions

With the following state transition.

┌─────────────┐ click/tap ┌──────────────┐
│ View │ ──────────────► │ Search │
└─────────────┘ ◄────────────── └──────────────┘
select item /
dismiss dialog
  • The view is a stateless composable implemented using a clickable surface composable that accepts a text composable as its content.
  • The search is stateless composable implemented using the basic dialog composable whose content is a basic text field and a LazyList layout to hold the suggestions.

Below is a skeletal structure of the view and search state implementation

@Composable
fun ViewState(text: String, onCLick: () -> Unit) {
Surface(...) {
Row(...) {
Text(text = text)
IconButton{
Icon(imageVector = Icons.Default.ArrowDropDown)
}
}
}
}
@Composable
fun SearchState(
suggestions: List<AnnotatedString>,
isError: Boolean,
searchTerm: String,
onQueryChange: (String) -> Unit,
onSelectSuggestion: (String) -> Unit
) {
Dialog(onDismissRequest = {...}) {
Card {
OutlinedTextField(...)
LazyColumn(...) {
items(items = suggestions) { text ->
Text(text = text)
}
}
}
}
}
view raw skeletal.kt hosted with ❤ by GitHub

The function signature for both view and search explicitly informs their roles — View State only displays text, Search State is the interactive component.

Next we define parent composable that orchestrates the transition between both views. To keep things clean and follow the Compose philosophy, we define state holder class for composables instead of making the composables responsible for managing their state.

The following properties are what define the search dialog state holder class:

  1. query — a mutable state of the search term
  2. foundMatch – a boolean used by the text field to show error state
  3. gsa (generalised suffix array) — An abstraction that represents a sorted suffix of many strings not just a single string.
  4. suggestions — a mutable state flow of a list of annotated strings. These strings are highlighted to indicate the searched characters.
  5. queryFow – a mutable state flow of the search term for the purpose of debouncing.
  6. items — a list of strings that would be indexed by gsa
class SearchDialogState(scope: CoroutineScope, val items: List<String>) {
var query: String by mutableStateOf("")
var foundMatch: Boolean by mutableStateOf(false)
private lateinit var gsa: GSuffArray
val suggestions: MutableStateFlow<List<AnnotatedString>> = MutableStateFlow(emptyList<AnnotatedString>())
val queryFlow: MutableStateFlow<String> = MutableStateFlow("")
private var dialogState by mutableStateOf(State.VIEW)
fun isView(): Boolean {...}
fun isSearch(): Boolean {...}
fun updateViewState() {...}
fun findMatch(query: String): Map<Int, List<Int>> {...}
}

The code for the parent composable that orchestrates the transitions is given below, together with the following events that triggers state changes.

  • onClick() – The Search composable is conditionally called when the user clicks the on the ViewState composable
  • onQueryChange() – This is used to update the value of query state and query flow.
  • onSelectSuggestion() – It communicates the users choice by calling onSelectItem(), updates both query State and query flow then closes the dialog by calling searchDialogState.updateViewState()
@Composable
fun SearchDialog(
modifier: Modifier = Modifier,
placeholder: String = "Search",
searchDialogState: SearchDialogState,
onSelectItem: (String) -> Unit
) {
Box(...) {
var text by searchDialogState.query
ViewState(text = text.ifEmpty { placeholder }, onCLick = { searchDialogState.updateViewState() })
if (searchDialogState.isSearch()) {
SearchState(
suggestions = searchDialogState.suggestions.collectAsStateWithLifecycle().value,
searchTerm = searchDialogState.query,
isError = searchDialogState.foundMatch.not(),
onQueryChange = { query ->
searchDialogState.query = query
searchDialogState.queryFlow.value = query
},
onSelectSuggestion = {
onSelectItem(it)
searchDialogState.query = it
searchDialogState.queryFlow.value = it
searchDialogState.updateViewState()
}
)
}
}
}

We currently have not implemented a way to update searchDialogState.suggestions whenever the query changes. Before doing that, there are a few things to consider:

  1. For each keystroke, we wouldn’t want to immediately engage the findMatch() function of the search dialog state as it is a fairly expensive operation. We should only engage the function after a set time interval (debounce).
  2. Since the findMatch() can be a fairly expensive operation depending on the number of keystrokes entered by the user, we don’t want it to run on the main thread.

Inside the SearchDialog composable we make a call to LaunchedEffect and implement the search suggestion update in its block. Central to this implementation is the searchDialogState.queryFlow property. It serves as a key to LaunchedEffect and we use it as a computational context to implement debounce and other intermediate operations.

@Composable
fun SearchDialog(
modifier: Modifier = Modifier,
placeholder: String = "Search",
searchDialogState: SearchDialogState,
onSelectItem: (String) -> Unit
) {
LaunchedEffect(searchDialogState.queryFlow) {
searchDialogState.queryFlow
.debounce(300) // wait 300ms of no new queries
.distinctUntilChanged() // only if query actually changed
.mapLatest { query ->
// perform suffix-array lookup on IO dispatcher
withContext(Dispatchers.IO) {
highlight(
query = query,
items = searchDialogState.items,
matches = searchDialogState.findMatchingIndex(query)
)
}
} .collectLatest { result ->
searchDialogState.suggestions.value = result
if (!result.isEmpty()) { searchDialogState.foundMatch = true }
else { searchDialogState.foundMatch = false }
}
}
/**
* There are codes below this comments
**/
}
view raw debounce.kt hosted with ❤ by GitHub

The mapLatest block is responsible for transforming the user query into a list of matching suggestions with the help of the following functions:

  • highlight() — returns an annotated string with highlight
  • searchDialogState.findMatchingIndex() —returns Map<Int, List<Int>> used by the highlight function

The key of the map represents the index of the matching string found in the list of items, the value represents the start positions of the query character in this matching string.

Let’s say the query is "iss" and we have the list ["Mississippi", "Kisses"]. Both contain "iss", the resulting map for the matches are:

mapOf(0 to listOf(1, 4), 1 to listOf(1))

This information is used by the highlight function to generate an annotated string.

fun highlight(
query: String,
items: List<String>,
matches: Map<Int, List<Int>>
): List<AnnotatedString> {
if (query.isEmpty()) return items.map { AnnotatedString(it) }
return matches
.map { entry -> // build list of annotated string from the index and character positions
buildAnnotatedString {
append(items[entry.key])
entry.value.forEach { startPos ->
addStyle(
style = SpanStyle(background = Color.Yellow),
start = startPos,
end = startPos + query.length
)
}
}
}
}
view raw highlight.kt hosted with ❤ by GitHub
Example Usage
val customItems = listOf(
"Apple", "Banana", "Cherry", "Date", "Orange", "Pineapple", "Pear"
"Avocado", "Watermelon", "Grape"
)
val scope = rememberCoroutineScope()
val searchState = remember { SearchDialogState(scope, customItems) }
SearchDialog(
searchDialogState = searchState,
placeholder = "Choose fruit",
onSelectItem = { fruit ->
// Handle fruit selection
}
)
view raw Usage.kt hosted with ❤ by GitHub

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

This component was built out of necessity — to fix an everyday annoyance — and it turned into something flexible and powerful. With a few Compose APIs and a solid algorithm like the suffix array, you can make simple things better.

If you’re dealing with searchable lists, especially in forms or filters, give this approach a try. You won’t need a database, a search engine, or even regular expressions. Just a well-structured in-memory index, a little debounce logic, and some UI glue.

Here’s a link to the full implementation. That’s all for now!

This article was previously published on proandroiddev.com.

Menu