Last active
March 18, 2024 00:18
-
-
Save fvilarino/997169b74410a7821e393551431f732d to your computer and use it in GitHub Desktop.
Chip Selector - Final
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
private const val AnimationDurationMillis = 600 | |
enum class SelectionMode(val index: Int) { | |
Single(0), | |
Multiple(1); | |
companion object { | |
fun fromIndex(index: Int) = entries.firstOrNull { it.index == index } ?: Single | |
} | |
} | |
@Stable | |
interface ChipSelectorState { | |
val chips: List<String> | |
val selectedChips: List<String> | |
fun onChipClick(chip: String) | |
fun isSelected(chip: String): Boolean | |
} | |
class ChipSelectorStateImpl( | |
override val chips: List<String>, | |
selectedChips: List<String> = emptyList(), | |
val mode: SelectionMode = SelectionMode.Single, | |
) : ChipSelectorState { | |
override var selectedChips by mutableStateOf(selectedChips) | |
override fun onChipClick(chip: String) { | |
if (mode == SelectionMode.Single) { | |
if (!selectedChips.contains(chip)) { | |
selectedChips = listOf(chip) | |
} | |
} else { | |
selectedChips = if (selectedChips.contains(chip)) { | |
selectedChips - chip | |
} else { | |
selectedChips + chip | |
} | |
} | |
} | |
override fun isSelected(chip: String): Boolean = selectedChips.contains(chip) | |
override fun equals(other: Any?): Boolean { | |
if (this === other) return true | |
if (javaClass != other?.javaClass) return false | |
other as ChipSelectorStateImpl | |
if (chips != other.chips) return false | |
if (mode != other.mode) return false | |
if (selectedChips != other.selectedChips) return false | |
return true | |
} | |
override fun hashCode(): Int { | |
var result = chips.hashCode() | |
result = 31 * result + mode.hashCode() | |
result = 31 * result + selectedChips.hashCode() | |
return result | |
} | |
companion object { | |
val saver = Saver<ChipSelectorStateImpl, List<*>>( | |
save = { state -> | |
buildList { | |
add(state.chips.size) | |
addAll(state.chips) | |
add(state.selectedChips.size) | |
addAll(state.selectedChips) | |
add(state.mode.index) | |
} | |
}, | |
restore = { items -> | |
var index = 0 | |
val chipsSize = items[index++] as Int | |
val chips = List(chipsSize) { | |
items[index++] as String | |
} | |
val selectedSize = items[index++] as Int | |
val selectedChips = List(selectedSize) { | |
items[index++] as String | |
} | |
val mode = SelectionMode.fromIndex(items[index] as Int) | |
ChipSelectorStateImpl( | |
chips = chips, | |
selectedChips = selectedChips, | |
mode = mode, | |
) | |
} | |
) | |
} | |
} | |
@Composable | |
fun rememberChipSelectorState( | |
chips: List<String>, | |
selectedChips: List<String> = emptyList(), | |
mode: SelectionMode = SelectionMode.Single, | |
): ChipSelectorState { | |
if (chips.isEmpty()) error("No chips provided") | |
if (mode == SelectionMode.Single && selectedChips.size > 1) { | |
error("Single choice can only have 1 pre-selected chip") | |
} | |
return rememberSaveable( | |
saver = ChipSelectorStateImpl.saver | |
) { | |
ChipSelectorStateImpl( | |
chips, | |
selectedChips, | |
mode, | |
) | |
} | |
} | |
@OptIn(ExperimentalLayoutApi::class) | |
@Composable | |
fun ChipsSelector( | |
state: ChipSelectorState, | |
modifier: Modifier = Modifier, | |
selectedTextColor: Color = MaterialTheme.colorScheme.onPrimaryContainer, | |
unselectedTextColor: Color = MaterialTheme.colorScheme.onSecondaryContainer, | |
selectedBackgroundColor: Color = MaterialTheme.colorScheme.primaryContainer, | |
unselectedBackgroundColor: Color = MaterialTheme.colorScheme.secondaryContainer, | |
borderColor: Color = MaterialTheme.colorScheme.onSurface, | |
borderWidth: Dp = 2.dp, | |
horizontalArrangement: Arrangement.Horizontal = Arrangement.spacedBy(16.dp), | |
verticalArrangement: Arrangement.Vertical = Arrangement.spacedBy(16.dp), | |
) { | |
FlowRow( | |
modifier = modifier, | |
horizontalArrangement = horizontalArrangement, | |
verticalArrangement = verticalArrangement, | |
) { | |
state.chips.forEach { chip -> | |
Chip( | |
label = chip, | |
isSelected = state.isSelected(chip), | |
onClick = { state.onChipClick(chip) }, | |
selectedTextColor = selectedTextColor, | |
unselectedTextColor = unselectedTextColor, | |
selectedBackgroundColor = selectedBackgroundColor, | |
unselectedBackgroundColor = unselectedBackgroundColor, | |
borderColor = borderColor, | |
borderWidth = borderWidth, | |
) | |
} | |
} | |
} | |
@Composable | |
fun Chip( | |
label: String, | |
isSelected: Boolean, | |
onClick: () -> Unit, | |
modifier: Modifier = Modifier, | |
selectedTextColor: Color = MaterialTheme.colorScheme.onPrimaryContainer, | |
unselectedTextColor: Color = MaterialTheme.colorScheme.onSecondaryContainer, | |
selectedBackgroundColor: Color = MaterialTheme.colorScheme.primaryContainer, | |
unselectedBackgroundColor: Color = MaterialTheme.colorScheme.secondaryContainer, | |
borderColor: Color = MaterialTheme.colorScheme.onSurface, | |
borderWidth: Dp = 2.dp, | |
) { | |
val interactionSource = remember { MutableInteractionSource() } | |
val path = remember { Path() } | |
val borderWidthPx = with(LocalDensity.current) { borderWidth.toPx() } | |
val pathMeasure = remember { PathMeasure() } | |
val pathSegment = remember { Path() } | |
val transition = updateTransition(targetState = isSelected, label = "transition") | |
val pathFraction by transition.animateFloat( | |
transitionSpec = { tween(durationMillis = AnimationDurationMillis) }, | |
label = "pathSegment" | |
) { selected -> | |
if (selected) 1f else 0f | |
} | |
val backgroundColor by transition.animateColor( | |
transitionSpec = { tween(durationMillis = AnimationDurationMillis) }, | |
label = "backgroundColor" | |
) { selected -> | |
if (selected) selectedBackgroundColor else unselectedBackgroundColor | |
} | |
val textColor by transition.animateColor( | |
transitionSpec = { tween(durationMillis = AnimationDurationMillis) }, | |
label = "textColor", | |
) { selected -> | |
if (selected) selectedTextColor else unselectedTextColor | |
} | |
val textAlpha by transition.animateFloat( | |
transitionSpec = { tween(durationMillis = AnimationDurationMillis) }, | |
label = "textAlpha" | |
) { selected -> | |
if (selected) 1f else .6f | |
} | |
Box( | |
modifier = modifier | |
.drawWithCache { | |
computePath(path, size, borderWidthPx) | |
pathMeasure.setPath(path, false) | |
pathSegment.reset() | |
pathMeasure.getSegment(0f, pathMeasure.length * pathFraction, pathSegment) | |
onDrawBehind { | |
drawPath( | |
path = path, | |
color = backgroundColor, | |
style = Fill, | |
) | |
drawPath( | |
path = pathSegment, | |
color = borderColor, | |
style = Stroke(width = borderWidthPx), | |
) | |
} | |
} | |
.clickable(interactionSource = interactionSource, indication = null, onClick = onClick) | |
) { | |
Text( | |
text = label, | |
color = textColor, | |
fontWeight = FontWeight.Bold, | |
modifier = Modifier | |
.padding(horizontal = 24.dp, vertical = 16.dp) | |
.graphicsLayer { alpha = textAlpha } | |
) | |
} | |
} | |
private fun computePath( | |
path: Path, | |
size: Size, | |
borderWidth: Float, | |
) { | |
val cornerRadius = size.height / 2f | |
with(path) { | |
moveTo(borderWidth + size.width / 2f, borderWidth) | |
lineTo(size.width - borderWidth - cornerRadius, borderWidth) | |
quadraticBezierTo( | |
size.width - borderWidth, | |
borderWidth, | |
size.width - borderWidth, | |
borderWidth + cornerRadius, | |
) | |
quadraticBezierTo( | |
size.width - borderWidth, | |
size.height - borderWidth, | |
size.width - borderWidth - cornerRadius, | |
size.height - borderWidth, | |
) | |
lineTo(borderWidth + cornerRadius, size.height - borderWidth) | |
quadraticBezierTo( | |
borderWidth, | |
size.height - borderWidth, | |
borderWidth, | |
size.height - cornerRadius - borderWidth, | |
) | |
quadraticBezierTo( | |
borderWidth, | |
borderWidth, | |
borderWidth + cornerRadius, | |
borderWidth, | |
) | |
close() | |
} | |
} | |
@Preview | |
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) | |
@Composable | |
private fun PreviewChip() { | |
PlaygroundTheme { | |
Surface( | |
color = MaterialTheme.colorScheme.background | |
) { | |
var isSelected by remember { | |
mutableStateOf(false) | |
} | |
Row( | |
horizontalArrangement = Arrangement.spacedBy(16.dp), | |
modifier = Modifier.padding(all = 24.dp) | |
) { | |
Chip( | |
label = "Avocado", | |
isSelected = isSelected, | |
onClick = { isSelected = !isSelected }, | |
) | |
Chip( | |
label = "Strawberry", | |
isSelected = true, | |
onClick = { isSelected = !isSelected }, | |
) | |
} | |
} | |
} | |
} | |
@Preview(widthDp = 720) | |
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, widthDp = 720) | |
@Composable | |
private fun PreviewChipSelector() { | |
PlaygroundTheme { | |
Surface( | |
color = MaterialTheme.colorScheme.background, | |
) { | |
val chips = remember { | |
listOf("Banana", "Blueberry", "Strawberry", "Pineapple", "Avocado") | |
} | |
Column( | |
verticalArrangement = Arrangement.spacedBy(16.dp), | |
modifier = Modifier.padding(all = 16.dp) | |
) { | |
Text(text = "Single selection") | |
val state1 = rememberChipSelectorState(chips = chips, selectedChips = listOf("Blueberry")) | |
ChipsSelector( | |
state = state1, | |
modifier = Modifier.fillMaxWidth(), | |
) | |
Text(text = "Multiple selection") | |
val state2 = rememberChipSelectorState(chips = chips, mode = SelectionMode.Multiple) | |
ChipsSelector( | |
state = state2, | |
modifier = Modifier.fillMaxWidth(), | |
) | |
} | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment