
Enhancing Dropdown Menus in Jetpack Compose: Implementing Searchable Selection
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:
- 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.
- 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. - 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
: AnOutlinedTextField
that shows the currently selected item. It should be read-only to prevent accidental modification.searchContent
: ATextField
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 ofDropdownMenuItem
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 whensearchText
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
)
}
}
)
}
)
}