Stelios Papamichail
Stelios Papamichail

Reputation: 1290

Positioning & sizing a PopUp composable to fake a DropDown component

I am trying to recreate a drop-down-like component that I found on another app using Jetpack Compose, but I am facing issues around sizing and positioning. Here is the approximate look I am going for:

collapsed

expanded

I decided to "fake" the dropdown functionality after messing around with the M3 outlined text field and not managing to get the right look. Instead, I use AnimatedVisibility and hide/show a PopUp that encapsulates a LazyColumn wrapper called PaginatedLazyColumn at approximately the same position as the Box acting as a text field. My issues are the following:

  1. Right now I am using an offset that i have basically just eyeballed, how can I position the popup so that its top starts at the top of the box representing the text field?
  2. I have not managed to alter the size (i.e. width) of the PopUp in any way when I need to use two of the DropDowns next to each other with weight(0.5f) respectively. When I do that, the source box-text field has the right size, but the PopUp always seems to take up the full space.
  3. In the case that the DropDown is near the bottom of the screen, how can I make it so that it either just expands until the system nav bar and then becomes scrollable or that it moves above the field, basically having its bottom side aligning with the bottom side of the field?
@Composable
fun LyraDropDown(
    modifier: Modifier = Modifier,
    data: List<String>,
    defaultSelectionIndex: Int = 0,
    label: String,
    onClick: (Int) -> Unit,
    paginationCallback: () -> Unit = {},
    itemContent: @Composable (Modifier, String) -> Unit
) {
    var isExpanded by remember {
        mutableStateOf(false)
    }
    var dropdownWidth by remember {
        mutableIntStateOf(0)
    }
    var selectedItemIndex by remember {
        mutableIntStateOf(defaultSelectionIndex)
    }
    val listWithDefaultOption by remember {
        derivedStateOf { (data.toMutableList().also { it.add(0, "No selection") }.toList()) }
    }

    Box(
        modifier
            .background(colorResource(id = R.color.white), shape = RoundedCornerShape(8.dp))
            .border(1.dp, colorResource(id = R.color.gray_200), RoundedCornerShape(8.dp))
            .clickable {
                isExpanded = true
            }
    ) {
        Row(
            Modifier
                .fillMaxWidth()
                .padding(horizontal = 16.dp, vertical = 16.dp)
                .onSizeChanged {
                    dropdownWidth = it.width
                },
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.SpaceBetween
        ) {
            Text(listWithDefaultOption[selectedItemIndex], fontSize = 12.sp)
            Icon(
                painter = painterResource(id = if (!isExpanded) 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)
            )
        }
        AnimatedVisibility(visible = isExpanded, enter = fadeIn(), exit = fadeOut()) {
            Popup(
                alignment = Alignment.TopCenter,
                offset = IntOffset(0, 48),
                onDismissRequest = {
                    isExpanded = false
                },
                properties = PopupProperties(
                    focusable = true,
                    clippingEnabled = false,
                    dismissOnClickOutside = true,
                    dismissOnBackPress = true
                )
            ) {
                Box(
                    modifier
                        .height(200.dp)
                        .width(dropdownWidth.dp)
                        .background(
                            colorResource(id = R.color.white),
                            shape = RoundedCornerShape(8.dp)
                        )
                        .border(
                            1.dp,
                            colorResource(id = R.color.gray_200),
                            RoundedCornerShape(8.dp)
                        )
                        .shadow(2.dp, shape = RoundedCornerShape(8.dp), clip = true)
                ) {
                    PaginatedLazyColumn(
                        modifier = Modifier
                            .animateContentSize(),
                        items = listWithDefaultOption,
                        itemKey = { UUID.randomUUID() },
                        itemContentIndexed = { selectionOption, index ->
                            val bgShape = when (index) {
                                0 -> RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)
                                listWithDefaultOption.size - 1 -> RoundedCornerShape(
                                    bottomStart = 8.dp,
                                    bottomEnd = 8.dp
                                )

                                else -> RoundedCornerShape(0.dp)
                            }
                            itemContent(
                                Modifier
                                    .fillMaxWidth()
                                    .background(
                                        colorResource(id = if (index == selectedItemIndex) R.color.blue_400 else R.color.white),
                                        shape = bgShape
                                    )
                                    .padding(horizontal = 16.dp, vertical = 16.dp)
                                    .clickable(remember { MutableInteractionSource() }, null) {
                                        isExpanded = false
                                        selectedItemIndex = index
                                        onClick(index)
                                    }, selectionOption
                            )
                            if (index != data.size - 1) HorizontalDivider(color = colorResource(id = R.color.gray_200))
                        },
                        loadingItem = { LyraCircularProgressIndicator() },
                        contentWhenEmpty = { }) {
                        paginationCallback()
                    }
                }
            }
        }
    }
}

I also tried basically copying and adjusting the source code for the M3 dropdown's position and sizing in the code above. However, I couldn't really get it to work for cases where I have two dropdown composables in a row with a 0.5f weight modifier, etc.

Here is a sample code and an image of the issue with this approach:


@Composable
internal fun DropdownMenuContent(
    modifier: Modifier = Modifier,
    expandedState: MutableTransitionState<Boolean>,
    transformOriginState: MutableState<TransformOrigin>,
    scrollState: ScrollState,
    shape: Shape,
    containerWidth: Dp,
    containerPadding: PaddingValues = PaddingValues(0.dp),
    containerColor: Color,
    tonalElevation: Dp,
    shadowElevation: Dp,
    border: BorderStroke?,
    content: @Composable ColumnScope.() -> Unit
) {
    // Menu open/close animation.
    @Suppress("DEPRECATION")
    val transition = updateTransition(expandedState, "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
                )
            }
        }, label = ""
    ) { expanded ->
        if (expanded) 1f else 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)
            }
        }, label = ""
    ) { expanded ->
        if (expanded) 1f else 0f
    }

    Surface(
        modifier = Modifier
            .graphicsLayer {
                scaleX = scale
                scaleY = scale
                this.alpha = alpha
                transformOrigin = transformOriginState.value
            }
            .width(containerWidth)
            .padding(containerPadding),
        shape = shape,
        color = containerColor,
        tonalElevation = tonalElevation,
        shadowElevation = shadowElevation,
        border = border,
    ) {
        Column(
            modifier = modifier
                .width(containerWidth)
                .verticalScroll(scrollState),
            content = content
        )
    }
}

//TODO(Figure out positioning logic, bug where text is white & duplication of code due to
// modifier params and actual params)

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LyraDropDown(
    modifier: Modifier = Modifier,
    dropdownPadding: PaddingValues,
    weight: Float = 1f,
    data: List<String>,
    defaultSelectionIndex: Int = 0,
    label: String,
    onClick: (Int) -> Unit,
    paginationCallback: () -> Unit = {},
    itemContent: @Composable (Modifier, String) -> Unit
) {
    val expandedState = remember { MutableTransitionState(false) }
    var dropdownSize by remember {
        mutableStateOf(IntSize.Zero)
    }
    val dropDownWidth by remember {
        derivedStateOf {
            dropdownSize.width.dp * weight + (dropdownPadding.calculateStartPadding(
                LayoutDirection.Ltr
            ) + dropdownPadding.calculateEndPadding(LayoutDirection.Ltr))
        }
    }
    var selectedItemIndex by remember {
        mutableIntStateOf(defaultSelectionIndex)
    }
    val listWithDefaultOption = data.toMutableList().also { it.add(0, "No Selection") }.toList()
    val dropdownIconRotation by remember {
        derivedStateOf {
            if (expandedState.currentState) -180f else 0f
        }
    }
    val dropDownIconAngleTransition: Float by animateFloatAsState(
        targetValue = dropdownIconRotation,
        animationSpec = tween(
            durationMillis = 250,
            easing = LinearEasing
        ), label = "Dropdown icon rotation"
    )

    //todo:sp negative values affect right side, positive the left side dropdown
    val offset by remember {
        derivedStateOf {
            DpOffset(
                (dropdownSize.width * weight +
                        dropdownPadding.calculateStartPadding(LayoutDirection.Ltr).value +
                        dropdownPadding.calculateEndPadding(LayoutDirection.Ltr).value).dp,
                (-32).dp // 32dp is the vertical padding of the outlinetextfield
            )
        }
    }

    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
                    },
                readOnly = true,
                value = listWithDefaultOption[selectedItemIndex],
                textStyle = TextStyle(fontSize = 12.sp),
                onValueChange = { },
                trailingIcon = {
                    Icon(
                        painter = painterResource(id = R.drawable.ic_chev_down),
                        contentDescription = null,
                        modifier = Modifier
                            .size(24.dp)
                            .padding(4.dp)
                            .rotate(dropDownIconAngleTransition),
                        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),
                    focusedTextColor = colorResource(id = R.color.black),
                    unfocusedTextColor = colorResource(id = R.color.black)
                )
            )
            if (expandedState.currentState || expandedState.targetState) {
                val transformOriginState =
                    remember { mutableStateOf(TransformOrigin.Center) }
                val density = LocalDensity.current
                val popupPositionProvider = remember(offset, density) {
                    DropdownMenuPositionProvider(
                        offset,
                        density,
                        verticalMargin = 0
                    ) { parentBounds, menuBounds ->
                        transformOriginState.value =
                            calculateTransformOrigin(parentBounds, menuBounds)
                    }
                }
                Popup(
                    popupPositionProvider = popupPositionProvider,
                    onDismissRequest = {
                        expandedState.targetState = false
                    },
                ) {
                    DropdownMenuContent(
                        expandedState = expandedState,
                        transformOriginState = transformOriginState,
                        scrollState = rememberScrollState(),
                        shape = RoundedCornerShape(8.dp),
                        containerColor = colorResource(id = R.color.white),
                        containerPadding = dropdownPadding,
                        tonalElevation = 0.dp,
                        shadowElevation = 3.dp,
                        containerWidth = dropDownWidth,
                        border = BorderStroke(
                            1.dp, colorResource(id = R.color.gray_200)
                        ),
                        content = {
                            PaginatedLazyColumn(
                                modifier = Modifier
                                    .height(if (listWithDefaultOption.size <= 3) (listWithDefaultOption.size * DropDownMenuDefaults.ItemHeight).dp else (3 * DropDownMenuDefaults.ItemHeight).dp)
                                    .width(dropdownSize.width.dp)
                                    .background(
                                        colorResource(id = R.color.green_500),
                                        shape = RoundedCornerShape(8.dp)
                                    ),
                                items = listWithDefaultOption,
                                itemKey = { UUID.randomUUID() },
                                itemContentIndexed = { selectionOption, index ->
                                    val bgShape = when (index) {
                                        0 -> RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)
                                        listWithDefaultOption.size - 1 -> RoundedCornerShape(
                                            bottomStart = 8.dp,
                                            bottomEnd = 8.dp
                                        )

                                        else -> RoundedCornerShape(0.dp)
                                    }
                                    itemContent(
                                        Modifier
                                            .fillMaxWidth()
                                            .background(
                                                colorResource(id = if (index == selectedItemIndex) R.color.blue_400 else R.color.white),
                                                shape = bgShape
                                            )
                                            .padding(horizontal = 16.dp, vertical = 16.dp)
                                            .clickable(
                                                remember { MutableInteractionSource() },
                                                null
                                            ) {
                                                expandedState.targetState = false
                                                selectedItemIndex = index
                                                if (index > 0) onClick(index - 1)
                                            },
                                        selectionOption
                                    )
                                },
                                itemDivider = {
                                    HorizontalDivider(color = colorResource(id = R.color.gray_200))
                                },
                                loadingItem = { LyraCircularProgressIndicator() },
                                contentWhenEmpty = { }) {
                                paginationCallback()
                            }
                        },
                    )
                }
            }
        }
    }
}

example of the issue when using custom sizing

Upvotes: 3

Views: 299

Answers (0)

Related Questions