Search code examples
androidkotlinandroid-jetpack-composeandroid-compose-exposeddropdown

Displaying a paginated lazy list inside of an ExposedDropDown


In our app, we need to display a paginated list of options inside a drop-down menu. Since the list of data is lazy, the size is not constant and thus can cause crashes if the nested PaginatedLazyColumn() composable isn't assigned constant size values. This obviously looks bad, so my question is, how should I approach this?

Should I somehow calculate the width of the longest data string (that is available at page 0 of the data payload, since if a longer string comes along, it can be truncated) and set that as the dropdown's width, while keeping the height at a “comfortable” DP size? If so, how could I do that?

I have created the following composable that represents a drop-down in our app:

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LyraDropDown(
    modifier: Modifier = Modifier,
    data: List<String>,
    defaultSelectionIndex: Int = 0,
    label: String,
    onClick: (Int) -> Unit,
    paginationCallback: () -> Unit = {},
    itemContent: @Composable (String) -> Unit
) {
    var expanded by remember { mutableStateOf(false) }
    var selectedIndex by remember { mutableIntStateOf(defaultSelectionIndex) }
    val listWithDefaultOption = data.toMutableList()
    listWithDefaultOption.add(0, "No selection")

    ExposedDropdownMenuBox(
        modifier = modifier.background(
            colorResource(id = R.color.white),
            shape = RoundedCornerShape(8.dp)
        ),
        expanded = expanded, onExpandedChange = { expanded = !expanded }) {
        OutlinedTextField(
            modifier = Modifier
                .fillMaxWidth()
                .menuAnchor()
                .background(colorResource(id = R.color.white), shape = RoundedCornerShape(8.dp)),
            readOnly = true,
            label = {
                Text(label, fontSize = 12.sp)
            },
            value = listWithDefaultOption[selectedIndex],
            textStyle = TextStyle(fontSize = 12.sp),
            onValueChange = { },
            trailingIcon = {
                Icon(
                    painter = painterResource(id = if (!expanded) R.drawable.ic_chev_down else R.drawable.ic_chev_up),
                    contentDescription = null,
                    modifier = Modifier
                        .size(24.dp)
                        .padding(4.dp),
                    tint = colorResource(id = R.color.gray_700)
                )
            },
            shape = RoundedCornerShape(8.dp),
            colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(
                unfocusedContainerColor = colorResource(id = R.color.white),
                focusedContainerColor = colorResource(id = R.color.white),
                focusedBorderColor = colorResource(id = R.color.indigo_500),
                unfocusedBorderColor = colorResource(id = R.color.gray_200),
                focusedLabelColor = colorResource(id = R.color.blue_500),
                unfocusedLabelColor = colorResource(id = R.color.black)
            )
        )
        ExposedDropdownMenu(
            expanded = expanded,
            onDismissRequest = {
                expanded = false
            },
            modifier = Modifier
                .background(colorResource(id = R.color.white))
                .width(200.dp)
                .height(300.dp),
        ) {

            PaginatedLazyColumn(
                modifier = Modifier
                    .width(200.dp)
                    .height(300.dp),
                items = listWithDefaultOption,
                itemKey = { UUID.randomUUID() },
                itemContentIndexed = { selectionOption, index ->
                    DropdownMenuItem(
                        onClick = {
                            selectedIndex = index
                            expanded = false
                            if (index != 0) onClick(index - 1) // account for hardcoded option
                        },
                        text = {
                            itemContent(selectionOption)
                        })
                },
                loadingItem = { LyraCircularProgressIndicator() },
                contentWhenEmpty = { }) {
                paginationCallback()
            }
        }
    }
}

The PaginatedLazyColumn is defined as:

@Composable
internal fun <T> PaginatedLazyColumn(
    modifier: Modifier = Modifier,
    loading: Boolean = false,
    listState: LazyListState = rememberLazyListState(),
    items: List<T>,
    itemKey: (T) -> Any,
    itemContentIndexed: @Composable (T, Int) -> Unit,
    loadingItem: @Composable () -> Unit,
    contentWhenEmpty: @Composable () -> Unit,
    loadMore: () -> Unit
) {

    val reachedBottom: Boolean by remember { derivedStateOf { listState.reachedBottom() } }

    // load more if scrolled to bottom
    LaunchedEffect(reachedBottom) {
        if (reachedBottom && !loading) loadMore()
    }

    LazyColumn(modifier = modifier, state = listState) {
        itemsIndexed(
            items = items,
            key = { _, item -> itemKey(item) }
        ) { index, item ->
            itemContentIndexed(item, index)
        }
        if (items.isEmpty()) {
            item {
                contentWhenEmpty()
            }
        }
        if (loading) {
            item {
                loadingItem()
            }
        }
    }
}

private fun LazyListState.reachedBottom(buffer: Int = 1): Boolean {
    val lastVisibleItem = this.layoutInfo.visibleItemsInfo.lastOrNull()
    return lastVisibleItem?.index != 0 && lastVisibleItem?.index == this.layoutInfo.totalItemsCount - buffer
}

Solution

  • In the end, I ended up dynamically calculating the width and height of the PaginatedLazyColumn in order to avoid any layout sizing errors. The height is calculated based on the number of items that are in the list (with a capped height of three times the average item height) and the width is equal to the anchor's width (with some adjustments for my case).

      val dropDownWidth by remember {
        derivedStateOf {
          dropdownSize.width.dp * weight + ((dropdownPadding.calculateStartPadding(
            layoutDirection
          ).value + dropdownPadding.calculateEndPadding(layoutDirection).value)).dp
        }
      }
      val dropdownTargetHeight = remember(data) {
        if (data.size <= 3) data.size * DropDownMenuDefaults.ItemHeight else 3 * DropDownMenuDefaults.ItemHeight
      }
    
    
      Column(
        modifier = modifier,
        horizontalAlignment = Alignment.Start,
      ) {
        Text(label, fontSize = 12.sp, modifier = Modifier.fillMaxWidth())
        ExposedDropdownMenuBox(
          expanded = expandedState.currentState,
          onExpandedChange = { expandedState.targetState = !expandedState.currentState }) {
          OutlinedTextField(
            modifier = Modifier
              .fillMaxWidth()
              .menuAnchor()
              .background(colorResource(id = R.color.white), shape = RoundedCornerShape(8.dp))
              .onSizeChanged { size ->
                dropdownSize = size
              }
              .onGloballyPositioned { layoutCoordinates ->
                dropdownAnchorDistanceFromBottom = with(density) {
                  screenHeight - layoutCoordinates.positionOnScreen().y.toDp()
                }
              },
    ......
                  PaginatedLazyColumn(
                    modifier = Modifier
                      .height(dropdownTargetHeight.dp)
                      .width(dropdownSize.width.dp)
                      .background(
                        colorResource(id = R.color.white),
                        shape = if (!searchEnabled) RoundedCornerShape(8.dp) else RectangleShape
                      ),
    ......