Created
November 4, 2025 16:12
-
-
Save Kyriakos-Georgiopoulos/7a23fefe420fa47d00b4728ba0eb1390 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
| /* | |
| * Copyright 2025 Kyriakos Georgiopoulos | |
| * | |
| * Licensed under the Apache License, Version 2.0 (the "License"); | |
| * you may not use this file except in compliance with the License. | |
| * You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, software | |
| * distributed under the License is distributed on an "AS IS" BASIS, | |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| * See the License for the specific language governing permissions and | |
| * limitations under the License. | |
| */ | |
| @file:Suppress("MagicNumber") | |
| import android.graphics.BlurMaskFilter | |
| import androidx.compose.animation.core.Animatable | |
| import androidx.compose.animation.core.FastOutSlowInEasing | |
| import androidx.compose.animation.core.animateFloatAsState | |
| import androidx.compose.animation.core.tween | |
| import androidx.compose.foundation.Canvas | |
| import androidx.compose.foundation.background | |
| import androidx.compose.foundation.gestures.detectDragGestures | |
| import androidx.compose.foundation.layout.Arrangement | |
| import androidx.compose.foundation.layout.Box | |
| import androidx.compose.foundation.layout.BoxWithConstraints | |
| 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.MaterialTheme | |
| import androidx.compose.material3.Surface | |
| import androidx.compose.material3.Text | |
| 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.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.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.Paint | |
| import androidx.compose.ui.graphics.Path | |
| import androidx.compose.ui.graphics.PathFillType | |
| import androidx.compose.ui.graphics.Shadow | |
| import androidx.compose.ui.graphics.StrokeCap | |
| import androidx.compose.ui.graphics.drawscope.DrawScope | |
| import androidx.compose.ui.graphics.drawscope.Stroke | |
| import androidx.compose.ui.graphics.drawscope.drawIntoCanvas | |
| import androidx.compose.ui.graphics.drawscope.inset | |
| import androidx.compose.ui.graphics.drawscope.withTransform | |
| import androidx.compose.ui.graphics.nativeCanvas | |
| import androidx.compose.ui.graphics.toArgb | |
| import androidx.compose.ui.input.pointer.pointerInput | |
| import androidx.compose.ui.layout.onSizeChanged | |
| 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.IntOffset | |
| import androidx.compose.ui.unit.dp | |
| import androidx.compose.ui.unit.max | |
| import androidx.compose.ui.unit.sp | |
| import com.zengrip.countdown.toPx | |
| import kotlinx.coroutines.launch | |
| import kotlin.math.abs | |
| import kotlin.math.max | |
| import kotlin.math.min | |
| import kotlin.math.pow | |
| import kotlin.math.roundToInt | |
| /** | |
| * Demo screen hosting the mood title, animated face, and custom slider. | |
| * | |
| * @param modifier parent modifier | |
| */ | |
| @Composable | |
| fun MoodFaceDemo(modifier: Modifier = Modifier) { | |
| var value by remember { mutableStateOf(0.5f) } | |
| val bg = lerp3( | |
| Color(0xFFFF6A00), | |
| Color(0xFFFFB300), | |
| Color(0xFF79D36A), | |
| value | |
| ) | |
| Surface(color = bg, modifier = modifier.fillMaxSize()) { | |
| Column(modifier = Modifier.fillMaxSize()) { | |
| Spacer(Modifier.height(8.dp)) | |
| MoodTitle(value = value) | |
| Box( | |
| modifier = Modifier | |
| .weight(1f) | |
| .fillMaxWidth(), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Face(value = value) | |
| } | |
| MoodSlider( | |
| value = value, | |
| onValueChange = { value = it }, | |
| states = listOf( | |
| MoodTick(0f, "sad"), | |
| MoodTick(0.5f, "confuse"), | |
| MoodTick(1f, "happy"), | |
| ), | |
| trackHeight = 110.dp, | |
| knobRadius = 28.dp, | |
| backgroundColor = bg, | |
| modifier = Modifier.fillMaxWidth() | |
| ) | |
| } | |
| } | |
| } | |
| /** | |
| * Displays the current mood label above the face. | |
| * | |
| * @param value slider value in [0f, 1f] | |
| */ | |
| @Composable | |
| private fun MoodTitle(value: Float) { | |
| val label = when (nearestMood(value)) { | |
| Mood.Sad -> "sad" | |
| Mood.Confused -> "confuse" | |
| Mood.Happy -> "happy" | |
| } | |
| val titleShadow = Shadow( | |
| color = Color(0xFFFF5330).copy(alpha = 0.55f), | |
| offset = Offset(3f, 5f), | |
| blurRadius = 18f | |
| ) | |
| Text( | |
| text = label, | |
| color = Color.White.copy(alpha = 0.70f), | |
| fontSize = 62.sp, | |
| fontWeight = FontWeight.ExtraBold, | |
| textAlign = TextAlign.Center, | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .padding(top = 40.dp), | |
| style = MaterialTheme.typography.displaySmall.copy(shadow = titleShadow), | |
| ) | |
| } | |
| /** | |
| * Animated face that blends expressions smoothly based on [value]. | |
| * | |
| * @param value slider value in [0f, 1f] | |
| */ | |
| @Composable | |
| private fun Face(value: Float) { | |
| val vSmooth = rememberSmoothValue(value, durationMs = 110) | |
| val vEase = easeInOutCubic(vSmooth) | |
| val (sadMaskRaw, confMaskRaw, happyMaskRaw) = | |
| softEmotionMasks(vEase).let { Triple(it.a, it.b, it.c) } | |
| val sadness by animateFloatAsState( | |
| targetValue = sadMaskRaw, | |
| animationSpec = tween(110), label = "sadness" | |
| ) | |
| val confused by animateFloatAsState( | |
| targetValue = confMaskRaw, | |
| animationSpec = tween(110), label = "confused" | |
| ) | |
| val happiness by animateFloatAsState( | |
| targetValue = happyMaskRaw, | |
| animationSpec = tween(110), label = "happiness" | |
| ) | |
| val eyesBounce by animateFloatAsState( | |
| targetValue = (sadness * 0.06f) + (confused * 0.00f) + (happiness * -0.04f), | |
| animationSpec = tween(110), label = "eyesBounce" | |
| ) | |
| val pupilBaseX by animateFloatAsState( | |
| targetValue = (vEase - 0.5f) * 0.35f, | |
| animationSpec = tween(110), label = "pupilXBase" | |
| ) | |
| val pupilScale by animateFloatAsState( | |
| targetValue = 0.85f + (confused * 0.10f) + (happiness * 0.20f), | |
| animationSpec = tween(110), label = "pupilScale" | |
| ) | |
| val mouthOpenTarget = (sadness * 0.85f) + (confused * 0.88f) + (happiness * 0.06f) | |
| val mouthOpen by animateFloatAsState( | |
| targetValue = mouthOpenTarget, | |
| animationSpec = tween(120), label = "mouthOpen" | |
| ) | |
| val showTeeth by animateFloatAsState( | |
| targetValue = sadness, | |
| animationSpec = tween(110), label = "teeth" | |
| ) | |
| Column( | |
| horizontalAlignment = Alignment.CenterHorizontally, | |
| verticalArrangement = Arrangement.Center, | |
| modifier = Modifier.fillMaxWidth() | |
| ) { | |
| EyesRow( | |
| eyesOffsetY = eyesBounce, | |
| pupilOffsetX = pupilBaseX, | |
| pupilScale = pupilScale, | |
| sadness = sadness, | |
| happiness = happiness | |
| ) | |
| Spacer(Modifier.height(14.dp)) | |
| Mouth(open = mouthOpen, teeth = showTeeth) | |
| } | |
| } | |
| /** | |
| * Returns a short-lag smoothed value following [target]. | |
| * | |
| * @param target target value | |
| * @param durationMs tween duration in milliseconds | |
| */ | |
| @Composable | |
| private fun rememberSmoothValue(target: Float, durationMs: Int = 110): Float { | |
| val anim = remember { Animatable(target) } | |
| LaunchedEffect(target) { | |
| anim.animateTo(target, tween(durationMs, easing = FastOutSlowInEasing)) | |
| } | |
| return anim.value | |
| } | |
| /** | |
| * Hermite smoothstep in range [edge0, edge1]. | |
| */ | |
| private fun smoothstep(edge0: Float, edge1: Float, x: Float): Float { | |
| if (edge0 == edge1) return if (x >= edge1) 1f else 0f | |
| val t = ((x - edge0) / (edge1 - edge0)).coerceIn(0f, 1f) | |
| return t * t * (3f - 2f * t) | |
| } | |
| /** | |
| * Triple weights for blending three targets. | |
| */ | |
| private data class TripleBlend(val a: Float, val b: Float, val c: Float) { | |
| fun normalized(): TripleBlend { | |
| val s = (a + b + c).coerceAtLeast(1e-4f) | |
| return TripleBlend(a / s, b / s, c / s) | |
| } | |
| } | |
| /** | |
| * Soft masks for Sad, Confused, Happy that overlap and sum ~ 1. | |
| * | |
| * @param v slider value in [0f, 1f] | |
| */ | |
| private fun softEmotionMasks(v: Float): TripleBlend { | |
| val sad = 1f - smoothstep(0.18f, 0.38f, v) | |
| val happy = smoothstep(0.62f, 0.82f, v) | |
| val midRaw = 1f - max(sad, happy) | |
| val conf = smoothstep(0f, 1f, midRaw) | |
| return TripleBlend(sad, conf, happy).normalized() | |
| } | |
| /** | |
| * Symmetric ease-in-out cubic. | |
| */ | |
| private fun easeInOutCubic(t: Float): Float = | |
| if (t < 0.5f) 4f * t * t * t else 1f - (-2f * t + 2f).pow(3f) / 2f | |
| /** | |
| * Row hosting both eyes. | |
| */ | |
| @Composable | |
| private fun EyesRow( | |
| eyesOffsetY: Float, | |
| pupilOffsetX: Float, | |
| pupilScale: Float, | |
| sadness: Float, | |
| happiness: Float | |
| ) { | |
| Row( | |
| verticalAlignment = Alignment.CenterVertically, | |
| horizontalArrangement = Arrangement.spacedBy(28.dp), | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .padding(horizontal = 40.dp) | |
| ) { | |
| Eye( | |
| pupilOffsetX = pupilOffsetX, | |
| pupilScale = pupilScale, | |
| eyesOffsetY = eyesOffsetY, | |
| sadness = sadness, | |
| happiness = happiness, | |
| modifier = Modifier.weight(1f) | |
| ) | |
| Eye( | |
| pupilOffsetX = pupilOffsetX, | |
| pupilScale = pupilScale, | |
| eyesOffsetY = eyesOffsetY, | |
| sadness = sadness, | |
| happiness = happiness, | |
| mirror = true, | |
| modifier = Modifier.weight(1f) | |
| ) | |
| } | |
| } | |
| /** | |
| * One eye with per-state shadow, pupil and highlight, plus eyebrow. | |
| * | |
| * @param pupilOffsetX normalized pupil horizontal offset baseline | |
| * @param pupilScale scale factor for pupil size | |
| * @param eyesOffsetY vertical bobbing offset | |
| * @param sadness weight for sad expression in [0f, 1f] | |
| * @param happiness weight for happy expression in [0f, 1f] | |
| * @param mirror mirrors horizontal offsets for right eye | |
| * @param modifier modifier | |
| */ | |
| @Composable | |
| private fun Eye( | |
| pupilOffsetX: Float, | |
| pupilScale: Float, | |
| eyesOffsetY: Float, | |
| sadness: Float, | |
| happiness: Float, | |
| mirror: Boolean = false, | |
| modifier: Modifier = Modifier | |
| ) { | |
| val eyeSize = 110.dp | |
| val pupilBase = 28.dp | |
| val mainHighlight = 12.dp | |
| val baseX = if (mirror) -pupilOffsetX else pupilOffsetX | |
| val eyeScaleY = 1f - 0.05f * happiness | |
| val pushX = 0.18f * happiness | |
| val pushY = 0.18f * happiness | |
| val pupilScaleBoost = 1f + 0.30f * happiness | |
| val baseXDamped = baseX * (1f - 0.65f * happiness) | |
| val confusedWarm = Color(0xFFFF4A24) | |
| val darkEdge = Color(0xFF111111) | |
| val edge = max(sadness, happiness) | |
| val baseShadowColor = lerpColor(confusedWarm, darkEdge, edge) | |
| val baseShadowAlpha = 0.75f | |
| val baseOffXK = 0.28f | |
| val baseOffYK = 0.38f | |
| val baseBlurK = 0.38f | |
| val baseRadiusK = 0.98f | |
| val yellow = Color(0xFFFFE14A) | |
| val useHappyYellow = happiness > 0.15f | |
| val yellowAlpha = (0.16f + 0.14f * happiness).coerceAtMost(0.30f) | |
| val sadToOther = 1f - clamp01(sadness) | |
| val hlx = lerp(0.dp, (-6).dp, sadToOther) | |
| val hly = lerp(0.dp, (-6).dp, sadToOther) | |
| Box( | |
| contentAlignment = Alignment.Center, | |
| modifier = modifier | |
| .size(eyeSize) | |
| .offset { IntOffset(0, (eyesOffsetY * eyeSize.toPx()).roundToInt()) } | |
| ) { | |
| Canvas(Modifier.matchParentSize()) { | |
| val r = size.minDimension / 2f | |
| val c = Offset(size.width / 2f, size.height / 2f) | |
| if (useHappyYellow) { | |
| val center = c + Offset(0f, r * 0.12f) | |
| drawSafeBlurredCircle( | |
| color = yellow.copy(alpha = yellowAlpha), | |
| center = center, | |
| radius = r * 0.95f, | |
| blur = r * 1.05f | |
| ) | |
| } else { | |
| val shadowOffset = Offset(r * baseOffXK, r * baseOffYK) | |
| val shadowBlur = (r * baseBlurK).coerceAtLeast(0.5f) | |
| val shadowRadius = (r * baseRadiusK).coerceAtLeast(0.5f) | |
| drawSafeBlurredCircle( | |
| color = baseShadowColor.copy(alpha = baseShadowAlpha), | |
| center = c + shadowOffset, | |
| radius = shadowRadius, | |
| blur = shadowBlur | |
| ) | |
| } | |
| withTransform({ scale(scaleX = 1f, scaleY = eyeScaleY, pivot = c) }) { | |
| drawCircle(Color.White, r, c) | |
| } | |
| drawEyebrow( | |
| center = c, | |
| r = r, | |
| sadness = sadness, | |
| happiness = happiness, | |
| mirror = mirror | |
| ) | |
| } | |
| val room = (eyeSize - pupilBase).toPx() * 0.35f | |
| val px = ((baseXDamped + pushX) * room).roundToInt() | |
| val py = (pushY * room).roundToInt() | |
| Box( | |
| contentAlignment = Alignment.Center, | |
| modifier = Modifier | |
| .size(pupilBase * pupilScale * pupilScaleBoost) | |
| .offset { IntOffset(px, py) } | |
| .clip(CircleShape) | |
| .background(Color.Black) | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .size(mainHighlight) | |
| .offset(x = hlx, y = hly) | |
| .clip(CircleShape) | |
| .background(Color.White) | |
| ) | |
| if (happiness > 0.25f) { | |
| Box( | |
| modifier = Modifier | |
| .size(5.dp) | |
| .offset(x = 6.dp, y = 6.dp) | |
| .clip(CircleShape) | |
| .background(Color.White.copy(alpha = 0.95f)) | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Draws a single eyebrow for an eye. | |
| * | |
| * @param center eye center in local canvas coordinates | |
| * @param r eye radius | |
| * @param sadness sad weight [0f, 1f] | |
| * @param happiness happy weight [0f, 1f] | |
| * @param mirror true if drawing the right eye (affects tilt) | |
| */ | |
| private fun DrawScope.drawEyebrow( | |
| center: Offset, | |
| r: Float, | |
| sadness: Float, | |
| happiness: Float, | |
| mirror: Boolean | |
| ) { | |
| val confused = (1f - max(sadness, happiness)).coerceIn(0f, 1f) | |
| val baseY = center.y - r * 1.18f | |
| val leftX = center.x - r * 0.68f | |
| val rightX = center.x + r * 0.68f | |
| val arcUp = r * (0.24f * happiness - 0.16f * sadness + 0.10f * confused) | |
| val isRightEye = mirror | |
| val innerUp = r * (0.12f * confused + 0.08f * sadness) | |
| val outerDown = r * (0.05f * confused + 0.03f * sadness) | |
| val (yL, yR) = if (isRightEye) { | |
| baseY - innerUp to baseY + outerDown | |
| } else { | |
| baseY + outerDown to baseY - innerUp | |
| } | |
| val thickness = (r * (0.20f + 0.03f * sadness - 0.02f * happiness)).coerceAtLeast(4f) | |
| val color = Color(0xFF222222).copy(alpha = 0.92f) | |
| val brow = Path().apply { | |
| moveTo(leftX, yL) | |
| quadraticBezierTo(center.x, baseY - arcUp, rightX, yR) | |
| } | |
| drawPath(path = brow, color = color, style = Stroke(width = thickness, cap = StrokeCap.Round)) | |
| } | |
| /** | |
| * Mouth that blends between sad, confused and happy variants. | |
| * | |
| * @param open openness factor in [0f, 1f] | |
| * @param teeth visibility factor for upper teeth in [0f, 1f] | |
| */ | |
| @Composable | |
| private fun Mouth(open: Float, teeth: Float) { | |
| val open01 = clamp01(open) | |
| val sadness = clamp01(teeth) | |
| val happyLevel = clamp01((0.60f - open01) / 0.44f) | |
| val baseWidth = lerp(66.dp, 112.dp, open01) | |
| val baseHeight = lerp(18.dp, 62.dp, open01) | |
| val sadWidthBoost = lerp(0.dp, 22.dp, sadness) | |
| val sadTargetHeight = 24.dp | |
| val widthSad = baseWidth + sadWidthBoost | |
| val heightSad = lerp(baseHeight, sadTargetHeight, sadness) | |
| val width = lerp(widthSad, widthSad * 1.12f, happyLevel) | |
| val height = lerp(heightSad, max(heightSad, 38.dp), happyLevel) | |
| val lip = Color(0xFFFFE7E1) | |
| val inner = Color(0xFF121212) | |
| val glow = Color(0xFFFF3D1F) | |
| Box(contentAlignment = Alignment.Center) { | |
| Canvas(Modifier.size(width + 28.dp, height + 28.dp)) { | |
| val w = size.width | |
| val h = size.height | |
| val rr = min(w, h) / 2f | |
| val glowRect = Rect(0f, h * 0.25f, w, h * 1.10f) | |
| drawSafeBlurredRoundRect( | |
| color = glow.copy(alpha = 0.90f), | |
| rect = glowRect, rx = rr, ry = rr, blur = 38f | |
| ) | |
| drawRoundRect( | |
| color = lip, | |
| size = Size(w, h), | |
| cornerRadius = CornerRadius(rr, rr) | |
| ) | |
| val maxPad = (min(w, h) / 2f) - 0.5f | |
| val lipPadPx = (6f + 10f * happyLevel).coerceAtMost(maxPad) | |
| if (lipPadPx > 0f) { | |
| val innerW = (w - lipPadPx * 2f).coerceAtLeast(0f) | |
| val innerH = (h - lipPadPx * 2f).coerceAtLeast(0f) | |
| val rrInner = min(innerW, innerH) / 2f | |
| inset(lipPadPx, lipPadPx) { | |
| drawRoundRect( | |
| color = Color.Transparent, | |
| size = Size(innerW, innerH), | |
| cornerRadius = CornerRadius(rrInner, rrInner), | |
| ) | |
| } | |
| } | |
| } | |
| Box( | |
| modifier = Modifier | |
| .width(width) | |
| .height(height) | |
| .clip(RoundedCornerShape(percent = 50)) | |
| ) { | |
| if (happyLevel < 0.6f) { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(inner) | |
| ) { | |
| if (teeth > 0.01f) { | |
| TeethSad( | |
| alpha = teeth, | |
| widen = lerp(0f, 0.12f, sadness), | |
| availableHeight = height | |
| ) | |
| } | |
| } | |
| } else { | |
| val cavW = width * lerp(0.84f, 0.95f, happyLevel) | |
| val cavH = height * lerp(0.50f, 0.62f, happyLevel) | |
| Box( | |
| modifier = Modifier | |
| .align(Alignment.Center) | |
| .size(cavW, cavH) | |
| .clip(RoundedCornerShape(percent = 50)) | |
| .background(inner) | |
| ) | |
| val tongueW = cavW * lerp(0.46f, 0.60f, happyLevel) | |
| val tongueH = cavH * lerp(0.24f, 0.34f, happyLevel) | |
| val tongueTop = Color(0xFFFF8FA3) | |
| val tongueBot = Color(0xFFEF6F85) | |
| Box( | |
| modifier = Modifier | |
| .align(Alignment.BottomCenter) | |
| .padding(bottom = 6.dp) | |
| .size(tongueW, tongueH) | |
| .clip( | |
| RoundedCornerShape( | |
| topStart = 12.dp, topEnd = 12.dp, | |
| bottomStart = 14.dp, bottomEnd = 14.dp | |
| ) | |
| ) | |
| .background(tongueTop.copy(alpha = 0.95f)) | |
| ) | |
| Box( | |
| modifier = Modifier | |
| .align(Alignment.BottomCenter) | |
| .padding(bottom = 4.dp) | |
| .size(tongueW * 0.90f, (tongueH * 0.28f).coerceAtLeast(2.dp)) | |
| .clip(RoundedCornerShape(percent = 50)) | |
| .background(tongueBot.copy(alpha = 0.65f)) | |
| .offset(y = (-2).dp) | |
| ) | |
| Canvas(Modifier.matchParentSize()) { | |
| val w = size.width | |
| val h = size.height | |
| val yBase = h * lerp(0.60f, 0.50f, happyLevel) | |
| val curveUp = h * lerp(0.22f, 0.34f, happyLevel) | |
| val strokeW = (h * lerp(0.14f, 0.12f, happyLevel)).coerceAtLeast(2f) | |
| val marginX = w * 0.12f | |
| val p = Path().apply { | |
| moveTo(marginX, yBase) | |
| quadraticBezierTo(w * 0.50f, yBase - curveUp, w - marginX, yBase) | |
| } | |
| drawPath( | |
| path = p, | |
| color = inner, | |
| style = Stroke(width = strokeW, cap = StrokeCap.Round) | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Upper teeth strip shown in sad/confused states. | |
| * | |
| * @param alpha alpha applied to teeth color | |
| * @param widen widening factor | |
| * @param availableHeight available mouth height | |
| */ | |
| @Composable | |
| private fun TeethSad( | |
| alpha: Float, | |
| widen: Float, | |
| availableHeight: Dp | |
| ) { | |
| val toothColor = Color(0xFFFAFAFA) | |
| val vPad = 2.dp | |
| val rowHeight = ((availableHeight - vPad * 2) / 2) | |
| .coerceAtLeast(6.dp) | |
| .coerceAtMost(10.dp) | |
| val extraWiden = 0.06f | |
| val widenFactor = 1f + widen + extraWiden | |
| val topBase = listOf(11.dp, 13.dp, 13.dp, 11.dp) | |
| val topWidths = topBase.map { it * widenFactor } | |
| val centerGap = 6.dp * (1f + widen / 2f) | |
| val hPad = 14.dp | |
| BoxWithConstraints( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .padding(horizontal = hPad, vertical = vPad) | |
| ) { | |
| val boxH = this.maxHeight | |
| val upperY = (boxH * 0.42f) - (rowHeight / 2) | |
| TeethStrip( | |
| widths = topWidths, | |
| toothHeight = rowHeight, | |
| centerGap = centerGap, | |
| color = toothColor, | |
| alpha = alpha, | |
| modifier = Modifier | |
| .align(Alignment.TopCenter) | |
| .offset(y = upperY) | |
| ) | |
| } | |
| } | |
| /** | |
| * Renders a horizontal strip of rectangular teeth with a center gap. | |
| * | |
| * @param widths list of tooth widths | |
| * @param toothHeight common height | |
| * @param centerGap gap between the two middle teeth | |
| * @param color base color | |
| * @param alpha alpha applied to [color] | |
| * @param modifier modifier | |
| */ | |
| @Composable | |
| private fun TeethStrip( | |
| widths: List<Dp>, | |
| toothHeight: Dp, | |
| centerGap: Dp, | |
| color: Color, | |
| alpha: Float, | |
| modifier: Modifier = Modifier | |
| ) { | |
| Row( | |
| modifier = modifier, | |
| verticalAlignment = Alignment.CenterVertically, | |
| horizontalArrangement = Arrangement.Center | |
| ) { | |
| widths.forEachIndexed { i, w -> | |
| if (i == 2) Spacer(Modifier.width(centerGap)) | |
| Box( | |
| modifier = Modifier | |
| .width(w) | |
| .height(toothHeight) | |
| .clip(RoundedCornerShape(2.dp)) | |
| .background(color.copy(alpha = alpha)) | |
| ) | |
| if (i < widths.lastIndex && i != 1) Spacer(Modifier.width(6.dp)) | |
| } | |
| } | |
| } | |
| /** | |
| * Custom mood slider with a circular cap and tangent-matched valleys. | |
| * | |
| * @param value current value in [0f, 1f] | |
| * @param onValueChange callback when value snaps or drags | |
| * @param states snap ticks with labels | |
| * @param trackHeight track height | |
| * @param knobRadius knob radius | |
| * @param backgroundColor background color above the panel | |
| * @param modifier modifier | |
| * @param ringThickness thickness of the white circular cap | |
| */ | |
| @Composable | |
| private fun MoodSlider( | |
| value: Float, | |
| onValueChange: (Float) -> Unit, | |
| states: List<MoodTick>, | |
| trackHeight: Dp, | |
| knobRadius: Dp, | |
| backgroundColor: Color, | |
| modifier: Modifier = Modifier, | |
| ringThickness: Dp = 16.dp, | |
| ) { | |
| var widthPx by remember { mutableStateOf(0f) } | |
| val scope = rememberCoroutineScope() | |
| val anim = remember { Animatable(value.coerceIn(0f, 1f)) } | |
| var dragging by remember { mutableStateOf(false) } | |
| LaunchedEffect(value) { if (!dragging) anim.snapTo(value.coerceIn(0f, 1f)) } | |
| Box( | |
| contentAlignment = Alignment.BottomCenter, | |
| modifier = modifier | |
| .fillMaxWidth() | |
| .height(trackHeight) | |
| .onSizeChanged { widthPx = it.width.toFloat() } | |
| .pointerInput(widthPx) { | |
| detectDragGestures( | |
| onDragStart = { o -> | |
| dragging = true | |
| scope.launch { anim.snapTo((o.x / widthPx).coerceIn(0f, 1f)) } | |
| }, | |
| onDrag = { c, _ -> | |
| scope.launch { anim.snapTo((c.position.x / widthPx).coerceIn(0f, 1f)) } | |
| }, | |
| onDragEnd = { | |
| dragging = false | |
| val snapped = snapRobust(states.map { it.position }, anim.value, 0.08f) | |
| onValueChange(snapped) | |
| scope.launch { | |
| anim.animateTo(snapped, tween(380, easing = FastOutSlowInEasing)) | |
| } | |
| }, | |
| onDragCancel = { dragging = false } | |
| ) | |
| } | |
| ) { | |
| val selected = | |
| states.minByOrNull { kotlin.math.abs(it.position - anim.value) } ?: states.first() | |
| Canvas(Modifier.fillMaxSize()) { | |
| val w = size.width | |
| val h = size.height | |
| val slabH = trackHeight.toPx() | |
| val panelTop = h - slabH | |
| val edgePull = 0.82f | |
| fun mapX(p: Float) = (0.5f + (p.coerceIn(0f, 1f) - 0.5f) * edgePull) * w | |
| val cx = mapX(anim.value) | |
| val r = knobRadius.toPx() | |
| val lip = ringThickness.toPx() | |
| val outerR = r + lip | |
| val horizonOverlap = 0.6f | |
| val valleySpanFactor = 1.35f | |
| val tangentPullFactor = 0.65f | |
| val leftAngle = 210f | |
| val rightAngle = 330f | |
| val sweep = (rightAngle - leftAngle + 360f) % 360f | |
| fun deg(a: Float) = Math.toRadians(a.toDouble()).toFloat() | |
| fun posOnOuter(angle: Float) = Offset( | |
| x = cx + outerR * kotlin.math.cos(deg(angle)), | |
| y = panelTop + outerR * kotlin.math.sin(deg(angle)) | |
| ) | |
| fun tangentAt(angle: Float) = Offset( | |
| x = -kotlin.math.sin(deg(angle)), | |
| y = kotlin.math.cos(deg(angle)) | |
| ) | |
| val leftTouch = posOnOuter(leftAngle) | |
| val rightTouch = posOnOuter(rightAngle) | |
| val tanLeft = tangentAt(leftAngle) | |
| val tanRight = tangentAt(rightAngle) | |
| val valleySpan = outerR * valleySpanFactor | |
| val tangentPull = outerR * tangentPullFactor | |
| val leftHorizonStart = Offset( | |
| x = (leftTouch.x - valleySpan).coerceAtLeast(0f), | |
| y = panelTop - horizonOverlap | |
| ) | |
| val rightHorizonEnd = Offset( | |
| x = (rightTouch.x + valleySpan).coerceAtMost(w), | |
| y = panelTop - horizonOverlap | |
| ) | |
| drawRect(backgroundColor, topLeft = Offset.Zero, size = Size(w, panelTop)) | |
| val outerOval = Rect(cx - outerR, panelTop - outerR, cx + outerR, panelTop + outerR) | |
| val whiteMass = Path().apply { | |
| fillType = PathFillType.EvenOdd | |
| moveTo(0f, panelTop - horizonOverlap) | |
| lineTo(leftHorizonStart.x, leftHorizonStart.y) | |
| quadraticBezierTo( | |
| leftTouch.x - tanLeft.x * tangentPull, | |
| leftTouch.y - tanLeft.y * tangentPull, | |
| leftTouch.x, leftTouch.y | |
| ) | |
| arcTo(outerOval, leftAngle, sweep, false) | |
| quadraticBezierTo( | |
| rightTouch.x + tanRight.x * tangentPull, | |
| rightTouch.y + tanRight.y * tangentPull, | |
| rightHorizonEnd.x, rightHorizonEnd.y | |
| ) | |
| lineTo(w, panelTop - horizonOverlap) | |
| lineTo(w, h) | |
| lineTo(0f, h) | |
| close() | |
| addOval(Rect(cx - r, panelTop - r, cx + r, panelTop + r)) | |
| } | |
| drawPath(whiteMass, Color.White) | |
| val centerY = panelTop + slabH * 0.50f | |
| val majorW = 10.dp.toPx() | |
| val majorHalf = 20.dp.toPx() | |
| states.forEach { st -> | |
| val x = mapX(st.position) | |
| val isSel = st == selected | |
| val color = if (isSel) Color(0xFF111111) else Color(0xFFD0D0D0) | |
| val hTick = if (isSel) majorHalf * 2f else majorHalf * 1.7f | |
| drawRoundRect( | |
| color, | |
| topLeft = Offset(x - majorW / 2f, centerY - hTick / 2f), | |
| size = Size(majorW, hTick), | |
| cornerRadius = CornerRadius(majorW, majorW) | |
| ) | |
| } | |
| val sq = 6.dp.toPx() | |
| val minor = Color(0xFFE6E6E6) | |
| fun midCompress(t: Float) = 0.5f + (t - 0.5f) * 0.58f | |
| for (i in 0 until states.lastIndex) { | |
| val l = mapX(states[i].position) | |
| val rgt = mapX(states[i + 1].position) | |
| for (s in 1..3) { | |
| val t = midCompress(s / 4f) | |
| val x = l + (rgt - l) * t | |
| drawRoundRect( | |
| minor, | |
| topLeft = Offset(x - sq / 2f, centerY - sq / 2f), | |
| size = Size(sq, sq), | |
| cornerRadius = CornerRadius(2.dp.toPx(), 2.dp.toPx()) | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Snaps a value to the nearest tick within [radiusFrac]; otherwise snaps by midpoints. | |
| * | |
| * @param positions list of normalized tick positions | |
| * @param v value to snap | |
| * @param radiusFrac snap radius in normalized units | |
| * @return snapped value | |
| */ | |
| private fun snapRobust(positions: List<Float>, v: Float, radiusFrac: Float): Float { | |
| if (positions.isEmpty()) return v | |
| val sorted = positions.map { it.coerceIn(0f, 1f) }.sorted() | |
| val nearest = sorted.minByOrNull { abs(it - v) }!! | |
| if (abs(nearest - v) <= radiusFrac) return nearest | |
| if (v <= sorted.first()) return sorted.first() | |
| if (v >= sorted.last()) return sorted.last() | |
| for (i in 0 until sorted.lastIndex) { | |
| val a = sorted[i] | |
| val b = sorted[i + 1] | |
| val mid = (a + b) / 2f | |
| if (v < mid) return a | |
| if (v >= mid && v <= b) return b | |
| } | |
| return sorted.last() | |
| } | |
| /** | |
| * Draws a blurred circle safely, avoiding invalid mask filter arguments. | |
| * | |
| * @param color circle color | |
| * @param center circle center | |
| * @param radius circle radius in px | |
| * @param blur blur radius in px | |
| */ | |
| private fun DrawScope.drawSafeBlurredCircle( | |
| color: Color, | |
| center: Offset, | |
| radius: Float, | |
| blur: Float, | |
| ) { | |
| val r = radius.takeIf { it.isFinite() && it > 0f } ?: return | |
| val b = blur.takeIf { it.isFinite() && it > 0.5f } ?: 0f | |
| drawIntoCanvas { canvas -> | |
| val p = Paint() | |
| val fp = p.asFrameworkPaint().apply { | |
| isAntiAlias = true | |
| this.color = color.toArgb() | |
| } | |
| if (b > 0f) { | |
| fp.maskFilter = BlurMaskFilter(b, BlurMaskFilter.Blur.NORMAL) | |
| } | |
| canvas.drawCircle(center, r, p) | |
| fp.maskFilter = null | |
| } | |
| } | |
| /** | |
| * Draws a blurred round-rect safely, avoiding invalid mask filter arguments. | |
| * | |
| * @param color rect color | |
| * @param rect rectangle bounds | |
| * @param rx x-radius for corners | |
| * @param ry y-radius for corners | |
| * @param blur blur radius in px | |
| */ | |
| private fun DrawScope.drawSafeBlurredRoundRect( | |
| color: Color, | |
| rect: Rect, | |
| rx: Float, | |
| ry: Float, | |
| blur: Float, | |
| ) { | |
| val hasArea = rect.width > 0f && rect.height > 0f | |
| if (!hasArea) return | |
| val rxx = rx.coerceAtLeast(0f) | |
| val ryy = ry.coerceAtLeast(0f) | |
| val b = blur.takeIf { it.isFinite() && it > 0.5f } ?: 0f | |
| drawIntoCanvas { canvas -> | |
| val p = Paint() | |
| val fp = p.asFrameworkPaint().apply { | |
| isAntiAlias = true | |
| this.color = color.toArgb() | |
| } | |
| if (b > 0f) { | |
| fp.maskFilter = BlurMaskFilter(b, BlurMaskFilter.Blur.NORMAL) | |
| } | |
| canvas.nativeCanvas.drawRoundRect( | |
| rect.left, rect.top, rect.right, rect.bottom, rxx, ryy, fp | |
| ) | |
| fp.maskFilter = null | |
| } | |
| } | |
| /** | |
| * Tick metadata for the mood slider. | |
| * | |
| * @param position normalized position [0f, 1f] | |
| * @param label display label | |
| */ | |
| data class MoodTick(val position: Float, val label: String) | |
| /** Supported moods. */ | |
| enum class Mood { Sad, Confused, Happy } | |
| /** | |
| * Returns the nearest mood for a given value. | |
| * | |
| * @param value normalized slider value | |
| */ | |
| private fun nearestMood(value: Float): Mood = when { | |
| value < 0.25f -> Mood.Sad | |
| value < 0.75f -> Mood.Confused | |
| else -> Mood.Happy | |
| } | |
| /** Clamps [v] to [0f, 1f]. */ | |
| private fun clamp01(v: Float) = max(0f, min(1f, v)) | |
| /** Linear interpolation for floats. */ | |
| private fun lerp(a: Float, b: Float, t: Float) = a + (b - a) * t | |
| /** Linear interpolation for Dp. */ | |
| private fun lerp(a: Dp, b: Dp, t: Float): Dp = a + (b - a) * t | |
| /** | |
| * Three-stop color interpolation. | |
| */ | |
| private fun lerp3(a: Color, b: Color, c: Color, t: Float): Color = when { | |
| t <= 0.5f -> lerpColor(a, b, t / 0.5f) | |
| else -> lerpColor(b, c, (t - 0.5f) / 0.5f) | |
| } | |
| /** | |
| * Three-stop float interpolation. | |
| */ | |
| private fun lerp3(a: Float, b: Float, c: Float, t: Float): Float = when { | |
| t <= 0.5f -> lerp(a, b, t / 0.5f) | |
| else -> lerp(b, c, (t - 0.5f) / 0.5f) | |
| } | |
| /** | |
| * Per-channel color interpolation. | |
| */ | |
| private fun lerpColor(a: Color, b: Color, t: Float): Color = Color( | |
| red = lerp(a.red, b.red, t), | |
| green = lerp(a.green, b.green, t), | |
| blue = lerp(a.blue, b.blue, t), | |
| alpha = lerp(a.alpha, b.alpha, t) | |
| ) | |
| /** | |
| * Preview for the demo screen. | |
| */ | |
| @Preview(widthDp = 420, heightDp = 780, showBackground = true) | |
| @Composable | |
| private fun PreviewMoodFace() { | |
| MaterialTheme { | |
| MoodFaceDemo() | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment