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
相关产品推荐
相关产品推荐

