You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Jetpack Compose中模拟下拉组件的Popup定位与尺寸适配问题

Jetpack Compose下拉组件的定位与尺寸问题

我尝试用Jetpack Compose复刻某应用的下拉组件,但在尺寸和定位上遇到了问题,预期效果包含折叠和展开两种状态。试过M3的轮廓文本框没达到预期,于是改用AnimatedVisibility结合Popup模拟下拉功能,Popup里包了一个叫PaginatedLazyColumn的LazyColumn包装器,和充当文本框的Box位置大致重合。现在遇到三个问题:

  • 目前手动估算偏移量,怎么让Popup顶部和文本框Box顶部对齐?
  • 两个Dropdown组件用weight(0.5f)并排时,文本框尺寸正常,但Popup总是占满宽度,没法调整?
  • 当下拉框靠近屏幕底部时,怎么实现要么扩展到系统导航栏后变成可滚动,要么向上展开,让Popup底部和文本框底部对齐?

初始实现代码

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

参考M3 DropdownMenu源码的尝试代码

我还尝试参考M3 DropdownMenu的源码调整定位和尺寸,但在两个Dropdown以weight(0.5f)并排等场景下还是无法正常工作,相关代码如下:

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

内容的提问来源于stack exchange,提问作者Stelios Papamichail

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.26 04:50:56