ProAndroidDev

The latest posts from Android Professionals and Google Developer Experts.

Follow publication

Enhancing Dropdown Menus in Jetpack Compose: Implementing Searchable Selection

Kerry Bisset
ProAndroidDev
Published in
9 min readMar 9, 2025

Have you ever struggled to find a specific item in a lengthy dropdown menu using Jetpack Compose? While Compose simplifies many aspects of UI development, handling extensive lists in dropdowns can still be quite tedious. Standard dropdown components, although straightforward, fall short in efficiently managing large datasets, negatively affecting user experience.

This article addresses these shortcomings by demonstrating how to build a searchable dropdown menu. We’ll explore adding intuitive search functionality directly into your Compose dropdowns, enhancing usability and overall interaction.

Defining the Structure of a Dropdown Composable

A well-designed dropdown composable typically consists of three essential parts:

  1. Display Field: This is usually a non-editable text field or label clearly showing the currently selected item. The primary purpose is to represent the current selection accurately — what we call the “truth.” Allowing direct editing of this field can confuse users and blur the distinction between selection and search, especially in cases where the dropdown supports dynamic or filtered content.
  2. Dropdown Items: Dropdown items are traditionally represented by components such as DropdownMenuItem in Jetpack Compose. The selectable entries are presented to the user upon interaction with the dropdown. Each item in this list should clearly communicate its selectable state and reflect the available options.
  3. Search Field (New Addition): The innovation here is introducing an editable search field, distinct from the display field, dedicated solely to filtering dropdown items based on user input. This search field dynamically updates the visible dropdown items, significantly enhancing the usability of the dropdown in scenarios with many possible selections.

One might wonder, “Why not simply make the display field itself editable to handle searching?” The distinction lies in clarity and accuracy. An editable display field might inadvertently lead to users changing or mistaking their actual selection, which is confusing. Separating the selection display from the filtering input ensures that users always clearly understand their current selection versus their search query.

Breaking Down the Searchable Dropdown API in Jetpack Compose

To build a searchable dropdown menu in Jetpack Compose, we need a structured API that separates different responsibilities while allowing flexibility. The ExposedSearchMenu composable is the primary entry point, providing hooks for customization while maintaining a smooth user experience.

Key Components of ExposedSearchMenu

The API is designed with flexibility in mind, consisting of several key parts:

Expansion State Management

  • The dropdown visibility is controlled by an expanded state.
  • A callback onExpandedChange handles changes to this state.

Item List Handling

  • The items parameter accepts a list of generic type <T>, allowing for flexibility with different data models.
  • itemContent is a composable lambda that dictates how each dropdown item is rendered.

Handling Empty States

  • noItemsContent provides a customizable composable for displaying when no items match the search query.

Search Functionality

  • searchContent defines how the search input field is presented inside the dropdown.
  • This is distinct from the display field to maintain clarity between selection and filtering.

Display Field Integration

  • displayContent defines how the currently selected item is shown.
  • This is scoped within ExposedDropdownMenuBoxScope, ensuring it integrates seamlessly into the dropdown system.

Entry Point: ExposedSearchMenu

Below is the core structure of the ExposedSearchMenu, which brings together these elements:

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun <T> ExposedSearchMenu(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
items: List<T>,
itemContent: @Composable (T) -> Unit,
searchContent: @Composable () -> Unit,
displayContent: @Composable ExposedDropdownMenuBoxScope.(Modifier) -> Unit,
modifier: Modifier = Modifier,
noItemsContent: @Composable () -> Unit = {
Text("No items", modifier = Modifier.padding(8.dp).fillMaxWidth(), textAlign = TextAlign.Center)
},
) {
ExposedDropdownMenuBox(
expanded = expanded, onExpandedChange = onExpandedChange,
modifier = modifier
) {
displayContent(Modifier)
ExposedSearchableDropDownMenu(
expanded = expanded,
onDismissRequest = { onExpandedChange.invoke(false) },
) {
searchContent()
if (items.isEmpty()) {
noItemsContent()
} else {
items.forEach { item ->
itemContent(item)
}
}
}
}
}

Internal Mechanism: ExposedSearchableDropDownMenu

The ExposedSearchableDropDownMenu composable is responsible for:

  • Managing popup behavior and positioning.
  • Controlling the expansion state through animations.
  • Integrating scroll support for large lists.

The implementation ensures that the dropdown appears in a natural position relative to its anchor element:

@Composable
internal fun ExposedDropdownMenuBoxScope.ExposedSearchableDropDownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
scrollState: ScrollState = rememberScrollState()
,
content: @Composable ColumnScope.() -> Unit
) {
val expandedStates = remember { MutableTransitionState(false) }
expandedStates.targetState = expanded

if (expandedStates.currentState || expandedStates.targetState) {
val transformOriginState = remember { mutableStateOf(TransformOrigin.Center) }
val density = LocalDensity.current
val popupPositionProvider =
DropdownMenuPositionProvider(DpOffset.Zero, density) { parentBounds, menuBounds ->
transformOriginState.value = calculateTransformOrigin(parentBounds, menuBounds)
}

ExposedDropdownMenuPopup(
onDismissRequest = onDismissRequest,
popupPositionProvider = popupPositionProvider
) {
DropdownMenuContent(
expandedStates = expandedStates,
transformOriginState = transformOriginState,
scrollState = scrollState,
modifier = modifier.exposedDropdownSize(),
content = content
)
}
}
}

@Immutable
internal data class DropdownMenuPositionProvider(
val contentOffset: DpOffset,
val density: Density,
val onPositionCalculated: (IntRect, IntRect) -> Unit = { _, _ -> }
) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize
)
: IntOffset {
// The min margin above and below the menu, relative to the screen.
val verticalMargin = with(density) { MenuVerticalMargin.roundToPx() }
// The content offset specified using the dropdown offset parameter.
val contentOffsetX =
with(density) {
contentOffset.x.roundToPx() *
(if (layoutDirection == LayoutDirection.Ltr) 1 else -1)
}
val contentOffsetY = with(density) { contentOffset.y.roundToPx() }

// Compute horizontal position.
val leftToAnchorLeft = anchorBounds.left + contentOffsetX
val rightToAnchorRight = anchorBounds.right - popupContentSize.width + contentOffsetX
val rightToWindowRight = windowSize.width - popupContentSize.width
val leftToWindowLeft = 0
val x =
if (layoutDirection == LayoutDirection.Ltr) {
sequenceOf(
leftToAnchorLeft,
rightToAnchorRight,
// If the anchor gets outside of the window on the left, we want to position
// toDisplayLeft for proximity to the anchor. Otherwise, toDisplayRight.
if (anchorBounds.left >= 0) rightToWindowRight else leftToWindowLeft
)
} else {
sequenceOf(
rightToAnchorRight,
leftToAnchorLeft,
// If the anchor gets outside of the window on the right, we want to
// position
// toDisplayRight for proximity to the anchor. Otherwise, toDisplayLeft.
if (anchorBounds.right <= windowSize.width) leftToWindowLeft
else rightToWindowRight
)
}
.firstOrNull { it >= 0 && it + popupContentSize.width <= windowSize.width }
?: rightToAnchorRight

// Compute vertical position.
val topToAnchorBottom = maxOf(anchorBounds.bottom + contentOffsetY, verticalMargin)
val bottomToAnchorTop = anchorBounds.top - popupContentSize.height + contentOffsetY
val centerToAnchorTop = anchorBounds.top - popupContentSize.height / 2 + contentOffsetY
val bottomToWindowBottom = windowSize.height - popupContentSize.height - verticalMargin
val y =
sequenceOf(
topToAnchorBottom,
bottomToAnchorTop,
centerToAnchorTop,
bottomToWindowBottom
)
.firstOrNull {
it >= verticalMargin &&
it + popupContentSize.height <= windowSize.height - verticalMargin
} ?: bottomToAnchorTop

onPositionCalculated(
anchorBounds,
IntRect(x, y, x + popupContentSize.width, y + popupContentSize.height)
)
return IntOffset(x, y)
}
}

