Skip to content

Instantly share code, notes, and snippets.

@fvilarino
Last active March 18, 2024 00:18
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save fvilarino/997169b74410a7821e393551431f732d to your computer and use it in GitHub Desktop.
Save fvilarino/997169b74410a7821e393551431f732d to your computer and use it in GitHub Desktop.
Chip Selector - Final
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