Last active
March 30, 2026 18:55
-
-
Save TuleSimon/f0ce818d962e01607782345a7da59961 to your computer and use it in GitHub Desktop.
Animated Check
This file contains hidden or 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.anonymous.animatedtoggle | |
| import android.graphics.BlurMaskFilter | |
| import android.os.Bundle | |
| import androidx.activity.ComponentActivity | |
| import androidx.activity.compose.setContent | |
| import androidx.activity.enableEdgeToEdge | |
| import androidx.compose.animation.animateColorAsState | |
| import androidx.compose.animation.core.Animatable | |
| import androidx.compose.animation.core.FastOutSlowInEasing | |
| import androidx.compose.animation.core.Spring | |
| import androidx.compose.animation.core.animateFloatAsState | |
| import androidx.compose.animation.core.spring | |
| import androidx.compose.animation.core.tween | |
| import androidx.compose.foundation.background | |
| import androidx.compose.foundation.gestures.detectTapGestures | |
| import androidx.compose.foundation.layout.Box | |
| import androidx.compose.foundation.layout.fillMaxSize | |
| import androidx.compose.foundation.layout.size | |
| import androidx.compose.runtime.Composable | |
| import androidx.compose.runtime.LaunchedEffect | |
| import androidx.compose.runtime.getValue | |
| import androidx.compose.runtime.mutableStateOf | |
| import androidx.compose.runtime.remember | |
| import androidx.compose.runtime.setValue | |
| import kotlinx.coroutines.delay | |
| import androidx.compose.ui.Alignment | |
| import androidx.compose.ui.Modifier | |
| import androidx.compose.ui.draw.drawBehind | |
| import androidx.compose.ui.geometry.CornerRadius | |
| import androidx.compose.ui.geometry.Offset | |
| import androidx.compose.ui.geometry.Rect | |
| import androidx.compose.ui.geometry.Size | |
| import androidx.compose.ui.graphics.Color | |
| import androidx.compose.ui.graphics.Path | |
| import androidx.compose.ui.graphics.asAndroidPath | |
| import androidx.compose.ui.graphics.drawscope.DrawScope | |
| import androidx.compose.ui.graphics.drawscope.drawIntoCanvas | |
| import androidx.compose.ui.graphics.nativeCanvas | |
| import androidx.compose.ui.input.pointer.pointerInput | |
| import androidx.compose.ui.tooling.preview.Preview | |
| import androidx.compose.ui.unit.dp | |
| import androidx.compose.ui.util.lerp | |
| import com.anonymous.animatedtoggle.ui.theme.AnimatedToggleTheme | |
| import kotlin.math.abs | |
| import kotlin.math.atan2 | |
| import kotlin.math.sqrt | |
| class MainActivity : ComponentActivity() { | |
| override fun onCreate(savedInstanceState: Bundle?) { | |
| super.onCreate(savedInstanceState) | |
| enableEdgeToEdge() | |
| setContent { | |
| AnimatedToggleTheme { | |
| var checked by remember { mutableStateOf(false) } | |
| val backgroundColor = animateColorAsState(if(checked) Color(0xFF1A2E4A) else Color(0xFFB8D4E8)) | |
| Box( | |
| contentAlignment = Alignment.Center, | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(backgroundColor.value. copy(alpha = 0.7f)) | |
| ) { | |
| Toggle(checked = checked, onCheckedChange = { checked = it }) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| fun Toggle( | |
| checked: Boolean, | |
| onCheckedChange: (Boolean) -> Unit, | |
| modifier: Modifier = Modifier | |
| ) { | |
| val leadProgress by animateFloatAsState( | |
| targetValue = if (checked) 1f else 0f, | |
| animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium), | |
| label = "" | |
| ) | |
| val trailProgress by animateFloatAsState( | |
| targetValue = if (checked) 1f else 0f, | |
| animationSpec = tween(durationMillis = 480, delayMillis = 160, easing = FastOutSlowInEasing), | |
| label = "" | |
| ) | |
| val trackColor by animateColorAsState( | |
| targetValue = if (checked) Color(0xFF1A2E4A) else Color(0xFFB8D4E8), | |
| animationSpec = tween(400), | |
| label = "" | |
| ) | |
| val squish = remember { Animatable(0f) } | |
| LaunchedEffect(checked) { | |
| delay(300) | |
| squish.snapTo(1f) | |
| squish.animateTo( | |
| targetValue = 0f, | |
| animationSpec = spring( | |
| dampingRatio = Spring.DampingRatioLowBouncy, | |
| stiffness = Spring.StiffnessMediumLow | |
| ) | |
| ) | |
| } | |
| Box( | |
| modifier = modifier | |
| .size(130.dp, 54.dp) | |
| .pointerInput(checked) { detectTapGestures { onCheckedChange(!checked) } } | |
| .drawBehind { | |
| val corner = size.height / 2f | |
| drawRoundRect(color = trackColor, cornerRadius = CornerRadius(corner)) | |
| val r = corner - 5.dp.toPx() | |
| val onCenter = size.width - corner | |
| val leadX = lerp(corner, onCenter, leadProgress) | |
| val trailX = lerp(corner, onCenter, trailProgress) | |
| drawThumb(leadX, trailX, size.height / 2f, r, Color(0xFFF0F8FF), squish.value) | |
| } | |
| ) | |
| } | |
| private fun DrawScope.drawThumb(leadX: Float, trailX: Float, cy: Float, r: Float, color: Color, squish: Float = 0f) { | |
| val dist = abs(leadX - trailX) | |
| if (dist < 0.5f) { | |
| val rx = r * (1f + squish * 0.4f) | |
| val ry = r * (1f - squish * 0.25f) | |
| drawOval(color, topLeft = Offset(leadX - rx, cy - ry), size = Size(rx * 2, ry * 2)) | |
| return | |
| } | |
| val stretch = (dist / (size.width - r * 4f)).coerceIn(0f, 1f) | |
| taperedPill( | |
| bigX = leadX, | |
| smallX = trailX, | |
| cy = cy, | |
| r1 = r, | |
| r2 = r * (1f - stretch * 0.95f), | |
| color = color, | |
| blurStretch = stretch | |
| ) | |
| } | |
| private fun DrawScope.taperedPill( | |
| bigX: Float, smallX: Float, cy: Float, r1: Float, r2: Float, color: Color, | |
| blurStretch: Float = 0f | |
| ) { | |
| val d = smallX - bigX | |
| val dist = abs(d) | |
| val sgnD = if (d >= 0f) 1f else -1f | |
| val sinA = ((r1 - r2) / dist).coerceIn(0f, 1f) | |
| val cosA = sqrt((1f - sinA * sinA).coerceAtLeast(0f)) | |
| val upperBig = Offset(bigX + sgnD * r1 * sinA, cy - r1 * cosA) | |
| val lowerBig = Offset(bigX + sgnD * r1 * sinA, cy + r1 * cosA) | |
| val upperSmall = Offset(smallX + sgnD * r2 * sinA, cy - r2 * cosA) | |
| val lowerSmall = Offset(smallX + sgnD * r2 * sinA, cy + r2 * cosA) | |
| fun angleOf(p: Offset, cx: Float) = | |
| (Math.toDegrees(atan2((p.y - cy).toDouble(), (p.x - cx).toDouble())) | |
| .toFloat() + 360f) % 360f | |
| val bigLowerAngle = angleOf(lowerBig, bigX) | |
| val bigUpperAngle = angleOf(upperBig, bigX) | |
| val smallUpperAngle = angleOf(upperSmall, smallX) | |
| val smallLowerAngle = angleOf(lowerSmall, smallX) | |
| val bigSweep = sgnD * ((sgnD * (bigUpperAngle - bigLowerAngle) + 360f) % 360f) | |
| val smallSweep = sgnD * ((sgnD * (smallLowerAngle - smallUpperAngle) + 360f) % 360f) | |
| val path = Path() | |
| path.moveTo(upperBig.x, upperBig.y) | |
| path.lineTo(upperSmall.x, upperSmall.y) | |
| path.arcTo(Rect(smallX - r2, cy - r2, smallX + r2, cy + r2), smallUpperAngle, smallSweep, false) | |
| path.lineTo(lowerBig.x, lowerBig.y) | |
| path.arcTo(Rect(bigX - r1, cy - r1, bigX + r1, cy + r1), bigLowerAngle, bigSweep, false) | |
| path.close() | |
| if (blurStretch > 0.01f) { | |
| drawIntoCanvas { canvas -> | |
| val nativePaint = android.graphics.Paint().apply { | |
| isAntiAlias = true | |
| maskFilter = BlurMaskFilter(blurStretch * 12.dp.toPx(), BlurMaskFilter.Blur.NORMAL) | |
| setColor( | |
| android.graphics.Color.argb( | |
| (255 * 0.4f * blurStretch).toInt(), | |
| (color.red * 255).toInt(), | |
| (color.green * 255).toInt(), | |
| (color.blue * 255).toInt() | |
| ) | |
| ) | |
| } | |
| canvas.nativeCanvas.drawPath(path.asAndroidPath(), nativePaint) | |
| } | |
| } | |
| drawPath(path, color) | |
| } | |
| @Preview(showBackground = true, backgroundColor = 0xFF7AB5D4) | |
| @Composable | |
| private fun Preview() { | |
| var checked by remember { mutableStateOf(false) } | |
| Toggle(checked = checked, onCheckedChange = { checked = it }) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment