Skip to content

Instantly share code, notes, and snippets.

@Kyriakos-Georgiopoulos
Created September 19, 2025 15:13
Show Gist options
  • Save Kyriakos-Georgiopoulos/2be30128eed1030bd02b3b382a522cae to your computer and use it in GitHub Desktop.
Save Kyriakos-Georgiopoulos/2be30128eed1030bd02b3b382a522cae to your computer and use it in GitHub Desktop.
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