internal fun calculateTransformOrigin(parentBounds: IntRect, menuBounds: IntRect): TransformOrigin {
val pivotX =
when {
menuBounds.left >= parentBounds.right -> 0f
menuBounds.right <= parentBounds.left -> 1f
menuBounds.width == 0 -> 0f
else -> {
val intersectionCenter =
(max(parentBounds.left, menuBounds.left) +
min(parentBounds.right, menuBounds.right)) / 2
(intersectionCenter - menuBounds.left).toFloat() / menuBounds.width
}
}
val pivotY =
when {
menuBounds.top >= parentBounds.bottom -> 0f
menuBounds.bottom <= parentBounds.top -> 1f
menuBounds.height == 0 -> 0f
else -> {
val intersectionCenter =
(max(parentBounds.top, menuBounds.top) +
min(parentBounds.bottom, menuBounds.bottom)) / 2
(intersectionCenter - menuBounds.top).toFloat() / menuBounds.height
}
}
return TransformOrigin(pivotX, pivotY)
}

@Composable
internal fun ExposedDropdownMenuPopup(
onDismissRequest: (() -> Unit)?,
popupPositionProvider: PopupPositionProvider,
content: @Composable () -> Unit
)
{
var focusManager: FocusManager? by mutableStateOf(null)
var inputModeManager: InputModeManager? by mutableStateOf(null)
Popup(
popupPositionProvider = popupPositionProvider,
onDismissRequest = onDismissRequest,

/*
LOOK HERE
This is the most important thing for allowing text entry.
*/


properties = PopupProperties(focusable = true),
onKeyEvent = null
) {
focusManager = LocalFocusManager.current
inputModeManager = LocalInputModeManager.current
content()
}
}

@Composable
internal fun DropdownMenuContent(
expandedStates: MutableTransitionState<Boolean>,
transformOriginState: MutableState<TransformOrigin>,
scrollState: ScrollState,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
)
{
// Menu open/close animation.
val transition = rememberTransition(expandedStates, "DropDownMenu")

val scale by
transition.animateFloat(
transitionSpec = {
if (false isTransitioningTo true) {
// Dismissed to expanded
tween(durationMillis = InTransitionDuration, easing = LinearOutSlowInEasing)
} else {
// Expanded to dismissed.
tween(durationMillis = 1, delayMillis = OutTransitionDuration - 1)
}
}
) {
if (it) {
// Menu is expanded.
1f
} else {
// Menu is dismissed.
0.8f
}
}

val alpha by
transition.animateFloat(
transitionSpec = {
if (false isTransitioningTo true) {
// Dismissed to expanded
tween(durationMillis = 30)
} else {
// Expanded to dismissed.
tween(durationMillis = OutTransitionDuration)
}
}
) {
if (it) {
// Menu is expanded.
1f
} else {
// Menu is dismissed.
0f
}
}
Card(
modifier =
Modifier.graphicsLayer {
scaleX = scale
scaleY = scale
this.alpha = alpha
transformOrigin = transformOriginState.value
},
) {
Column(
modifier =
modifier
.padding(vertical = DropdownMenuVerticalPadding)
.width(IntrinsicSize.Max)
.verticalScroll(scrollState),
content = content
)
}
}

Implementing a Searchable Dropdown in Jetpack Compose

To implement a searchable dropdown in Jetpack Compose, we need to extend the standard dropdown functionality by integrating a search field that dynamically filters available options. This approach enhances user experience by making it easier to find specific items in long lists.

Breaking Down the Implementation

The implementation consists of several key elements:

State Management:
We use remember to manage UI states such as the search query, selection, and expansion state of the dropdown.

Filtering Mechanism:
The list of dropdown items is filtered using derivedStateOf, ensuring that the displayed options react efficiently to user input without unnecessary recomputation.

Composable Structure:

  • displayContent: An OutlinedTextField that shows the currently selected item. It should be read-only to prevent accidental modification.
  • searchContent: A TextField placed inside the dropdown for real-time filtering. While I intended it for text filtering, it can be used for other types of filtering, such as chips.
  • itemContent: A list of DropdownMenuItem components representing the selectable options.

Code Implementation

Below is the structured implementation of a searchable dropdown using an ExposedSearchMenu:

