Reputation: 1290
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:
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:
@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()
}
},
)
}
}
}
}
}
Upvotes: 3
Views: 299