When migrating to Compose you may have come upon the DropdownMenu
or the ExposedDropdownMenuBox
+ ExposedDropdownMenu
. These APIs provide an easy interface for showing a list of items in a dropdown menu to let users pick a choice of items. However, if you have more than just a few items to pick from, you may also have noticed that it’s slow. While I like the use of standard APIs in Compose this made me create my own alternative dropdown components (specifically for the ExposedDropdownMenu
in Material3).
Why is it so slow?
The DropdownMenu
in Compose uses a Column
internally to display all the items. I say all the items because that is kind of what Column
does, it renders/handles all the items at once. Even on lists with just over 100 items, the user will get a noticeable lag when trying to open up the dropdown menu.
I came across this problem when migrating from the Spinner item in AndroidX to the Compose DropdownMenu
and was quite annoyed that opening a list of countries (249 to be exact) sometimes felt like it took several seconds before the UI responded again. It was slow even in the production builds, so I had to come up with an alternative solution. I don’t know why the DropdownMenu
just doesn’t use a LazyColumn
internally, but that’s what I needed.
Why did I try and reinvent the wheel?
Others have also found this to be a problem and tried to implement different solutions. I didn’t want to have to pull in a new dependency for this (if there even exists one) and I didn’t want to spend too much time trying to get the DropdownMenu
to use a LazyColumn
, since others have already tried that and failed.
Select or not to select
The example implementations I’ve seen allow the user to select and even edit the text. I prefer this to be disabled and instead work more like a button. This prohibits any auto-suggest functionality from being implemented, but I wouldn’t want the keyboard to appear if the list is opened anyway. But that is just my preference.
Selectable text (though not editable and the keyboard doesn`t open)
Clickable text with ripple
Positioning
I’m not sold on the dropdown menu positioning, always trying to show itself next to the item being opened. That works well for small lists so they are more context-aware, but is not a good fit for large lists IMO as the lists quite often just show a small number of items in a limited list. I rather prefer either a bottom sheet list look or a dialog list in the center of the screen so that more of the screen real estate can be utilized to scroll through the list options.
The 3 items on the left look good. The 10 items in the center are also fine. The 7 items on the right are limiting and may even be worse on other phones.
Bottom sheet
Since modal bottom sheets are implemented quite differently in Compose, as to AndroidX (where they could just be opened as Dialog
) this seemed like too much work to get right. It might be possible to customize the Dialog
to appear as a bottom sheet, but I haven’t tried it so I don’t know how much work that would require. 🤷
Dialog
The Compose Dialog
is quite easy to use. Once it’s added to a Composable it is displayed on top of all content on the screen, making it modal.
@Composable fun Dialog( onDismissRequest: () -> Unit, properties: DialogProperties = DialogProperties(), content: @Composable () -> Unit )
var showDialog by remember { mutableStateOf(false) } // TODO Implement logic to set showDialog = true if (showDialog) { Dialog( onDismissRequest = { showDialog = false }, ) { ... } }
More features
These are some features I also wanted since I was implementing my own dropdown composable anyway. It would have been possible to implement these using the standard DropdownMenu
as well (though scrolling to an item in a Column
can be difficult).
- Enable/disable
- Simple but flexible API
- Typed items
- Optional “Not set” item
- Scroll to the selected item when the dropdown is opened
- Highlight selected item
Implementation
Long story short, this is the final result including the code (see below). The list opens up instantly and the user can’t press anything on the screen around the dialog by mistake.
One downside to this approach is now with small lists instead. If the dropdown is far to the top or bottom, the list is still opened in the center of the screen, creating a small disassociation.
Another downside/bug I’ve found is when navigating with the Tab key (on a keyboard), both the text input and the surface above will get focus. That can probably be fixed, to provide better accessibility support, but it’s nothing I’m concerned with ATM.
Overall I’m happy that the list is always fast now and has a clean look. I hope you like it too 😊
Thank you for reading!
Job Offers
@Composable fun <T> LargeDropdownMenu( modifier: Modifier = Modifier, enabled: Boolean = true, label: String, notSetLabel: String? = null, items: List<T>, selectedIndex: Int = -1, onItemSelected: (index: Int, item: T) -> Unit, selectedItemToString: (T) -> String = { it.toString() }, drawItem: @Composable (T, Boolean, Boolean, () -> Unit) -> Unit = { item, selected, itemEnabled, onClick -> LargeDropdownMenuItem( text = item.toString(), selected = selected, enabled = itemEnabled, onClick = onClick, ) }, ) { var expanded by remember { mutableStateOf(false) } Box(modifier = modifier.height(IntrinsicSize.Min)) { OutlinedTextField( label = { Text(label) }, value = items.getOrNull(selectedIndex)?.let { selectedItemToString(it) } ?: "", enabled = enabled, modifier = Modifier.fillMaxWidth(), trailingIcon = { val icon = expanded.select(Icons.Filled.ArrowDropUp, Icons.Filled.ArrowDropDown) Icon(icon, "") }, onValueChange = { }, readOnly = true, ) // Transparent clickable surface on top of OutlinedTextField Surface( modifier = Modifier .fillMaxSize() .padding(top = 8.dp) .clip(MaterialTheme.shapes.extraSmall) .clickable(enabled = enabled) { expanded = true }, color = Color.Transparent, ) {} } if (expanded) { Dialog( onDismissRequest = { expanded = false }, ) { MyTheme { Surface( shape = RoundedCornerShape(12.dp), ) { val listState = rememberLazyListState() if (selectedIndex > -1) { LaunchedEffect("ScrollToSelected") { listState.scrollToItem(index = selectedIndex) } } LazyColumn(modifier = Modifier.fillMaxWidth(), state = listState) { if (notSetLabel != null) { item { LargeDropdownMenuItem( text = notSetLabel, selected = false, enabled = false, onClick = { }, ) } } itemsIndexed(items) { index, item -> val selectedItem = index == selectedIndex drawItem( item, selectedItem, true ) { onItemSelected(index, item) expanded = false } if (index < items.lastIndex) { Divider(modifier = Modifier.padding(horizontal = 16.dp)) } } } } } } } } @Composable fun LargeDropdownMenuItem( text: String, selected: Boolean, enabled: Boolean, onClick: () -> Unit, ) { val contentColor = when { !enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = ALPHA_DISABLED) selected -> MaterialTheme.colorScheme.primary.copy(alpha = ALPHA_FULL) else -> MaterialTheme.colorScheme.onSurface.copy(alpha = ALPHA_FULL) } CompositionLocalProvider(LocalContentColor provides contentColor) { Box(modifier = Modifier .clickable(enabled) { onClick() } .fillMaxWidth() .padding(16.dp)) { Text( text = text, style = MaterialTheme.typography.titleSmall, ) } } }
Usage
var selectedIndex by remember { mutableStateOf(-1) } LargeDropdownMenu( label = "Sample", items = listOf("Item 1", "Item 2", "Item 3"), selectedIndex = selectedIndex, onItemSelected = { index, _ -> selectedIndex = index }, )
References
This article was originally published on proandroiddev.com