SearchBar has two states, and the second one takes over the screen

Material 3's SearchBar transitions into a full-screen suggestion surface, which makes it a navigation concern. Chips come in four semantic variants, and the assist/filter/input/suggestion split matters.

6 min read
androidcomposekotlinmaterial3

Day 43 — SearchBar has two states, and the second one takes over the

Day 43 of 100, closing the components pillar. Two components that look decorative and turn out to carry navigation and semantics respectively.

The symptom

A search bar that traps the user:

var query by rememberSaveable { mutableStateOf("") }
var expanded by rememberSaveable { mutableStateOf(false) }

SearchBar(
    inputField = {
        SearchBarDefaults.InputField(
            query = query,
            onQueryChange = { query = it },
            onSearch = { expanded = false },
            expanded = expanded,
            onExpandedChange = { expanded = it },
        )
    },
    expanded = expanded,
    onExpandedChange = { expanded = it },
) {
    SuggestionList(query, onPick = { query = it; expanded = false })
}

Tap the bar, the suggestion surface fills the screen — then press back and the app exits, because nothing told the back stack that a full-screen surface opened.

Why the obvious fix fails

The obvious fix is a BackHandler:

BackHandler(enabled = expanded) { expanded = false }

Correct, and it's the whole insight rather than a workaround — this component genuinely has a navigation-shaped state and you have to say so. What fails is not writing it, which is easy because the component works fine until someone presses back.

The related failure is treating expanded as cosmetic and leaving it in remember. It's a full-screen surface; losing it on rotation drops the user out of search mid-query.

Collapsed and expanded are two different surfaces, and expanded is a navigation state

The actual mechanism

SearchBar has two states that are visually and behaviourally distinct:

Collapsed — an inline bar in the layout, typically in or below the app bar.

Expanded — a full-screen surface covering everything, showing suggestions or recent searches.

The transition is animated by the component; the consequences are yours. Three things follow from "expanded is a full-screen surface":

It needs back handling. As above.

It should be saveable. rememberSaveable for both query and expanded.

It shouldn't be inside a scrolling container. A search bar that scrolls away while expanded produces a surface anchored to nothing. Put it in the topBar slot, or pin it.

DockedSearchBar is the variant for larger windows: the suggestion surface appears as a dropdown attached to the bar rather than taking the screen. On a tablet the full-screen version wastes the space, which makes this another Day 29 branch:

if (widthClass == WindowWidthSizeClass.COMPACT) {
    SearchBar(…) { suggestions() }
} else {
    DockedSearchBar(…) { suggestions() }
}

Debounce belongs between the field and the query

Search-as-you-type without a debounce fires a request per keystroke. With Day 38's TextFieldState, the idiom is a flow rather than an effect:

LaunchedEffect(Unit) {
    snapshotFlow { searchState.text.toString() }
        .debounce(300)
        .distinctUntilChanged()
        .filter { it.length >= 2 }
        .collect { viewModel.search(it) }
}

distinctUntilChanged matters more than it looks: typing a character and deleting it returns to the same query, and without it you'd issue a second identical request.

Four chips, four meanings

Chips are the other component people treat as one thing with styles. There are four, and they differ in behaviour rather than appearance:

AssistChip — triggers an action related to the content. "Add to calendar", "Call". It's a button that looks compact; it has no selected state.

FilterChip — a toggle. Selected or not, and the selection filters something. This is the one with selected and a leading checkmark.

InputChip — represents a discrete piece of user input, and is removable. Email recipients, applied tags. It has a trailing dismiss icon.

SuggestionChip — offers something the system inferred. Query suggestions, smart replies. It disappears once acted on.

FilterChip(
    selected = category in selectedCategories,
    onClick = { toggle(category) },
    label = { Text(category.name) },
    leadingIcon = if (category in selectedCategories) {
        { Icon(Icons.Default.Check, contentDescription = null, Modifier.size(18.dp)) }
    } else null,
)

Using AssistChip for a filter is the common mistake, and it costs the selected state — so the user can't see which filters are active, and a screen reader announces "button" rather than "selected".

A row of filter chips is FlowRow from Day 26, not a horizontally scrolling Row. Filters the user can't see are filters they forget are applied.

The active count is worth surfacing too. When filters are applied but scrolled out of view — or collapsed behind a "Filters" button — a badge showing how many are active is the difference between a user who understands their results and one who thinks the app is broken.

Chips are not buttons

Two rules worth stating because the components look similar:

Chips are for contextual, dynamic options — a set that varies with the content. A fixed action that's always present is a button.

Chips should not be the primary action. Their visual weight sits below TextButton in Day 36's hierarchy, and using one for "Submit" makes the important action the quietest thing on screen.

How to prove it

The back behaviour is one instrumented test and it's the bug that reaches production:

@Test fun backCollapsesSearchBeforeExitingScreen() {
    composeRule.onNodeWithTag("search").performClick()
    composeRule.onNodeWithTag("suggestions").assertExists()
    Espresso.pressBack()
    composeRule.onNodeWithTag("suggestions").assertDoesNotExist()
    composeRule.onNodeWithTag("content").assertExists()      // still on the screen
}

For the chips, the check is a TalkBack pass over the filter row. Each selected chip should announce its selected state; if they all announce "button", they're assist chips.

Empty and recent states

The expanded surface is showing something from the moment it opens, before a single character is typed, and deciding what is part of building the component rather than a polish item:

SearchBar(…) {
    when {
        query.isEmpty() && recent.isNotEmpty() -> RecentSearches(recent, onPick = ::run)
        query.isEmpty() -> SearchTips()
        results.isEmpty() -> NoResults(query, onClear = { query = "" })
        else -> Results(results, onPick = ::open)
    }
}

Four states, and the two that get skipped are the empty-query one — which is where recent searches earn their place — and the no-results one, which should name the query that failed and offer a way out rather than showing a blank surface.

Recent searches are also the one piece of search state that belongs in the data layer rather than the composition: they outlive the screen, so by Day 12's test they're not UI element state at all.

What this generalizes to

Both halves of today are the same point the pillar has been making: components encode semantics, and the semantics have consequences beyond appearance. A search bar's expanded state is a navigation state whether or not you treat it as one. A filter chip's selection is information the accessibility tree needs.

That's the components pillar in one sentence — the library gives you widgets that already know what they mean, and most component bugs come from picking one whose meaning doesn't match the intent.

Nine days of components, and almost none of the difficulty was in the drawing. It was innerPadding being a measurement, a Switch being a promise, TextFieldState carrying a cursor the String overload discarded, a dialog being a fact rather than a call, and a date being a calendar concept rather than a timestamp. The widgets were the easy part every time.

Tomorrow, Day 44 opens the theming pillar with Material 3's colour system and why there are so many named roles.


Day 43 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Search bars.