Skip to content

Instantly share code, notes, and snippets.

@KlassenKonstantin
Created March 29, 2024 21:51
Show Gist options
  • Star 22 You must be signed in to star a gist
  • Fork 6 You must be signed in to fork a gist
  • Save KlassenKonstantin/9b6e6d4d09e04c905f0e2dfbcbb685de to your computer and use it in GitHub Desktop.
Save KlassenKonstantin/9b6e6d4d09e04c905f0e2dfbcbb685de to your computer and use it in GitHub Desktop.
Color Picker
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