Created
August 27, 2024 07:02
-
-
Save teovladusic/3d7c1aeea82938ed3e77ca894ea8a2a2 to your computer and use it in GitHub Desktop.
Rate your experience animation - Jetpack Compose
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
package com.puzzle_agency.androidknowledge.knowledge.rate_your_experience | |
import androidx.compose.animation.core.Animatable | |
import androidx.compose.animation.core.tween | |
import androidx.compose.foundation.Canvas | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.gestures.detectHorizontalDragGestures | |
import androidx.compose.foundation.layout.Arrangement | |
import androidx.compose.foundation.layout.Box | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.Row | |
import androidx.compose.foundation.layout.Spacer | |
import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.height | |
import androidx.compose.foundation.layout.offset | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.layout.size | |
import androidx.compose.foundation.layout.width | |
import androidx.compose.foundation.shape.CircleShape | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.material3.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.getValue | |
import androidx.compose.runtime.mutableFloatStateOf | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.runtime.rememberCoroutineScope | |
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.draw.rotate | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.graphics.Path | |
import androidx.compose.ui.graphics.StrokeCap | |
import androidx.compose.ui.graphics.StrokeJoin | |
import androidx.compose.ui.graphics.drawscope.Stroke | |
import androidx.compose.ui.graphics.lerp | |
import androidx.compose.ui.input.pointer.pointerInput | |
import androidx.compose.ui.platform.LocalConfiguration | |
import androidx.compose.ui.text.font.FontWeight | |
import androidx.compose.ui.tooling.preview.Preview | |
import androidx.compose.ui.unit.Dp | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.unit.lerp | |
import androidx.compose.ui.unit.sp | |
import kotlinx.coroutines.launch | |
import kotlin.math.abs | |
@Composable | |
fun RateYourExperienceScreen() { | |
var experience by remember { mutableStateOf(Experience.Good) } | |
var color by remember { mutableStateOf(experience.color) } | |
var mouthRotation by remember { mutableFloatStateOf(0f) } | |
var darkColor by remember { mutableStateOf(experience.darkColor) } | |
var eyeHeight by remember { mutableStateOf(88.dp) } | |
var eyeWidth by remember { mutableStateOf(88.dp) } | |
var eyeRotation by remember { mutableFloatStateOf(0f) } | |
var sliderColor by remember { mutableStateOf(experience.sliderColor) } | |
var badTextPosition by remember { mutableStateOf((-350).dp) } | |
var notBadTextPosition by remember { mutableStateOf((-350).dp) } | |
var goodTextPosition by remember { mutableStateOf((0).dp) } | |
Column( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(color), | |
verticalArrangement = Arrangement.Center, | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
Column( | |
modifier = Modifier.height(100.dp), | |
verticalArrangement = Arrangement.Center, | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
Row { | |
Eye( | |
experience = Experience.Good, | |
color = darkColor, | |
eyeWidth, | |
eyeHeight, | |
-eyeRotation | |
) | |
Spacer(modifier = Modifier.width(16.dp)) | |
Eye( | |
experience = Experience.Good, | |
color = darkColor, | |
eyeWidth, | |
eyeHeight, | |
eyeRotation | |
) | |
} | |
Spacer(modifier = Modifier.height(16.dp)) | |
Mouth(mouthRotation, darkColor) | |
} | |
Spacer(modifier = Modifier.height(44.dp)) | |
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { | |
Text( | |
text = Experience.Good.text, | |
fontSize = 60.sp, | |
letterSpacing = (-4.5).sp, | |
fontWeight = FontWeight.Black, | |
color = Color.Black.copy(alpha = .35f), | |
modifier = Modifier.offset(x = goodTextPosition) | |
) | |
Text( | |
text = Experience.Bad.text, | |
fontSize = 60.sp, | |
letterSpacing = (-4.5).sp, | |
fontWeight = FontWeight.Black, | |
color = Color.Black.copy(alpha = .35f), | |
modifier = Modifier.offset(x = badTextPosition) | |
) | |
Text( | |
text = Experience.NotBad.text, | |
fontSize = 60.sp, | |
letterSpacing = (-4.5).sp, | |
fontWeight = FontWeight.Black, | |
color = Color.Black.copy(alpha = .35f), | |
modifier = Modifier.offset(x = notBadTextPosition) | |
) | |
} | |
Spacer(modifier = Modifier.height(32.dp)) | |
SliderExperience( | |
experience = experience, | |
darkColor = darkColor, | |
sliderColor = sliderColor | |
) { percentage -> | |
val targetColor: Color | |
val targetRotation: Float | |
val targetDarkColor: Color | |
val targetSliderColor: Color | |
when { | |
percentage <= 0.5f -> { | |
targetColor = lerp( | |
Experience.Bad.color, Experience.NotBad.color, percentage / 0.5f | |
) | |
targetRotation = 180f | |
targetDarkColor = lerp( | |
Experience.Bad.darkColor, Experience.NotBad.darkColor, percentage / 0.5f | |
) | |
eyeHeight = lerp( | |
32.dp, 28.dp, percentage / .5f | |
) | |
eyeWidth = lerp( | |
32.dp, 88.dp, percentage / .5f | |
) | |
eyeRotation = androidx.compose.ui.util.lerp( | |
100f, 0f, percentage / .5f | |
) | |
targetSliderColor = lerp( | |
Experience.Bad.sliderColor, Experience.NotBad.sliderColor, percentage / 0.5f | |
) | |
badTextPosition = lerp(0.dp, 300.dp, percentage / 0.5f) | |
} | |
else -> { | |
targetColor = lerp( | |
Experience.NotBad.color, | |
Experience.Good.color, | |
(percentage - 0.5f) / 0.5f | |
) | |
targetRotation = | |
androidx.compose.ui.util.lerp(180f, 0f, (percentage - 0.5f) / 0.5f) | |
targetDarkColor = lerp( | |
Experience.NotBad.darkColor, | |
Experience.Good.darkColor, | |
(percentage - 0.5f) / 0.5f | |
) | |
eyeHeight = lerp( | |
28.dp, 88.dp, (percentage - 0.5f) / 0.5f | |
) | |
eyeWidth = lerp( | |
88.dp, 88.dp, (percentage - 0.5f) / 0.5f | |
) | |
eyeRotation = androidx.compose.ui.util.lerp( | |
0f, 0f, percentage / .5f | |
) | |
targetSliderColor = lerp( | |
Experience.NotBad.sliderColor, | |
Experience.Good.sliderColor, | |
(percentage - 0.5f) / 0.5f | |
) | |
badTextPosition = (-300).dp | |
} | |
} | |
notBadTextPosition = when { | |
percentage <= .5f -> { | |
lerp((-350).dp, 0.dp, percentage / 0.5f) | |
} | |
percentage <= .75f -> { | |
lerp(0.dp, 350.dp, (percentage - 0.5f) / 0.25f) | |
} | |
else -> (-350).dp | |
} | |
goodTextPosition = if (percentage > .75f) | |
lerp((-300).dp, 0.dp, (percentage - 0.75f) / 0.25f) | |
else (-300).dp | |
color = lerp(color, targetColor, percentage) | |
darkColor = lerp(darkColor, targetDarkColor, percentage) | |
mouthRotation = androidx.compose.ui.util.lerp(mouthRotation, targetRotation, percentage) | |
sliderColor = lerp(sliderColor, targetSliderColor, percentage) | |
experience = when (percentage) { | |
in .45f..(.55f) -> Experience.NotBad | |
in 0f..(.44f) -> Experience.Bad | |
else -> Experience.Good | |
} | |
} | |
} | |
} | |
@Composable | |
private fun SliderExperience( | |
experience: Experience, | |
darkColor: Color, | |
sliderColor: Color, | |
onScroll: (Float) -> Unit | |
) { | |
val coroutineScope = rememberCoroutineScope() | |
val screenWidth = LocalConfiguration.current.screenWidthDp | |
val padding = 64 + 12 + 24 | |
val minOffset = 0f | |
val maxOffset = screenWidth - padding | |
val offsetInCenter = maxOffset / 2 | |
val dragDp = remember { Animatable(maxOffset.toFloat()) } | |
Box( | |
modifier = Modifier | |
.padding(horizontal = 32.dp) | |
.fillMaxWidth() | |
) { | |
Box( | |
modifier = Modifier | |
.padding(top = 8.5.dp, end = 4.dp) | |
.padding(horizontal = 6.dp) | |
.fillMaxWidth() | |
.background(sliderColor) | |
.height(6.dp) | |
) | |
Text( | |
text = "Bad", | |
color = darkColor, | |
modifier = Modifier | |
.offset(y = 30.dp, x = 4.dp) | |
.align(Alignment.CenterStart) | |
) | |
Text( | |
text = "Not bad", | |
color = darkColor, | |
modifier = Modifier | |
.offset(y = 30.dp) | |
.align(Alignment.Center) | |
) | |
Text( | |
text = "Good", | |
color = darkColor, | |
modifier = Modifier | |
.offset(x = (0).dp, y = 30.dp) | |
.align(Alignment.CenterEnd) | |
) | |
Row( | |
modifier = Modifier | |
.padding(horizontal = 6.dp) | |
.fillMaxWidth(), | |
verticalAlignment = Alignment.CenterVertically, | |
horizontalArrangement = Arrangement.SpaceBetween | |
) { | |
Box( | |
modifier = Modifier | |
.size(24.dp) | |
.clip(CircleShape) | |
.background(sliderColor) | |
) | |
Box( | |
modifier = Modifier | |
.size(24.dp) | |
.clip(CircleShape) | |
.background(sliderColor) | |
) | |
Box( | |
modifier = Modifier | |
.size(24.dp) | |
.clip(CircleShape) | |
.background(sliderColor) | |
) | |
} | |
Box( | |
modifier = Modifier | |
.offset(y = (-8).dp, x = dragDp.value.dp) | |
.pointerInput(Unit) { | |
detectHorizontalDragGestures( | |
onDragEnd = { | |
val offset = snapToClosestOffset( | |
dragDp.value, | |
minOffset, | |
offsetInCenter.toFloat(), | |
maxOffset.toFloat() | |
) | |
onScroll(offset / maxOffset) | |
coroutineScope.launch { | |
dragDp.animateTo(offset) | |
} | |
} | |
) { change, dragAmount -> | |
change.consume() | |
val dp = dragAmount.toDp() | |
val newDrag = | |
(dragDp.targetValue.dp + dp).coerceIn(minOffset.dp, maxOffset.dp) | |
val scrolledPercentage = newDrag.value / maxOffset | |
onScroll(scrolledPercentage) | |
coroutineScope.launch { | |
dragDp.animateTo( | |
newDrag.value, | |
animationSpec = tween(durationMillis = 0) | |
) | |
} | |
} | |
} | |
.size(36.dp) | |
.clip(CircleShape) | |
.background(color = darkColor) | |
) | |
} | |
} | |
fun snapToClosestOffset( | |
currentOffset: Float, | |
minOffset: Float, | |
centerOffset: Float, | |
maxOffset: Float | |
): Float { | |
val distances = mapOf( | |
minOffset to abs(currentOffset - minOffset), | |
centerOffset to abs(currentOffset - centerOffset), | |
maxOffset to abs(currentOffset - maxOffset) | |
) | |
return distances.minByOrNull { it.value }?.key ?: currentOffset | |
} | |
@Composable | |
private fun Mouth(rotation: Float, darkColor: Color) { | |
Canvas( | |
modifier = Modifier.rotate(rotation), | |
onDraw = { | |
val path = Path().apply { | |
moveTo(-50f, 0f) // Move to the starting point | |
quadraticBezierTo( | |
000f, | |
50f, | |
50f, | |
000f | |
) // Create a quadratic bezier curve for the smile | |
} | |
drawPath( | |
path = path, | |
color = darkColor, | |
style = Stroke( | |
width = 25f, | |
cap = StrokeCap.Round, | |
join = StrokeJoin.Round | |
) | |
) | |
} | |
) | |
} | |
@Composable | |
private fun Eye(experience: Experience, color: Color, width: Dp, height: Dp, eyeRotation: Float) { | |
val shape = when (experience) { | |
Experience.Good -> CircleShape | |
Experience.NotBad -> RoundedCornerShape(24.dp) | |
Experience.Bad -> CircleShape | |
} | |
Box( | |
modifier = Modifier | |
.rotate(eyeRotation) | |
.clip(shape) | |
.size(width = width, height = height) | |
.background(color) | |
) | |
} | |
@Preview | |
@Composable | |
private fun Preview() { | |
RateYourExperienceScreen() | |
} | |
val colorGood = Color(color = 0xFFA8C25C) | |
val colorNotBad = Color(color = 0xFFDFA141) | |
val colorBad = Color(color = 0xFFFF7759) | |
enum class Experience(val color: Color) { | |
Good(colorGood), | |
NotBad(colorNotBad), | |
Bad(colorBad); | |
val text: String | |
@Composable get() = when (this) { | |
Good -> "GOOD" | |
NotBad -> "NOT BAD" | |
Bad -> "BAD" | |
} | |
val darkColor: Color | |
get() = when (this) { | |
Good -> Color(color = 0xFF1D4000) | |
NotBad -> Color(color = 0xFF4E2800) | |
Bad -> Color(color = 0xFF830E01) | |
} | |
val sliderColor: Color | |
get() = when (this) { | |
Good -> Color(color = 0xFF9EB84B) | |
NotBad -> Color(color = 0xFFD79721) | |
Bad -> Color(color = 0xFFFA6E5A) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment