ga('send', 'pageview');
Categories
Blogg Mjukvaruhantverk Teknik Utvecklare

Improving the Compose DropdownMenu

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!

Thank you for reading!

You can also read the original blog here and find more blogs from Peter here

@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 },
)

By Peter Törnhult

Android utvecklare, Techcoach, Scrum master

Leave a Reply

Your email address will not be published. Required fields are marked *