Last active
May 2, 2026 03:40
-
-
Save Mikkareem/fff614fbac2ee0c5c7578637b8896f82 to your computer and use it in GitHub Desktop.
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
| @file:Suppress("FunctionName") | |
| package com.techullurgy.playground | |
| import androidx.compose.animation.core.animateFloat | |
| import androidx.compose.animation.core.spring | |
| import androidx.compose.animation.core.updateTransition | |
| import androidx.compose.animation.core.withInfiniteAnimationFrameMillis | |
| import androidx.compose.foundation.Canvas | |
| import androidx.compose.foundation.background | |
| import androidx.compose.foundation.layout.fillMaxSize | |
| import androidx.compose.runtime.Composable | |
| import androidx.compose.runtime.Stable | |
| import androidx.compose.runtime.getValue | |
| import androidx.compose.runtime.produceState | |
| import androidx.compose.ui.Modifier | |
| import androidx.compose.ui.geometry.Offset | |
| import androidx.compose.ui.geometry.Rect | |
| import androidx.compose.ui.geometry.lerp | |
| import androidx.compose.ui.graphics.Color | |
| import androidx.compose.ui.graphics.Path | |
| import androidx.compose.ui.graphics.drawscope.DrawScope | |
| import androidx.compose.ui.graphics.drawscope.Stroke | |
| import androidx.compose.ui.graphics.drawscope.rotate | |
| import androidx.compose.ui.graphics.drawscope.translate | |
| import androidx.compose.ui.text.TextMeasurer | |
| import androidx.compose.ui.text.TextStyle | |
| import androidx.compose.ui.text.drawText | |
| import androidx.compose.ui.text.font.FontWeight | |
| import androidx.compose.ui.text.rememberTextMeasurer | |
| import androidx.compose.ui.text.style.TextOverflow | |
| import androidx.compose.ui.tooling.preview.Preview | |
| import androidx.compose.ui.unit.Density | |
| import androidx.compose.ui.unit.TextUnit | |
| import androidx.compose.ui.unit.center | |
| import androidx.compose.ui.unit.dp | |
| import androidx.compose.ui.unit.sp | |
| import kotlin.math.PI | |
| import kotlin.math.cos | |
| import kotlin.math.sin | |
| import kotlin.math.sqrt | |
| import kotlin.time.Instant | |
| private val HourColor = Color(0xffff3f7f) | |
| private val MinuteColor = Color(0xffffb8de) | |
| private val SecondColor = Color(0xff08cb00) | |
| private val SkinColor = Color(0xff1e104e) | |
| private val SkinPinColor = Color(0xff8c00ff) | |
| private const val HourHandleFixedRotation = 180f | |
| private const val MinuteHandleFixedRotation = 138f | |
| private const val SecondHandleFixedRotation = 240f | |
| private const val HourHandleCloseXFraction = 0.36f | |
| private const val MinuteHandleCloseXFraction = 0.52f | |
| private const val SecondHandleCloseXFraction = 0.57f | |
| private const val HourHandleBump = 60f | |
| private const val MinuteHandleBump = 30f | |
| private const val BarFraction = 0.1f | |
| private const val ClockOutlineWidthDp = 20 | |
| private const val ClockPinRadiusDp = 30 | |
| private const val HoursShrinkedRectPaddingDp = 20 | |
| private const val SecondsShrinkedRectPaddingDp = 75 | |
| private const val MinutesShrinkedRectPaddingDp = 120 | |
| private const val SecondHandleStrokeWidthDp = 4 | |
| private const val BarStrokeWidthDp = 2 | |
| private val ClockRotationAnimation = spring<Float>() | |
| @Stable | |
| private data class ClockTime( | |
| val hours: Int, | |
| val minutes: Int, | |
| val seconds: Int | |
| ) { | |
| fun hourHandleDegree(): Float = (hours % 12) * 30f + minutes * 0.5f | |
| fun minuteHandleDegree(): Float = minutes * 6f + seconds * 0.1f | |
| fun secondHandleDegree(): Float = seconds * 6f | |
| override fun toString(): String = listOf( | |
| hours.toString().padStart(2, '0'), | |
| minutes.toString().padStart(2, '0'), | |
| seconds.toString().padStart(2, '0'), | |
| ).joinToString(":") | |
| } | |
| @Preview | |
| @Composable | |
| fun AnimatedFullScreenClock() { | |
| val textMeasurer = rememberTextMeasurer() | |
| val isInPreview = false //LocalInspectionMode.current | |
| val clockTime by produceState<ClockTime?>(null) { | |
| while (true) { | |
| withInfiniteAnimationFrameMillis { | |
| val currentTime = if(isInPreview) { | |
| Instant.parse("2023-04-27T19:24:47Z").toEpochMilliseconds() | |
| } else System.currentTimeMillis() | |
| val seconds = (currentTime / 1000) % 60 | |
| val minutes = (currentTime / (1000 * 60)) % 60 | |
| val hours = (currentTime / (1000 * 60 * 60)) % 24 | |
| value = ClockTime( | |
| hours = hours.toInt(), | |
| minutes = minutes.toInt(), | |
| seconds = seconds.toInt() | |
| ) | |
| } | |
| } | |
| } | |
| val transition = updateTransition(clockTime) | |
| val secondsRotation by transition.animateFloat( | |
| transitionSpec = { ClockRotationAnimation }, | |
| targetValueByState = { | |
| it?.secondHandleDegree() ?: 0f | |
| } | |
| ) | |
| val minutesRotation by transition.animateFloat( | |
| transitionSpec = { ClockRotationAnimation }, | |
| targetValueByState = { | |
| it?.minuteHandleDegree() ?: 0f | |
| } | |
| ) | |
| val hoursRotation by transition.animateFloat( | |
| transitionSpec = { ClockRotationAnimation }, | |
| targetValueByState = { | |
| it?.hourHandleDegree() ?: 0f | |
| } | |
| ) | |
| Canvas( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(Color(0xffff5580)) | |
| ) { | |
| val clockOvalRect = Rect( | |
| left = size.width * 0.15f, | |
| top = 0f, | |
| right = size.width + (size.width * 0.75f), | |
| bottom = size.height | |
| ) | |
| DrawClock( | |
| hoursRotation = hoursRotation, | |
| minutesRotation = minutesRotation, | |
| secondsRotation = secondsRotation, | |
| clockOvalRect = clockOvalRect, | |
| textMeasurer = textMeasurer | |
| ) | |
| drawText( | |
| text = "$clockTime", | |
| textMeasurer = textMeasurer, | |
| topLeft = Offset(30.dp.toPx(), 50.dp.toPx()), | |
| style = TextStyle(color = Color.Black, fontWeight = FontWeight.Bold, fontSize = 30.sp) | |
| ) | |
| } | |
| } | |
| private fun DrawScope.DrawClock( | |
| hoursRotation: Float, | |
| minutesRotation: Float, | |
| secondsRotation: Float, | |
| clockOvalRect: Rect, | |
| textMeasurer: TextMeasurer | |
| ) { | |
| val clockOutline = Path().apply { | |
| addOval(oval = clockOvalRect) | |
| } | |
| drawPath( | |
| path = clockOutline, | |
| color = SkinColor | |
| ) | |
| drawPath( | |
| path = clockOutline, | |
| color = SkinPinColor, | |
| style = Stroke(ClockOutlineWidthDp.dp.toPx()) | |
| ) | |
| DrawClockHourMinSec( | |
| hoursRotation = hoursRotation, | |
| minutesRotation = minutesRotation, | |
| secondsRotation = secondsRotation, | |
| clockOvalRect = clockOvalRect, | |
| textMeasurer = textMeasurer | |
| ) | |
| DrawClockHandles( | |
| clockOvalRect = clockOvalRect | |
| ) | |
| } | |
| private fun DrawScope.DrawClockHourMinSec( | |
| hoursRotation: Float, | |
| minutesRotation: Float, | |
| secondsRotation: Float, | |
| clockOvalRect: Rect, | |
| textMeasurer: TextMeasurer | |
| ) { | |
| val hoursShrinkedRect = clockOvalRect.shrink(HoursShrinkedRectPaddingDp.dp.toPx()) | |
| val secondsShrinkedRect = clockOvalRect.shrink(SecondsShrinkedRectPaddingDp.dp.toPx()) | |
| val minutesShrinkedRect = clockOvalRect.shrink(MinutesShrinkedRectPaddingDp.dp.toPx()) | |
| DrawClockHr( | |
| rotation = hoursRotation + HourHandleFixedRotation, | |
| color = HourColor, | |
| fontSize = 45.sp, | |
| textMeasurer = textMeasurer, | |
| ovalBounds = hoursShrinkedRect | |
| ) | |
| DrawClockMinSec( | |
| rotation = secondsRotation + SecondHandleFixedRotation, | |
| color = SecondColor, | |
| fontSize = 25.sp, | |
| textMeasurer = textMeasurer, | |
| ovalBounds = secondsShrinkedRect | |
| ) | |
| DrawClockMinSec( | |
| rotation = minutesRotation + MinuteHandleFixedRotation, | |
| color = MinuteColor, | |
| fontSize = 20.sp, | |
| textMeasurer = textMeasurer, | |
| ovalBounds = minutesShrinkedRect | |
| ) | |
| } | |
| private fun DrawScope.DrawClockHandles( | |
| clockOvalRect: Rect | |
| ) { | |
| // Hour Handle | |
| DrawCurveHandle( | |
| oval = clockOvalRect, | |
| color = HourColor, | |
| rotation = HourHandleFixedRotation, | |
| bump = HourHandleBump, | |
| closeXFraction = HourHandleCloseXFraction, | |
| ) | |
| // Second Handle | |
| DrawLineHandle( | |
| oval = clockOvalRect, | |
| color = SecondColor, | |
| rotation = SecondHandleFixedRotation, | |
| strokeWidth = SecondHandleStrokeWidthDp, | |
| closeXFraction = SecondHandleCloseXFraction, | |
| ) | |
| // Minute Handle | |
| DrawCurveHandle( | |
| oval = clockOvalRect, | |
| color = MinuteColor, | |
| rotation = MinuteHandleFixedRotation, | |
| bump = MinuteHandleBump, | |
| closeXFraction = MinuteHandleCloseXFraction, | |
| ) | |
| drawCircle( | |
| color = SkinPinColor, | |
| radius = ClockPinRadiusDp.dp.toPx(), | |
| center = clockOvalRect.center | |
| ) | |
| } | |
| private fun DrawScope.DrawClockMinSec( | |
| rotation: Float, | |
| color: Color, | |
| ovalBounds: Rect, | |
| fontSize: TextUnit, | |
| textMeasurer: TextMeasurer, | |
| barFraction: Float = BarFraction | |
| ) { | |
| repeat(60) { | |
| val degree = it.toClockDegree() + rotation | |
| if(it % 5 == 0) { | |
| val minuteText = "$it".padStart(2, '0') | |
| val point = calculatePointOnOvalAtDegree(ovalBounds, degree) | |
| rotate( | |
| degrees = (degree + 90f), | |
| pivot = point | |
| ) { | |
| val textLayoutResult = textMeasurer.measure( | |
| text = minuteText, | |
| maxLines = 1, | |
| overflow = TextOverflow.Visible, | |
| style = TextStyle( | |
| fontSize = fontSize, | |
| fontWeight = FontWeight.Bold | |
| ) | |
| ) | |
| drawText( | |
| textLayoutResult = textLayoutResult, | |
| color = color, | |
| topLeft = point.copy(x = point.x - textLayoutResult.size.center.x) | |
| ) | |
| } | |
| } else { | |
| val point = calculatePointOnOvalAtDegree(ovalBounds, degree) | |
| val lineEnd = lerp(point, ovalBounds.center, barFraction) | |
| drawLine( | |
| color = color, | |
| start = point, | |
| end = lineEnd, | |
| strokeWidth = BarStrokeWidthDp.dp.toPx() | |
| ) | |
| } | |
| } | |
| } | |
| private fun DrawScope.DrawClockHr( | |
| rotation: Float, | |
| color: Color, | |
| ovalBounds: Rect, | |
| fontSize: TextUnit, | |
| textMeasurer: TextMeasurer | |
| ) { | |
| (1..60).forEach { | |
| val degree = it.toClockDegree() + rotation | |
| if(it % 5 == 0) { | |
| val minuteText = "${it / 5}" | |
| val point = calculatePointOnOvalAtDegree(ovalBounds, degree) | |
| rotate( | |
| degrees = (degree + 90f), | |
| pivot = point | |
| ) { | |
| val textLayoutResult = textMeasurer.measure( | |
| text = minuteText, | |
| maxLines = 1, | |
| overflow = TextOverflow.Visible, | |
| style = TextStyle( | |
| fontSize = fontSize, | |
| fontWeight = FontWeight.Bold | |
| ) | |
| ) | |
| drawText( | |
| textLayoutResult = textLayoutResult, | |
| color = color, | |
| topLeft = point.copy(x = point.x - textLayoutResult.size.center.x) | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| private fun DrawScope.DrawCurveHandle( | |
| oval: Rect, | |
| color: Color, | |
| rotation: Float, | |
| bump: Float, | |
| closeXFraction: Float | |
| ) { | |
| val handPath = provideHandPath( | |
| bump = bump, | |
| startAt = Offset(0f, 0f), | |
| closeAt = Offset(oval.width * closeXFraction, 0f) | |
| ) | |
| translate( | |
| left = oval.center.x, | |
| top = oval.center.y | |
| ) { | |
| rotate( | |
| rotation, | |
| pivot = Offset.Zero | |
| ) { | |
| drawPath( | |
| path = handPath, | |
| color = color, | |
| ) | |
| } | |
| } | |
| } | |
| private fun DrawScope.DrawLineHandle( | |
| oval: Rect, | |
| color: Color, | |
| rotation: Float, | |
| strokeWidth: Int, | |
| closeXFraction: Float | |
| ) { | |
| translate( | |
| left = oval.center.x, | |
| top = oval.center.y | |
| ) { | |
| rotate( | |
| rotation, | |
| pivot = Offset.Zero | |
| ) { | |
| drawLine( | |
| start = Offset.Zero, | |
| end = Offset(oval.width * closeXFraction, 0f), | |
| color = color, | |
| strokeWidth = strokeWidth.dp.toPx() | |
| ) | |
| } | |
| } | |
| } | |
| private fun Density.provideHandPath( | |
| bump: Float, | |
| startAt: Offset, | |
| closeAt: Offset, | |
| bumpDistance: Float = 70.dp.toPx() | |
| ): Path { | |
| return Path().apply { | |
| moveTo(startAt.x, startAt.y) | |
| quadraticTo(startAt.x + bumpDistance, startAt.y, startAt.x + bumpDistance, startAt.y - bump) | |
| lineTo(closeAt.x, closeAt.y) | |
| moveTo(startAt.x, startAt.y) | |
| quadraticTo(startAt.x + bumpDistance, startAt.y, startAt.x + bumpDistance, startAt.y + bump) | |
| lineTo(closeAt.x, closeAt.y) | |
| } | |
| } | |
| private fun calculatePointOnOvalAtDegree( | |
| oval: Rect, | |
| degree: Float | |
| ): Offset { | |
| val a = oval.width / 2 | |
| val b = oval.height / 2 | |
| val cosTheta = cos(degree.toRadians()) | |
| val sinTheta = sin(degree.toRadians()) | |
| val ab = a * b | |
| val aSquared = a.let { it * it } | |
| val bSquared = b.let { it * it } | |
| val cosSquared = cosTheta.let { it * it } | |
| val sinSquared = sinTheta.let { it * it } | |
| val denominator = sqrt((bSquared * cosSquared) + (aSquared * sinSquared)) | |
| val x = (ab * cosTheta) / denominator | |
| val y = (ab * sinTheta) / denominator | |
| return Offset(x.toFloat() + oval.center.x, y.toFloat() + oval.center.y) | |
| } | |
| private fun Rect.shrink(padding: Float): Rect { | |
| return Rect( | |
| left = left + padding, | |
| top = top + padding, | |
| right = right - padding, | |
| bottom = bottom - padding | |
| ) | |
| } | |
| private fun Int.toClockDegree() = -(this * 6f) | |
| private fun Float.toRadians() = this * (PI / 180f) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment