-
-
Save Pluu/9c20b79755c9bcff2ef2e6a128aa47c1 to your computer and use it in GitHub Desktop.
Color Picker
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
class MainActivity : ComponentActivity() { | |
override fun onCreate(savedInstanceState: Bundle?) { | |
WindowCompat.setDecorFitsSystemWindows(window, false) | |
super.onCreate(savedInstanceState) | |
setContent { | |
ColorPickerTheme { | |
Surface( | |
modifier = Modifier.fillMaxSize(), | |
color = MaterialTheme.colorScheme.background | |
) { | |
val density = LocalDensity.current | |
var parentSize by remember { mutableStateOf(DpSize.Zero) } | |
Column( | |
modifier = Modifier.systemBarsPadding(), | |
) { | |
val colorHolder = rememberColorHolder(density, parentSize) | |
ColorCardStack( | |
modifier = Modifier | |
.fillMaxWidth() | |
.weight(1f) | |
.onGloballyPositioned { | |
parentSize = with(density) { | |
DpSize( | |
width = it.size.width.toDp(), | |
height = it.size.height.toDp(), | |
) | |
} | |
}, | |
colorHolder = colorHolder | |
) | |
ColorPicker( | |
modifier = Modifier.padding(16.dp), | |
colorHolder = colorHolder | |
) | |
} | |
} | |
} | |
} | |
} | |
} | |
@Composable | |
fun ColorCardStack( | |
modifier: Modifier, | |
colorHolder: ColorHolder | |
) { | |
val context = LocalContext.current | |
val showToast = remember(context) {{ color: Color -> | |
Toast.makeText( | |
context, | |
"${color.format()} copied to clipboard!", | |
Toast.LENGTH_LONG | |
).show() | |
}} | |
Box( | |
modifier = modifier, | |
contentAlignment = Alignment.Center | |
) { | |
colorHolder.colorCardStack.forEach { colorCardData -> | |
key(colorCardData.id) { | |
ColorCard( | |
colorCardData = colorCardData, | |
onClick = { | |
showToast(it) | |
}, | |
onPopAnimationFinished = { | |
colorHolder.remove(colorCardData) | |
} | |
) | |
} | |
} | |
} | |
} | |
@Composable | |
fun ColorPicker( | |
modifier: Modifier, | |
colorHolder: ColorHolder | |
) { | |
Column( | |
modifier = modifier, | |
) { | |
ColorSlider( | |
header = "Hue", | |
value = colorHolder.currentHue / 360f, | |
onValueChange = { | |
colorHolder.set(Hue, it * 360f) | |
}, | |
onPlus = { | |
colorHolder.increase(Hue) | |
}, | |
onMinus = { | |
colorHolder.decrease(Hue) | |
} | |
) | |
ColorSlider( | |
header = "Saturation", | |
value = colorHolder.currentSaturation, | |
onValueChange = { | |
colorHolder.set(Saturation, it) | |
}, | |
onPlus = { | |
colorHolder.increase(Saturation) | |
}, | |
onMinus = { | |
colorHolder.decrease(Saturation) | |
} | |
) | |
ColorSlider( | |
header = "Lightness", | |
value = colorHolder.currentLightness, | |
onValueChange = { | |
colorHolder.set(Lightness, it) | |
}, | |
onPlus = { | |
colorHolder.increase(Lightness) | |
}, | |
onMinus = { | |
colorHolder.decrease(Lightness) | |
} | |
) | |
Button(onClick = { | |
colorHolder.pop() | |
}) { | |
Text(text = "Undo") | |
} | |
} | |
} | |
@Composable | |
fun ColorSlider( | |
header: String, | |
value: Float, | |
onValueChange: (Float) -> Unit, | |
onPlus: () -> Unit, | |
onMinus: () -> Unit, | |
) { | |
Column { | |
Text(text = header) | |
Row( | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
Slider( | |
modifier = Modifier.weight(1f), | |
value = value, | |
onValueChange = onValueChange | |
) | |
Spacer(modifier = Modifier.width(16.dp)) | |
Button(onClick = onMinus) { | |
Text(text = "-") | |
} | |
Spacer(modifier = Modifier.width(16.dp)) | |
Button(onClick = onPlus) { | |
Text(text = "+") | |
} | |
} | |
} | |
} | |
@Composable | |
fun rememberColorHolder( | |
density: Density, | |
parentSize: DpSize, | |
): ColorHolder { | |
return remember(density, parentSize) { | |
ColorHolder(density, parentSize) | |
} | |
} | |
class ColorHolder internal constructor( | |
private val density: Density, | |
private val parentSize: DpSize, | |
) { | |
var currentHue by mutableFloatStateOf(0f) | |
var currentSaturation by mutableFloatStateOf(0.5f) | |
var currentLightness by mutableFloatStateOf(0.5f) | |
val colorCardStack = mutableStateListOf<ColorCardData>() | |
private val currentColor: Color get() = Color.hsl(currentHue, currentSaturation, currentLightness) | |
init { | |
createColorCard() | |
} | |
fun increase(type: Type) = addUpdatedColor { | |
when (type) { | |
Hue -> currentHue = (currentHue + 1f).coerceIn(0f, 360f) | |
Saturation -> currentSaturation = (currentSaturation + 0.01f).coerceIn(0f, 1f) | |
Lightness -> currentLightness = (currentLightness + 0.01f).coerceIn(0f, 1f) | |
} | |
} | |
fun decrease(type: Type) = addUpdatedColor { | |
when (type) { | |
Hue -> currentHue = (currentHue - 1f).coerceIn(0f, 360f) | |
Saturation -> currentSaturation = (currentSaturation - 0.01f).coerceIn(0f, 1f) | |
Lightness -> currentLightness = (currentLightness - 0.01f).coerceIn(0f, 1f) | |
} | |
} | |
fun set(type: Type, value: Float) = addUpdatedColor { | |
when (type) { | |
Hue -> currentHue = value | |
Saturation -> currentSaturation = value | |
Lightness -> currentLightness = value | |
} | |
} | |
private fun createColorCard() { | |
colorCardStack.add(ColorCardData.createRandom(currentColor, density, parentSize)) | |
} | |
private fun addUpdatedColor(block: () -> Unit) { | |
block() | |
createColorCard() | |
if (colorCardStack.size > 110) colorCardStack.removeRange(0, 10) | |
} | |
fun pop() { | |
val indexOfLastNonDirtyColorCard = colorCardStack.indexOfLast { !it.dirty } | |
if (indexOfLastNonDirtyColorCard >= 0) { | |
val item = colorCardStack[indexOfLastNonDirtyColorCard].copy( | |
dirty = true | |
) | |
colorCardStack.removeAt(indexOfLastNonDirtyColorCard) | |
colorCardStack.add(indexOfLastNonDirtyColorCard, item) | |
} | |
} | |
fun remove(colorCardData: ColorCardData) { | |
colorCardStack.remove(colorCardData) | |
} | |
enum class Type { | |
Hue, Saturation, Lightness | |
} | |
} | |
data class ColorCardData( | |
// Unique id | |
val id: String, | |
// The color | |
val color: Color, | |
// The rotation in degrees of when the card is created offscreen | |
val rotationStart: Float, | |
// The rotation in degrees of when the card reached the destination | |
val rotationEnd: Float, | |
// The rotation in degrees of when the card is popped | |
val rotationPop: Float, | |
// Where the card starts when created | |
val translationStart: IntOffset, | |
// Where the card rests until it's popped | |
val translationEnd: IntOffset, | |
// Where the card moves when it's popped | |
val translationPop: IntOffset, | |
// How high is a popped card tossed up before it falls off the screen | |
val popHeight: Int, | |
// When true, card is currently being popped. Delete it once translationPop is reached | |
val dirty: Boolean = false, | |
) { | |
companion object { | |
fun createRandom(color: Color, density: Density, parentSize: DpSize) = ColorCardData( | |
id = UUID.randomUUID().toString(), | |
color = color, | |
rotationStart = Random.nextInt(-90, 90).toFloat(), | |
rotationEnd = Random.nextInt(-30, 30).toFloat(), | |
rotationPop = Random.nextInt(-720, 720).toFloat(), | |
translationStart = density.run { | |
IntOffset( | |
x = 0, | |
y = -(parentSize.height * 2).roundToPx() | |
) | |
}, | |
translationEnd = density.run { | |
IntOffset( | |
x = Random.nextInt(-25, 25).dp.roundToPx(), | |
y = Random.nextInt(-25, 25).dp.roundToPx(), | |
) | |
}, | |
translationPop = density.run { | |
IntOffset( | |
x = Random.nextInt(-300, 300).dp.roundToPx(), | |
y = (parentSize.height * 2).roundToPx() | |
) | |
}, | |
popHeight = density.run { Random.nextInt(100, 300).dp.roundToPx() } | |
) | |
} | |
} | |
@Composable | |
fun ColorCard( | |
colorCardData: ColorCardData, | |
onClick: (Color) -> Unit, | |
onPopAnimationFinished: () -> Unit, | |
) { | |
var state by remember { mutableStateOf(State.Start) } | |
// Animates between the states Start -> End -> Pop | |
val transition = updateTransition(targetState = state) | |
// Notify parent that the pop animation has finished. Animation driven logic, nice! | |
LaunchedEffect(transition.isRunning) { | |
if (transition.currentState == State.Pop && !transition.isRunning) { | |
onPopAnimationFinished() | |
} | |
} | |
// On composition: Start -> End | |
// Once marked as dirty: End -> Pop | |
LaunchedEffect(colorCardData.dirty) { | |
state = if (colorCardData.dirty) { | |
State.Pop | |
} else { | |
State.End | |
} | |
} | |
val offset by transition.animateIntOffset( | |
transitionSpec = { | |
if (targetState == State.End) spring() else { | |
keyframes { | |
durationMillis = popAnimationDuration | |
// Toss up -> decelerate | |
IntOffset( | |
x = colorCardData.translationPop.x / 2, | |
y = -colorCardData.popHeight | |
) at popAnimationDuration / 2 with EaseOut | |
// Fall off the screen -> accelerate | |
colorCardData.translationPop at popAnimationDuration with EaseIn | |
} | |
} | |
} | |
) { | |
when (it) { | |
State.Start -> colorCardData.translationStart | |
State.End -> colorCardData.translationEnd | |
State.Pop -> colorCardData.translationPop | |
} | |
} | |
val degrees by transition.animateFloat( | |
transitionSpec = { | |
if (targetState == State.End) spring() else tween(popAnimationDuration, easing = EaseIn) // Match with offset animation when popped | |
} | |
) { | |
when (it) { | |
State.Start -> colorCardData.rotationStart | |
State.End -> colorCardData.rotationEnd | |
State.Pop -> colorCardData.rotationPop | |
} | |
} | |
Card( | |
modifier = Modifier | |
.size(256.dp) | |
.offset { | |
offset | |
} | |
.graphicsLayer { | |
rotationZ = degrees | |
}, | |
colors = CardDefaults.cardColors(Color.White), | |
shape = RoundedCornerShape(12.dp) | |
) { | |
Card( | |
modifier = Modifier | |
.padding(4.dp) | |
.fillMaxSize(), | |
colors = CardDefaults.cardColors(colorCardData.color), | |
shape = RoundedCornerShape(8.dp), | |
onClick = { | |
onClick(colorCardData.color) | |
} | |
) { | |
Text( | |
modifier = Modifier.padding(8.dp), | |
text = colorCardData.color.format(), | |
style = MaterialTheme.typography.bodyLarge, | |
color = Color.White | |
) | |
} | |
} | |
} | |
enum class State { | |
Start, End, Pop | |
} | |
private fun Color.format() = String.format("#%06X", 0xFFFFFF and this.toArgb()) | |
const val popAnimationDuration = 500 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
https://x.com/Snokbert/status/1773832627868299377?s=20
b.mp4