Created
September 19, 2025 15:13
-
-
Save Kyriakos-Georgiopoulos/2be30128eed1030bd02b3b382a522cae 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
import androidx.compose.animation.core.* | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.gestures.detectDragGestures | |
import androidx.compose.foundation.layout.* | |
import androidx.compose.foundation.shape.CircleShape | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.material3.* | |
import androidx.compose.runtime.* | |
import androidx.compose.ui.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.draw.alpha | |
import androidx.compose.ui.draw.clip | |
import androidx.compose.ui.draw.rotate | |
import androidx.compose.ui.draw.scale | |
import androidx.compose.ui.graphics.Brush | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.input.pointer.pointerInput | |
import androidx.compose.ui.platform.LocalDensity | |
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.util.lerp | |
import kotlinx.coroutines.launch | |
import kotlin.math.abs | |
@Composable | |
fun SpringPlayground() { | |
Box( | |
Modifier | |
.fillMaxSize() | |
.background(Color(0xFF0F1115)) | |
.padding(24.dp), | |
contentAlignment = Alignment.Center | |
) { | |
Column( | |
verticalArrangement = Arrangement.spacedBy(28.dp), | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
PopFeedbackButton() | |
ExpandableSpringCard() | |
DraggableSpringChip() | |
} | |
} | |
} | |
/* ---------------- LESSON 1: Spring feedback on tap ---------------- | |
Small scale/tilt changes with a spring make interactions feel alive. | |
This shows how springs can replace static tap states with motion. | |
-------------------------------------------------------------------- */ | |
@Composable | |
private fun PopFeedbackButton() { | |
var pressed by remember { mutableStateOf(false) } | |
val scope = rememberCoroutineScope() | |
val scale by animateFloatAsState( | |
targetValue = if (pressed) 1.12f else 1f, | |
animationSpec = spring( | |
dampingRatio = Spring.DampingRatioLowBouncy, | |
stiffness = Spring.StiffnessMedium | |
), | |
label = "popScale" | |
) | |
val tilt by animateFloatAsState( | |
targetValue = if (pressed) 6f else 0f, | |
animationSpec = spring( | |
dampingRatio = Spring.DampingRatioLowBouncy, | |
stiffness = Spring.StiffnessMediumLow | |
), | |
label = "popTilt" | |
) | |
Button( | |
onClick = { | |
pressed = true | |
scope.launch { | |
kotlinx.coroutines.delay(120) | |
pressed = false | |
} | |
}, | |
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF5B86E5)), | |
shape = RoundedCornerShape(14.dp), | |
modifier = Modifier | |
.scale(scale) | |
.rotate(tilt) | |
.height(48.dp) | |
) { Text("Spring Pop", color = Color.White) } | |
} | |
/* ---------------- LESSON 2: Expand with personality ---------------- | |
Container height uses a stable spring (no bounce), | |
while the inside content overshoots playfully. | |
This contrast makes UIs feel confident yet lively. | |
-------------------------------------------------------------------- */ | |
@Composable | |
private fun ExpandableSpringCard() { | |
var expanded by remember { mutableStateOf(false) } | |
val height by animateDpAsState( | |
targetValue = if (expanded) 200.dp else 88.dp, | |
animationSpec = spring( | |
dampingRatio = Spring.DampingRatioNoBouncy, | |
stiffness = Spring.StiffnessMedium | |
), | |
label = "cardHeight" | |
) | |
val line1Scale by animateFloatAsState( | |
if (expanded) 1f else 0.9f, | |
spring(Spring.DampingRatioLowBouncy, Spring.StiffnessLow) | |
) | |
val line2Scale by animateFloatAsState( | |
if (expanded) 1f else 0.9f, | |
spring(Spring.DampingRatioLowBouncy, Spring.StiffnessLow) | |
) | |
val line3Scale by animateFloatAsState( | |
if (expanded) 1f else 0.9f, | |
spring(Spring.DampingRatioLowBouncy, Spring.StiffnessLow) | |
) | |
val line1Alpha by animateFloatAsState(if (expanded) 1f else 0.75f) | |
val line2Alpha by animateFloatAsState(if (expanded) 1f else 0.75f) | |
val line3Alpha by animateFloatAsState(if (expanded) 1f else 0.75f) | |
Column( | |
modifier = Modifier | |
.fillMaxWidth(0.92f) | |
.clip(RoundedCornerShape(16.dp)) | |
.background(Color(0x22FFFFFF)) | |
.height(height) | |
.padding(16.dp) | |
) { | |
Row( | |
Modifier.fillMaxWidth(), | |
horizontalArrangement = Arrangement.SpaceBetween, | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
Text("Expandable (spring)", color = Color.White) | |
TextButton(onClick = { expanded = !expanded }) { | |
Text(if (expanded) "Collapse" else "Expand") | |
} | |
} | |
Spacer(Modifier.height(12.dp)) | |
FunLine(line1Scale, line1Alpha, 1f) | |
Spacer(Modifier.height(8.dp)) | |
FunLine(line2Scale, line2Alpha, 0.72f) | |
if (expanded) { | |
Spacer(Modifier.height(8.dp)) | |
FunLine(line3Scale, line3Alpha, 0.54f) | |
} | |
} | |
} | |
@Composable | |
private fun FunLine(scale: Float, alpha: Float, widthFraction: Float) { | |
Box( | |
Modifier | |
.fillMaxWidth(widthFraction) | |
.height(10.dp) | |
.scale(scale) | |
.alpha(alpha) | |
.clip(RoundedCornerShape(6.dp)) | |
.background(Brush.horizontalGradient(listOf(Color(0xFF36D1DC), Color(0xFF5B86E5)))) | |
) | |
} | |
/* ---------------- LESSON 3: Drag with elasticity ---------------- | |
The chip can be dragged across the track. | |
Ghosts trail behind for character. | |
On release, a spring pulls everything back to center. | |
This shows springs as a natural way to settle interactive motion. | |
-------------------------------------------------------------------- */ | |
@Composable | |
private fun DraggableSpringChip() { | |
var dragFrac by remember { mutableFloatStateOf(0f) } | |
var isDragging by remember { mutableStateOf(false) } | |
val settle = remember { Animatable(0f) } | |
val scope = rememberCoroutineScope() | |
val density = LocalDensity.current | |
val display = if (isDragging) dragFrac else settle.value | |
val ghost1 by animateFloatAsState(display, tween(140, easing = LinearEasing), label = "g1") | |
val ghost2 by animateFloatAsState(display, tween(260, easing = LinearEasing), label = "g2") | |
val ghost3 by animateFloatAsState(display, tween(380, easing = LinearEasing), label = "g3") | |
BoxWithConstraints( | |
modifier = Modifier | |
.fillMaxWidth(0.92f) | |
.height(72.dp) | |
.clip(RoundedCornerShape(36.dp)) | |
.background(Color(0x18FFFFFF)), | |
contentAlignment = Alignment.Center | |
) { | |
val chipSize = 48.dp | |
val travel = (maxWidth - chipSize).coerceAtLeast(0.dp) | |
val halfTravel = travel / 2f | |
val halfTravelPx = with(density) { halfTravel.toPx() } | |
fun fracToX(f: Float): Dp = | |
lerp(-halfTravel, halfTravel, ((f.coerceIn(-1f, 1f) + 1f) / 2f)) | |
GhostCircle(fracToX(ghost3), 0.45f) | |
GhostCircle(fracToX(ghost2), 0.65f) | |
GhostCircle(fracToX(ghost1), 0.85f) | |
val tilt = 10f * display | |
val scale = 1f + 0.08f * abs(display) | |
val color = Brush.horizontalGradient( | |
if (display >= 0f) listOf(Color(0xFFFF7EB3), Color(0xFF36D1DC)) | |
else listOf(Color(0xFF36D1DC), Color(0xFF5B86E5)) | |
) | |
Box( | |
modifier = Modifier | |
.offset(x = fracToX(display)) | |
.size(chipSize) | |
.scale(scale) | |
.rotate(tilt) | |
.clip(CircleShape) | |
.background(color) | |
.pointerInput(halfTravelPx) { | |
detectDragGestures( | |
onDragStart = { | |
isDragging = true | |
scope.launch { settle.stop() } | |
dragFrac = settle.value | |
}, | |
onDrag = { change, drag -> | |
change.consume() | |
if (halfTravelPx > 0f) { | |
val dxFrac = drag.x / halfTravelPx | |
dragFrac = (dragFrac + dxFrac).coerceIn(-1f, 1f) | |
} | |
}, | |
onDragEnd = { | |
isDragging = false | |
scope.launch { | |
settle.snapTo(dragFrac) | |
settle.animateTo( | |
targetValue = 0f, | |
animationSpec = spring( | |
dampingRatio = Spring.DampingRatioLowBouncy, | |
stiffness = Spring.StiffnessLow | |
) | |
) | |
dragFrac = settle.value | |
} | |
}, | |
onDragCancel = { | |
isDragging = false | |
scope.launch { | |
settle.snapTo(dragFrac) | |
settle.animateTo( | |
0f, | |
spring( | |
dampingRatio = Spring.DampingRatioLowBouncy, | |
stiffness = Spring.StiffnessLow | |
) | |
) | |
dragFrac = settle.value | |
} | |
} | |
) | |
} | |
) | |
} | |
} | |
@Composable | |
private fun GhostCircle(x: Dp, alpha: Float) { | |
Box( | |
modifier = Modifier | |
.offset(x = x) | |
.size(36.dp) | |
.clip(CircleShape) | |
.background(Color.White.copy(alpha = 0.08f)) | |
.alpha(alpha) | |
) | |
} | |
@Preview(showBackground = true, backgroundColor = 0xFFF0F0F0) | |
@Composable | |
private fun PreviewSpringPlayground() { | |
SpringPlayground() | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment