Engineering

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.

MobileByteSensei 6 min read
Day 43 — SearchBar has two states, and the second one takes over the
androidcomposekotlinmaterial3

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

The case this post is about

One search bar that traps the user:

var query by rememberSaveable { mutableStateOf("") }
var expanded by rememberSaveable { mutableStateOf(false) }
SearchBar(
    inputField = {
        SearchBarDefaults.InputField(
            query = query,

Tap the bar, it expands to a full-screen surface with suggestions, and press back — the app closes. Not the search, the app. Because expanding a search bar is a state change inside one screen, and the system has no idea a place was entered that the user might want to leave.

This post follows that one bar, because the fix is a back handler and the reason it is needed is structural: an expanded search bar is a destination that does not exist in the navigation graph, so nothing except that handler knows it can be exited.

The symptom: a search bar that traps the user

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 a BackHandler is the wrong fix

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 mechanism: SearchBar has two distinct states

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: one instrumented test for the back behaviour

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: components encode behaviour, not just looks

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.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS