Skip to content

Instantly share code, notes, and snippets.

@TuleSimon
Created March 5, 2026 08:58
Show Gist options
  • Select an option

  • Save TuleSimon/9fdef920e1496d788340b720bd4b291b to your computer and use it in GitHub Desktop.

Select an option

Save TuleSimon/9fdef920e1496d788340b720bd4b291b to your computer and use it in GitHub Desktop.
Animate Review Compose Implementation - Inspiration from https://www.pinterest.com/pin/1137088605932327547/
package com.anonymous.animatedreview
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.DrawableRes
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.SliderState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.drawBehind
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.lerp
import com.anonymous.animatedreview.ui.theme.AnimatedReviewTheme
import kotlinx.coroutines.launch
import androidx.compose.ui.graphics.lerp as lerpColor
private const val TRANSITION_MS = 600
private val BgGreen = Color(0xFFB2E060)
private val BgYellow = Color(0xFFF0CA43)
private val BgRed = Color(0xFFE86F6F)
private val AccentGreen = Color(0xFF4b7000)
private val AccentYellow = Color(0xFF6B5922)
private val AccentRed = Color(0xFF663130)
enum class Mood { HAPPY, MEDIUM, SAD }
private fun Float.toMood(): Mood = when {
this > 0.67f -> Mood.HAPPY
this > 0.33f -> Mood.MEDIUM
else -> Mood.SAD
}
private fun Mood.label(): String = when (this) {
Mood.HAPPY -> "GOOD"
Mood.MEDIUM -> "NOT BAD"
Mood.SAD -> "BAD"
}
/**
* Interpolates a float across three keyframes (bad → medium → good)
* based on [p] in the range 0f..1f.
*/
private fun triLerp(bad: Float, medium: Float, good: Float, p: Float): Float =
if (p <= 0.5f) lerp(bad, medium, p * 2f) else lerp(medium, good, (p - 0.5f) * 2f)
/**
* Interpolates a [Color] across three keyframes (bad → medium → good)
* based on [p] in the range 0f..1f.
*/
private fun triLerpColor(bad: Color, medium: Color, good: Color, p: Float): Color =
if (p <= 0.5f) lerpColor(bad, medium, p * 2f) else lerpColor(medium, good, (p - 0.5f) * 2f)
private fun backgroundFor(p: Float) = triLerpColor(BgRed, BgYellow, BgGreen, p)
private fun accentFor(p: Float) = triLerpColor(AccentRed, AccentYellow, AccentGreen, p)
/**
* Snaps [p] to the nearest of the three mood positions: 0f (bad), 0.5f (not bad), 1f (good).
*/
private fun snapToMood(p: Float): Float = when {
p < 0.25f -> 0f
p < 0.75f -> 0.5f
else -> 1f
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
AnimatedReviewTheme {
ReviewScreen()
}
}
}
}
/**
* Draws the animated smiley face. No internal animation — all motion is driven externally by [progress].
*
* @param progress 0f = bad/sad, 0.5f = neutral, 1f = good/happy.
* @param modifier Applied to the underlying [Canvas].
*/
@Composable
fun SmileyFaceCanvas(
progress: Float,
modifier: Modifier = Modifier
) {
val accent = accentFor(progress)
Canvas(modifier = modifier) {
val w = size.width
val h = size.height
val eyeHalfWidth = w * triLerp(bad = 0.14f, medium = 0.17f, good = 0.20f, p = progress)
val eyeHalfHeight = w * triLerp(bad = 0.14f, medium = 0.057f, good = 0.20f, p = progress)
val eyeY = h * triLerp(bad = 0.38f, medium = 0.37f, good = 0.35f, p = progress)
val leftEyeX = w * triLerp(bad = 0.30f, medium = 0.27f, good = 0.28f, p = progress)
val rightEyeX = w * triLerp(bad = 0.70f, medium = 0.73f, good = 0.72f, p = progress)
val eyeRotation = triLerp(bad = 90f, medium = 0f, good = 0f, p = progress)
withTransform({ rotate(eyeRotation, Offset(leftEyeX, eyeY)) }) {
drawRoundRect(
color = accent,
topLeft = Offset(leftEyeX - eyeHalfWidth, eyeY - eyeHalfHeight),
size = Size(eyeHalfWidth * 2f, eyeHalfHeight * 2f),
cornerRadius = CornerRadius(eyeHalfHeight, eyeHalfHeight)
)
}
withTransform({ rotate(-eyeRotation, Offset(rightEyeX, eyeY)) }) {
drawRoundRect(
color = accent,
topLeft = Offset(rightEyeX - eyeHalfWidth, eyeY - eyeHalfHeight),
size = Size(eyeHalfWidth * 2f, eyeHalfHeight * 2f),
cornerRadius = CornerRadius(eyeHalfHeight, eyeHalfHeight)
)
}
val mouthCenterX = w * 0.50f
val mouthY = h * 0.60f
val mouthHalfWidth = w * 0.1f
val mouthCurveDepth = h * 0.11f
val mouthRotation = triLerp(bad = 180f, medium = 180f, good = 0f, p = progress)
withTransform({ rotate(mouthRotation, Offset(mouthCenterX, mouthY)) }) {
drawPath(
path = Path().apply {
moveTo(mouthCenterX - mouthHalfWidth, mouthY)
quadraticTo(mouthCenterX, mouthY + mouthCurveDepth, mouthCenterX + mouthHalfWidth, mouthY)
},
color = accent,
style = Stroke(width = 16.dp.toPx(), cap = StrokeCap.Round)
)
}
}
}
/**
* The slider thumb — a small circle with a mood-matching mouth inside.
*
* @param progress Same 0f–1f scale as [SmileyFaceCanvas].
*/
@Composable
private fun SliderThumb(progress: Float) {
val accent = accentFor(progress)
val lipColor = backgroundFor(progress)
Canvas(modifier = Modifier.size(44.dp)) {
val radius = size.minDimension / 2f
val cx = size.width / 2f
val cy = size.height / 2f
drawCircle(color = accent, radius = radius)
val mouthHalfWidth = radius * 0.38f
val mouthCurveDepth = radius * 0.52f
val mouthY = cy + radius * 0.10f
val mouthRotation = triLerp(bad = 180f, medium = 180f, good = 0f, p = progress)
withTransform({ rotate(mouthRotation, Offset(cx, mouthY)) }) {
drawPath(
path = Path().apply {
moveTo(cx - mouthHalfWidth, mouthY)
quadraticTo(cx, mouthY + mouthCurveDepth, cx + mouthHalfWidth, mouthY)
},
color = lipColor,
style = Stroke(width = 4.dp.toPx(), cap = StrokeCap.Round)
)
}
}
}
/**
* Custom slider track — a semi-transparent line with circles at each snap position.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SliderTrack(@Suppress("UNUSED_PARAMETER") state: SliderState) {
val trackColor = Color.Black.copy(alpha = 0.18f)
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(44.dp)
) {
val centerY = size.height / 2f
val dotRadius = 7.dp.toPx()
drawLine(
color = trackColor,
start = Offset(0f, centerY),
end = Offset(size.width, centerY),
strokeWidth = 4.dp.toPx(),
cap = StrokeCap.Round
)
listOf(0f, 0.5f, 1f).forEach { position ->
drawCircle(
color = trackColor,
radius = dotRadius,
center = Offset(size.width * position, centerY)
)
}
}
}
/**
* The main review screen. Shows an animated smiley face driven by a three-position slider.
* Tapping "Add note" hides the slider and shows a text input.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReviewScreen() {
val scope = rememberCoroutineScope()
val progress = remember { Animatable(1f) }
val interactionSource = remember { MutableInteractionSource() }
val isSliderDragged by interactionSource.collectIsDraggedAsState()
var lastDragPosition by remember { mutableFloatStateOf(1f) }
var showNote by remember { mutableStateOf(false) }
val bg = backgroundFor(progress.value)
val bgLight = lerpColor(bg, Color.White, 0.30f)
val accent = accentFor(progress.value)
val mood = progress.value.toMood()
fun goTo(target: Float) {
scope.launch {
progress.animateTo(target, tween(TRANSITION_MS, easing = FastOutSlowInEasing))
}
}
Box(
modifier = Modifier
.fillMaxSize()
.drawBehind {
drawRect(
brush = Brush.radialGradient(
colors = listOf(bgLight, bg),
center = Offset(size.width / 2f, size.height * 0.35f),
radius = size.width * 0.85f
)
)
},
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier
.statusBarsPadding()
.fillMaxSize()
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
IconButton(R.drawable.baseline_close_24) { showNote = false }
IconButton(R.drawable.outline_info_24)
}
AnimatedVisibility(
visible = !showNote,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically()
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Spacer(Modifier.height(48.dp))
Text(
text = "How was your shopping \nexperience?",
color = accent,
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
lineHeight = 28.sp,
textAlign = TextAlign.Center
)
}
}
Spacer(Modifier.height(24.dp))
SmileyFaceCanvas(
progress = progress.value,
modifier = Modifier
.fillMaxWidth(0.75f)
.aspectRatio(1f)
)
Spacer(Modifier.height(16.dp))
if (!showNote) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
AnimatedContent(
mood.label(),
modifier = Modifier.fillMaxWidth(),
transitionSpec = {
slideInHorizontally { -it } + fadeIn() togetherWith
slideOutHorizontally { -it } + fadeOut()
},
contentAlignment = Alignment.Center
) {
Text(
text = it,
color = accent.copy(alpha = 0.45f),
fontWeight = FontWeight.Black,
textAlign = TextAlign.Center,
fontSize = 52.sp,
letterSpacing = -5.sp
)
}
Spacer(Modifier.height(32.dp))
Slider(
value = progress.value,
onValueChange = { v ->
lastDragPosition = v
if (isSliderDragged) {
scope.launch { progress.snapTo(v) }
}
},
onValueChangeFinished = {
goTo(snapToMood(lastDragPosition))
},
interactionSource = interactionSource,
valueRange = 0f..1f,
colors = SliderDefaults.colors(
activeTrackColor = bg,
inactiveTrackColor = bg,
activeTickColor = Color.Transparent,
inactiveTickColor = Color.Transparent
),
thumb = { SliderThumb(progress = progress.value) },
track = { SliderTrack(it) },
modifier = Modifier
.fillMaxWidth()
.scale(scaleX = 1.08f, scaleY = 1f)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"Bad",
color = accent,
fontWeight = if (mood == Mood.SAD) FontWeight.Bold else FontWeight.Normal,
modifier = Modifier.clickable { goTo(0f) }
)
Text(
"Not Bad",
color = accent.copy(alpha = if (mood == Mood.MEDIUM) 1f else 0.6f),
fontWeight = if (mood == Mood.MEDIUM) FontWeight.Bold else FontWeight.Normal,
modifier = Modifier.clickable { goTo(0.5f) }
)
Text(
"Good",
color = accent.copy(alpha = if (mood == Mood.HAPPY) 1f else 0.6f),
fontWeight = if (mood == Mood.HAPPY) FontWeight.Bold else FontWeight.Normal,
modifier = Modifier.clickable { goTo(1f) }
)
}
}
}
AnimatedVisibility(
visible = showNote,
enter = fadeIn() + slideInVertically(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
),
exit = fadeOut() + slideOutVertically { it * 6 }
) {
NoteField(accent = accent)
}
Spacer(Modifier.weight(1f))
AnimatedVisibility(
visible = !showNote,
enter = fadeIn() + slideInVertically(
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
),
exit = fadeOut() + slideOutVertically { -it * 7 }
) {
SubmitFooter(accent = accent, onAddNote = { showNote = true })
}
Spacer(
Modifier
.height(40.dp)
.statusBarsPadding()
)
}
}
}
/**
* Expandable text input shown when the user taps "Add note".
* Auto-focuses on appearance. Border becomes visible when focused.
*
* @param accent Color used for the border, text, and submit button.
*/
@Composable
private fun NoteField(accent: Color) {
var note by remember { mutableStateOf("") }
var focused by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
Box(
modifier = Modifier
.fillMaxWidth()
.then(
if (focused) Modifier.border(2.dp, accent, RoundedCornerShape(24.dp))
else Modifier
)
.background(Color.Black.copy(alpha = 0.1f), RoundedCornerShape(24.dp))
.padding(20.dp)
) {
BasicTextField(
value = note,
onValueChange = { note = it },
modifier = Modifier
.fillMaxWidth()
.height(110.dp)
.align(Alignment.TopStart)
.focusRequester(focusRequester)
.onFocusChanged { focused = it.isFocused },
textStyle = MaterialTheme.typography.bodyMedium.copy(color = accent),
decorationBox = { inner ->
if (note.isEmpty()) {
Text(
"Add note",
style = MaterialTheme.typography.bodyMedium.copy(
color = accent.copy(alpha = 0.45f)
)
)
}
inner()
}
)
Row(
modifier = Modifier
.align(Alignment.BottomEnd)
.background(accent, RoundedCornerShape(100f))
.padding(horizontal = 14.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Submit",
style = MaterialTheme.typography.bodyMedium.copy(
color = Color.White,
fontWeight = FontWeight.SemiBold
)
)
Icon(
painterResource(R.drawable.outline_arrow_forward_24),
tint = Color.White,
modifier = Modifier.size(15.dp),
contentDescription = null
)
}
}
}
/**
* A simple icon button with a semi-transparent circular background.
*
* @param icon Drawable resource to display.
* @param onClick Called when the button is tapped.
*/
@Composable
private fun IconButton(@DrawableRes icon: Int, onClick: () -> Unit = {}) {
Box(
Modifier
.clickable { onClick() }
.background(Color.Black.copy(0.1f), CircleShape)
.padding(12.dp)
) {
Icon(
painterResource(icon),
contentDescription = null,
tint = Color.Black,
modifier = Modifier.size(24.dp)
)
}
}
/**
* Bottom bar with an "Add note" button and a submit action.
*
* @param accent Color applied to text and the submit pill.
* @param onAddNote Called when the user taps "Add note".
*/
@Composable
private fun SubmitFooter(accent: Color, onAddNote: () -> Unit) {
Row(
Modifier
.fillMaxWidth(0.9f)
.background(Color.Black.copy(0.1f), RoundedCornerShape(100f)),
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Add note",
style = MaterialTheme.typography.bodyMedium.copy(
color = accent,
fontWeight = FontWeight.SemiBold
),
modifier = Modifier
.padding(horizontal = 16.dp)
.weight(1f)
.clickable { onAddNote() }
)
Row(
Modifier
.background(accent, RoundedCornerShape(100f))
.padding(horizontal = 14.dp, vertical = 15.dp),
horizontalArrangement = Arrangement.spacedBy(5.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Submit",
style = MaterialTheme.typography.bodyMedium.copy(
color = Color.White,
fontWeight = FontWeight.SemiBold
)
)
Icon(
painterResource(R.drawable.outline_arrow_forward_24),
tint = Color.White,
modifier = Modifier.size(15.dp),
contentDescription = null
)
}
}
}
@Preview(showBackground = true, backgroundColor = 0xFFB2E060)
@Composable
private fun PreviewHappy() {
AnimatedReviewTheme {
SmileyFaceCanvas(progress = 1f, modifier = Modifier.size(280.dp))
}
}
@Preview(showBackground = true, backgroundColor = 0xFFF0CA43)
@Composable
private fun PreviewMedium() {
AnimatedReviewTheme {
SmileyFaceCanvas(progress = 0.5f, modifier = Modifier.size(280.dp))
}
}
@Preview(showBackground = true, backgroundColor = 0xFFE86F6F)
@Composable
private fun PreviewSad() {
AnimatedReviewTheme {
SmileyFaceCanvas(progress = 0f, modifier = Modifier.size(280.dp))
}
}
@Preview(showSystemUi = true)
@Composable
private fun PreviewReviewScreen() {
AnimatedReviewTheme {
ReviewScreen()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment