Blog Infos
Author
Published
Topics
Published
Topics

 

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

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Building State Holders in Compose with Molecule: A New Approach to Reusable UI Components

Are your ViewModels exponentially growing out of control as they manage the state for each of your Composables? This talk introduces Molecule, a new library for creating state holders in Jetpack Compose.
Watch Video

Building State Holders in Compose with Molecule: A New Approach to Reusable UI Components

Jack Adams
Senion Android Engineer
Trainline

Building State Holders in Compose with Molecule: A New Approach to Reusable UI Components

Jack Adams
Senion Android Engin ...
Trainline

Building State Holders in Compose with Molecule: A New Approach to Reusable UI Components

Jack Adams
Senion Android Engineer
Trainline

Jobs

@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

https://developer.android.com

This article was originally published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
Compose is a relatively young technology for writing declarative UI. Many developers don’t even…
READ MORE
blog
When it comes to the contentDescription-attribute, I’ve noticed a couple of things Android devs…
READ MORE
blog
In this article we’ll go through how to own a legacy code that is…
READ MORE
blog
Compose is part of the Jetpack Library released by Android last spring. Create Android…
READ MORE
Menu