From 9d76c168ec1deee8c54de1d70fdeaeddb99a9323 Mon Sep 17 00:00:00 2001 From: Sad Ellie Date: Thu, 8 Feb 2024 23:12:19 +0300 Subject: [PATCH] Flex chips --- .../sadellie/unitto/core/ui/common/Chip.kt | 54 ++--- .../feature/converter/LeftSideScreen.kt | 8 +- .../feature/converter/components/ChipsRow.kt | 218 ++++++++++++++---- 3 files changed, 202 insertions(+), 78 deletions(-) diff --git a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/Chip.kt b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/Chip.kt index d9ba0520..21ce2c59 100644 --- a/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/Chip.kt +++ b/core/ui/src/main/java/com/sadellie/unitto/core/ui/common/Chip.kt @@ -18,7 +18,8 @@ package com.sadellie.unitto.core.ui.common -import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColor +import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -26,7 +27,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.FilterChipDefaults @@ -40,6 +40,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview @@ -48,41 +49,37 @@ import androidx.compose.ui.unit.dp @Composable fun FilterChip( modifier: Modifier = Modifier, - selected: Boolean, + isSelected: Boolean, onClick: () -> Unit, label: String, - imageVector: ImageVector, - contentDescription: String, ) { + val transition = updateTransition(targetState = isSelected, label = "Selected transition") + val backgroundColor = transition.animateColor(label = "Background color") { + if (it) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface + } + val borderColor = transition.animateColor(label = "Border color") { + if (it) Color.Transparent else MaterialTheme.colorScheme.outline + } + Row( modifier = modifier - .background( - color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, - shape = FilterChipDefaults.shape - ) + .padding(vertical = 8.dp) + .clip(FilterChipDefaults.shape) + .clickable { onClick() } + .background(backgroundColor.value) .border( width = 1.dp, - color = if (selected) Color.Transparent else MaterialTheme.colorScheme.outline, + color = borderColor.value, shape = FilterChipDefaults.shape ) .height(FilterChipDefaults.Height) - .clickable { onClick() } - .padding(start = 8.dp, end = 16.dp), + .padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically ) { - AnimatedVisibility(visible = selected) { - Icon( - modifier = Modifier.height(FilterChipDefaults.IconSize), - imageVector = imageVector, - contentDescription = contentDescription, - tint = MaterialTheme.colorScheme.onPrimaryContainer - ) - } Text( - modifier = Modifier.padding(start = 8.dp), text = label, style = MaterialTheme.typography.labelLarge, - color = if (selected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant + color = if (isSelected) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant ) } } @@ -96,17 +93,16 @@ fun AssistChip( ) { Row( modifier = modifier - .background( - color = MaterialTheme.colorScheme.surface, - shape = AssistChipDefaults.shape - ) + .padding(vertical = 8.dp) + .clip(FilterChipDefaults.shape) + .clickable { onClick() } + .background(MaterialTheme.colorScheme.surface) .border( width = 1.dp, color = MaterialTheme.colorScheme.outline, shape = AssistChipDefaults.shape ) .height(32.dp) - .clickable { onClick() } .padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -135,10 +131,8 @@ fun PreviewFilterChip() { var isSelected by remember { mutableStateOf(true) } FilterChip( - selected = isSelected, + isSelected = isSelected, onClick = { isSelected = !isSelected }, label = "Label", - imageVector = Icons.Default.Check, - contentDescription = "" ) } diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/LeftSideScreen.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/LeftSideScreen.kt index 35060b7d..6bea6401 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/LeftSideScreen.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/LeftSideScreen.kt @@ -20,6 +20,7 @@ package com.sadellie.unitto.feature.converter import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.MaterialTheme @@ -32,6 +33,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.sadellie.unitto.core.base.R import com.sadellie.unitto.core.ui.common.EmptyScreen @@ -114,10 +116,12 @@ private fun LeftSideScreen( ) ChipsRow( + modifier = Modifier + .padding(start = 8.dp, end = 8.dp, bottom = 4.dp) + .fillMaxWidth(), chosenUnitGroup = uiState.unitGroup, items = uiState.shownUnitGroups, selectAction = updateUnitGroup, - lazyListState = chipsRowLazyListState, navigateToSettingsAction = navigateToUnitGroups ) } @@ -161,7 +165,7 @@ private fun LeftSideScreenPreview() { units = units, query = TextFieldValue("test"), favorites = false, - shownUnitGroups = listOf(UnitGroup.LENGTH, UnitGroup.TEMPERATURE, UnitGroup.CURRENCY), + shownUnitGroups = UnitGroup.entries, unitGroup = units.keys.toList().first(), sorting = UnitsListSorting.USAGE, ), diff --git a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/ChipsRow.kt b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/ChipsRow.kt index 55f54072..9b21b646 100644 --- a/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/ChipsRow.kt +++ b/feature/converter/src/main/java/com/sadellie/unitto/feature/converter/components/ChipsRow.kt @@ -18,23 +18,30 @@ package com.sadellie.unitto.feature.converter.components -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -50,51 +57,156 @@ import com.sadellie.unitto.data.model.UnitGroup * @param chosenUnitGroup Currently selected [UnitGroup] * @param selectAction Action to perform when a chip is clicked * @param navigateToSettingsAction Action to perform when clicking settings chip at the end - * @param lazyListState Used for animated scroll when entering unit selection screen */ @Composable internal fun ChipsRow( - items: List = UnitGroup.entries, + modifier: Modifier, + items: List, chosenUnitGroup: UnitGroup?, selectAction: (UnitGroup?) -> Unit, navigateToSettingsAction: () -> Unit, - lazyListState: LazyListState ) { - LazyRow( - modifier = Modifier - .padding(bottom = 4.dp) - .fillMaxWidth(), - state = lazyListState, - contentPadding = PaddingValues(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(items, key = { it.name }) { item -> - val selected: Boolean = item == chosenUnitGroup - FilterChip( - selected = selected, - onClick = { selectAction(if (selected) null else item) }, - label = stringResource(item.res), - imageVector = Icons.Default.Check, - contentDescription = stringResource(R.string.checked_filter_description) - ) + var expanded by remember { mutableStateOf(false) } + val chipModifier = Modifier.padding(horizontal = 4.dp) + + AnimatedContent( + targetState = expanded, + transitionSpec = { + expandVertically(expandFrom = Alignment.Top) { it } + fadeIn() togetherWith + shrinkVertically(shrinkTowards = Alignment.Top) { it } + fadeOut() + } + ) { isExpanded -> + FlexRow( + modifier = modifier, + maxRows = if (isExpanded) Int.MAX_VALUE else 2, + mainContent = { + items.forEach { item -> + val selected: Boolean = item == chosenUnitGroup + FilterChip( + modifier = chipModifier, + isSelected = selected, + onClick = { + selectAction(if (selected) null else item) + expanded = false + }, + label = stringResource(item.res), + ) + } + + AssistChip( + modifier = chipModifier, + onClick = navigateToSettingsAction, + imageVector = Icons.Default.Settings, + contentDescription = stringResource(R.string.open_settings_label) + ) + + if (expanded) { + AssistChip( + modifier = chipModifier, + onClick = { expanded = false }, + imageVector = Icons.Default.ExpandLess, + contentDescription = "" // TODO + ) + } + }, + expandContent = { + AssistChip( + modifier = chipModifier, + onClick = { expanded = true }, + imageVector = Icons.Default.ExpandMore, + contentDescription = "" // TODO + ) + }, + ) + } +} + +/** + * Foldable row that places a specified element if overflown. + * + * @param modifier [Modifier] to be applied to this layout. + * @param maxRows Max amount of rows including with [expandContent]. + * @param mainContent Main content (list of items) that will be folded. + * @param expandContent Item that will be placed at the end if given [maxRows] wasn't high enough to + * place all [mainContent] items. + */ +@Composable +private fun FlexRow( + modifier: Modifier = Modifier, + maxRows: Int = Int.MAX_VALUE, + mainContent: @Composable () -> Unit, + expandContent: @Composable () -> Unit, +) { + SubcomposeLayout( + modifier = modifier + ) { constraints -> + val localConstraints = constraints.copy(minWidth = 0, minHeight = 0) + val layoutWidth = localConstraints.maxWidth + + val mainMeasurables = subcompose(FlexRowSlots.Main, mainContent) + val expandMeasurables = subcompose( + slotId = FlexRowSlots.Expand, + content = { expandContent() } + ).map { + it.measure(localConstraints) + } + val expandContentWidth = expandMeasurables.sumOf { it.measuredWidth } + + val placeables = mutableListOf>(mutableListOf()) + + var widthLeft = layoutWidth + var index = 0 + for (measurable in mainMeasurables) { + val mainPlaceable = measurable.measure(localConstraints) + + val lastAvailableRow = placeables.size >= maxRows + val notLastItem = index < mainMeasurables.lastIndex + + // count expandContent width only for last row and not last main placeable + val measuredWidth = if (lastAvailableRow and notLastItem) { + mainPlaceable.measuredWidth + expandContentWidth + } else { + mainPlaceable.measuredWidth + } + + // need new row + if (widthLeft <= measuredWidth) { + // Can't add more rows, add expandContent + if (lastAvailableRow) { + expandMeasurables.forEach { + placeables.last().add(it) + } + break + } + + placeables.add(mutableListOf()) + widthLeft = layoutWidth + } + placeables.last().add(mainPlaceable) + index++ + widthLeft -= mainPlaceable.measuredWidth } - /** - * Usually this chip is placed at the start, but since we scroll to currently selected - * chip, user may never find it. There is higher chance that user will notice this chip when - * scrolling right (to the last one). - */ - item("settings") { - AssistChip( - onClick = navigateToSettingsAction, - imageVector = Icons.Default.Settings, - contentDescription = stringResource(R.string.open_settings_label) - ) + val flattenPlaceables = placeables.flatten() + val layoutHeight = placeables.size * (flattenPlaceables.maxByOrNull { it.height }?.height ?: 0) + + layout(layoutWidth, layoutHeight) { + var yPos = 0 + placeables.forEach { row -> + var xPos = 0 + row.forEach { placeable -> + placeable.place(x = xPos, y = yPos) + xPos += placeable.width + } + yPos += row.maxByOrNull { it.height }?.height ?: 0 + } } } } -@Preview +private enum class FlexRowSlots { Main, Expand } + +@Preview(device = "spec:width=380dp,height=850.9dp,dpi=440") @Composable fun PreviewUnittoChips() { var selected by remember { mutableStateOf(UnitGroup.LENGTH) } @@ -103,11 +215,25 @@ fun PreviewUnittoChips() { selected = unitGroup } - ChipsRow( - items = UnitGroup.entries, - chosenUnitGroup = selected, - selectAction = { selectAction(it) }, - navigateToSettingsAction = {}, - lazyListState = rememberLazyListState() - ) + Column { + ChipsRow( + modifier = Modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxWidth(), + items = UnitGroup.entries.take(7), + chosenUnitGroup = selected, + selectAction = { selectAction(it) }, + navigateToSettingsAction = {}, + ) + + ChipsRow( + modifier = Modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxWidth(), + items = UnitGroup.entries.take(10), + chosenUnitGroup = selected, + selectAction = { selectAction(it) }, + navigateToSettingsAction = {}, + ) + } }