@Composable
fun SearchableDropdownMenu() {
var searchText by remember { mutableStateOf("") }
val testItems = listOf("Test", "Test2", "Test3", "Example", "Sample", "Demo")

val filteredList by remember {
derivedStateOf {
testItems.filter { it.contains(searchText, ignoreCase = true) }
}
}

var selection by remember { mutableStateOf("") }
var expanded by remember { mutableStateOf(false) }

ExposedSearchMenu(
expanded = expanded,
onExpandedChange = { expanded = it },
items = filteredList,
itemContent = { item ->
DropdownMenuItem(
text = { Text(item) },
onClick = {
selection = item
expanded = false
}
)
},
searchContent = {
TextField(
value = searchText,
onValueChange = { searchText = it },
label = { Text("Search") },
modifier = Modifier.fillMaxWidth()
)
},
displayContent = {
OutlinedTextField(
value = selection,
onValueChange = { },
label = { Text("Selection") },
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable),
readOnly = true,
trailingIcon = {
IconButton(onClick = { expanded = !expanded }) {
Icon(
imageVector = if (expanded) Icons.Filled.ArrowDropUp else Icons.Filled.ArrowDropDown,
contentDescription = null
)
}
}
)
}
)
}

Why This Approach Works

  • Efficient Filtering: The derivedStateOf ensures that filtering is recomputed only when searchText changes.
  • Clear Selection and Search Separation: Users can search freely without modifying their selected item.
  • Better UX: The dropdown remains user-friendly for large datasets, preventing excessive scrolling.

By structuring the dropdown in this way, we enhance usability while maintaining clarity in selection and search functionalities. This approach ensures an intuitive and responsive dropdown experience, making data selection easy for users.

Leveraging ViewModel and MVI for State Management

While the previous implementation effectively manages state locally within the composable, a more scalable approach for real-world applications is to separate UI state from business logic using Model-View-Intent (MVI) architecture.

Why Use ViewModel?

  • Searching through a list might involve querying a database or making a remote API call.
  • UI state should be persisted across configuration changes, avoiding unnecessary recomputations.
  • Decoupling logic from UI ensures cleaner, testable, and maintainable code.

Refactoring with ViewModel

Instead of keeping searchText, filteredList, and selection inside the composable, we can move them to a ViewModel:

class DropdownViewModel : ViewModel() {
private val _searchText = MutableStateFlow("")
val searchText: StateFlow<String> = _searchText.asStateFlow()

private val _items = MutableStateFlow(listOf("Test", "Test2", "Test3", "Example", "Sample", "Demo"))
val filteredList: StateFlow<List<String>> = _searchText
.combine(_items) { search, items ->
items.filter { it.contains(search, ignoreCase = true) }
}
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())

private val _selection = MutableStateFlow("")
val selection: StateFlow<String> = _selection.asStateFlow()

fun onSearchTextChanged(text: String) {
_searchText.value = text
}

fun onItemSelected(item: String) {
_selection.value = item
}
}

Updating the Composable

Then we modify our SearchableDropdownMenu to rely on the ViewModel:

@Composable
fun SearchableDropdownMenu(viewModel: DropdownViewModel = viewModel()) {
val searchText by viewModel.searchText.collectAsState()
val filteredList by viewModel.filteredList.collectAsState()
val selection by viewModel.selection.collectAsState()
var expanded by remember { mutableStateOf(false) }

ExposedSearchMenu(
expanded = expanded,
onExpandedChange = { expanded = it },
items = filteredList,
itemContent = { item ->
DropdownMenuItem(
text = { Text(item) },
onClick = {
viewModel.onItemSelected(item)
expanded = false
}
)
},
searchContent = {
TextField(
value = searchText,
onValueChange = { viewModel.onSearchTextChanged(it) },
label = { Text("Search") },
modifier = Modifier.fillMaxWidth()
)
},
displayContent = {
OutlinedTextField(
value = selection,
onValueChange = { },
label = { Text("Selection") },
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryEditable),
readOnly = true,
trailingIcon = {
IconButton(onClick = { expanded = !expanded }) {
Icon(
imageVector = if (expanded) Icons.Filled.ArrowDropUp else Icons.Filled.ArrowDropDown,
contentDescription = null
)
}
}
)
}
)
}

No responses yet

Write a response