Skip to content

Instantly share code, notes, and snippets.

@Kyriakos-Georgiopoulos
Last active October 28, 2025 14:36
Show Gist options
  • Save Kyriakos-Georgiopoulos/db6de8fc3bd7f157dbb0eb71e667d255 to your computer and use it in GitHub Desktop.
Save Kyriakos-Georgiopoulos/db6de8fc3bd7f157dbb0eb71e667d255 to your computer and use it in GitHub Desktop.
/*
* 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.
*/
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults.centerAlignedTopAppBarColors
import androidx.compose.material3.TopAppBarDefaults.pinnedScrollBehavior
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.center
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.launch
import kotlin.math.PI
import kotlin.math.pow
import kotlin.math.sin
/* ──────────────────────────────────────────────────────────────────────────────
Public model
──────────────────────────────────────────────────────────────────────────── */
data class RowItem(val id: Int, val title: String)
/* ──────────────────────────────────────────────────────────────────────────────
Tunables (easy to tweak)
──────────────────────────────────────────────────────────────────────────── */
private const val ANIM_DURATION_MS = 600 // full stomp timeline
private const val CRUSH_MULTIPLIER = 1.2f // neighbor travel strength
private const val AFTER_SHOCK_FACTOR = 0.18f // wobble amplitude factor
private const val DEFAULT_ITEM_SHIFT_DP = 48 // fallback when height unknown
private const val MIN_CRUSH_PX = 24f // never move less than this
private const val TOPBAR_ROTATE_DEG = 2.0f // tiny wiggle
private const val NEIGHBOR_ROTATE_DEG = 2.0f // tiny wiggle
private const val NEIGHBOR_SQUASH = 0.14f // impact squash strength
private const val NEIGHBOR_PINCH_X = 0.05f // impact pinch strength
/* ──────────────────────────────────────────────────────────────────────────────
Screen
──────────────────────────────────────────────────────────────────────────── */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StompListScreen(
initial: List<RowItem> = List(100) { RowItem(it, "Item #$it") }
) {
// Data
var items by remember { mutableStateOf(initial) }
val listState = rememberLazyListState()
// Animation state
var deletingId by remember { mutableStateOf<Int?>(null) }
val stompProgress = remember { Animatable(0f) } // 0..1 per stomp
val heightsPx = remember { mutableStateMapOf<Int, Int>() } // measured row heights
// Utils
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
val density = LocalDensity.current
/* Delete action: optionally reveal neighbors, run timeline, then remove. */
fun triggerDelete(itemId: Int) {
if (deletingId != null) return
deletingId = itemId
scope.launch {
val idx = items.indexOfFirst { it.id == itemId }
if (idx != -1) {
val above = (idx - 1).coerceAtLeast(0)
val below = (idx + 1).coerceAtMost(items.lastIndex)
val needAbove = !listState.isIndexVisible(above)
val needBelow = !listState.isIndexVisible(below)
when {
needAbove -> runCatching { listState.animateScrollToItem(above) }
needBelow -> runCatching { listState.animateScrollToItem(below) }
}
}
stompProgress.snapTo(0f)
stompProgress.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = ANIM_DURATION_MS,
easing = FastOutSlowInEasing
)
)
if (deletingId == itemId) items = items.filterNot { it.id == itemId }
deletingId = null
stompProgress.snapTo(0f)
}
}
/* Top app bar participates when first item is target (acts like "above neighbor"). */
val targetId = deletingId
val targetIndex = targetId?.let { tid -> items.indexOfFirst { it.id == tid } } ?: -1
val p = stompProgress.value
val defaultShiftPx = with(density) { DEFAULT_ITEM_SHIFT_DP.dp.toPx() }
val targetHeightPx = if (targetIndex >= 0) (heightsPx[targetId] ?: 0).toFloat() else 0f
val crushBase = (((if (targetHeightPx > 0) targetHeightPx else defaultShiftPx) / 2f)
.coerceAtLeast(MIN_CRUSH_PX))
val topBarInvolved = targetIndex == 0 && deletingId != null
val topBarOffset =
if (topBarInvolved) crushBase * smoothCrushCurve(p) + afterShock(p) * (crushBase * AFTER_SHOCK_FACTOR)
else 0f
val topBarScaleY = if (topBarInvolved) 1f + NEIGHBOR_SQUASH * impactPulseSoft(p) else 1f
val topBarScaleX = if (topBarInvolved) 1f - 0.045f * impactPulseSoft(p) else 1f
val topBarRotate = if (topBarInvolved) TOPBAR_ROTATE_DEG * impactPulseSoft(p) else 0f
// Material 3 scaffold & top bar (colors adapt to light/dark via theme)
val scrollBehavior = pinnedScrollBehavior()
val topBarColors = centerAlignedTopAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(3.dp),
scrolledContainerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
titleContentColor = MaterialTheme.colorScheme.onSurface
)
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
CenterAlignedTopAppBar(
title = { Text("Stomp to Delete") },
colors = topBarColors,
scrollBehavior = scrollBehavior,
modifier = Modifier
.graphicsLayer {
translationY = topBarOffset
scaleY = topBarScaleY
scaleX = topBarScaleX
rotationZ = topBarRotate
}
.zIndex(1f) // draw above list
)
}
) { padding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(padding),
color = MaterialTheme.colorScheme.surface
) {
LazyColumn(
state = listState,
contentPadding = PaddingValues(
start = 12.dp,
end = 12.dp,
top = 8.dp,
bottom = 24.dp
),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
itemsIndexed(items, key = { _, it -> it.id }) { index, item ->
val isAbove = deletingId != null && index == targetIndex - 1
val isBelow = deletingId != null && index == targetIndex + 1
val isTarget = deletingId == item.id
// Total neighbor travel (towards the target) with extra intensity.
val crush = crushBase * CRUSH_MULTIPLIER
val towardPx = when {
isAbove -> +crush * smoothCrushCurve(p)
isBelow -> -crush * smoothCrushCurve(p)
else -> 0f
}
val wobblePx = afterShock(p) * (crushBase * AFTER_SHOCK_FACTOR)
val totalOffsetPx = towardPx + when {
isAbove -> +wobblePx
isBelow -> -wobblePx
else -> 0f
}
// Small haptic right at impact.
if (isTarget && p in 0.47f..0.49f) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
}
// Neighbor squash/pinch/tilt.
val neighborScaleY =
if (isAbove || isBelow) 1f + NEIGHBOR_SQUASH * impactPulseSoft(p) else 1f
val neighborScaleX =
if (isAbove || isBelow) 1f - NEIGHBOR_PINCH_X * impactPulseSoft(p) else 1f
val neighborRotate = when {
isAbove -> +NEIGHBOR_ROTATE_DEG * impactPulseSoft(p)
isBelow -> -NEIGHBOR_ROTATE_DEG * impactPulseSoft(p)
else -> 0f
}
// Target vanish curve (scaleY & alpha).
val targetScaleY = if (isTarget) 1f - smoothVanishCurve(p) else 1f
val targetAlpha = if (isTarget) 1f - smoothVanishCurve(p) else 1f
// Impact ring on target only.
val showImpact = isTarget && p in 0.33f..0.72f
val impactStrength = ringCurveSmooth(p)
StompCardRow(
item = item,
onDelete = { triggerDelete(item.id) },
modifier = Modifier
.fillMaxWidth()
.animateItem(
fadeInSpec = null, // avoid first composition fade
fadeOutSpec = null, // we manage vanish ourselves
placementSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessLow
)
)
.offset(y = with(density) { totalOffsetPx.toDp() })
.graphicsLayer {
if (isTarget) {
scaleY = targetScaleY
alpha = targetAlpha
} else {
scaleY = neighborScaleY
scaleX = neighborScaleX
rotationZ = neighborRotate
}
}
.zIndex(if (isTarget) 1f else 0f)
.onGloballyPositioned { coords ->
val h = coords.size.height
if (h > 0) heightsPx[item.id] = h
},
impact = if (showImpact) impactStrength else 0f
)
}
}
}
}
}
/* ──────────────────────────────────────────────────────────────────────────────
Row (Material 3 look)
──────────────────────────────────────────────────────────────────────────── */
@Composable
private fun StompCardRow(
item: RowItem,
onDelete: () -> Unit,
modifier: Modifier = Modifier,
impact: Float = 0f
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surfaceContainerLow, // subtle separation on dark
tonalElevation = 1.dp
) {
Box(Modifier.fillMaxWidth()) {
ListItem(
headlineContent = {
Text(
item.title,
style = MaterialTheme.typography.titleMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium
)
)
},
supportingContent = {
Text(
"Tap delete to stomp",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
trailingContent = {
FilledTonalIconButton(
onClick = onDelete,
colors = IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
) {
Icon(Icons.Default.Delete, contentDescription = "Delete")
}
},
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
// Expanding, fading ring to sell the “crash”
if (impact > 0f) ImpactRing(
progress = impact,
modifier = Modifier
.matchParentSize()
.zIndex(-1f)
)
}
}
}
/* ──────────────────────────────────────────────────────────────────────────────
Impact ring visual
──────────────────────────────────────────────────────────────────────────── */
@Composable
private fun ImpactRing(progress: Float, modifier: Modifier = Modifier) {
val fade = (1f - progress).coerceIn(0f, 1f)
// Dark UIs need a little more energy for readability.
val color = MaterialTheme.colorScheme.error.copy(alpha = 0.42f * fade)
Canvas(modifier) {
val c = size.center
val r = size.minDimension / 2f
val radius = r * (0.18f + progress * 1.25f)
drawCircle(
color = color,
radius = radius,
center = c,
style = Stroke(width = 14f * (1f - 0.55f * progress))
)
}
}
/* ──────────────────────────────────────────────────────────────────────────────
Curves & helpers (animation math)
──────────────────────────────────────────────────────────────────────────── */
/** Smooth approach with tiny recoil after impact. */
private fun smoothCrushCurve(p: Float): Float {
val approachEnd = 0.44f
val a = (p / approachEnd).coerceIn(0f, 1f)
val approach = easeOutCubic.transform(a)
val b = ((p - approachEnd) / (1f - approachEnd)).coerceIn(0f, 1f)
val settle = 1f - easeInCubic.transform(b) * 0.22f
return approach * settle
}
/** Softer, rounded impact pulse used for squash/pinch/tilt. */
private fun impactPulseSoft(p: Float): Float {
val start = 0.45f
val dur = 0.18f
if (p < start || p > start + dur) return 0f
val t = (p - start) / dur
return sin(Math.PI.toFloat() * t).pow(0.9f)
}
/** Damped after-shock wobble for neighbors after the collision. */
private fun afterShock(p: Float): Float {
val t = ((p - 0.50f) / 0.50f).coerceIn(0f, 1f)
val waves = sin(t * 4.5f * PI).toFloat()
val decay = 1f - t
return waves * decay
}
/** Smoother vanish curve for the target (affects scaleY & alpha). */
private fun smoothVanishCurve(p: Float): Float {
val mid = 0.44f
return if (p <= mid) {
0.62f * easeInOutCubic.transform(p / mid)
} else {
0.62f + 0.38f * easeInCubic.transform((p - mid) / (1f - mid))
}
}
/** Impact ring strength with a gentle ease-out. */
private fun ringCurveSmooth(p: Float): Float =
when {
p < 0.33f -> 0f
p > 0.75f -> 1f - ((p - 0.75f) / 0.25f).coerceIn(0f, 1f)
else -> easeOutCubic.transform(((p - 0.33f) / 0.42f).coerceIn(0f, 1f))
}
/* Simple easing utilities */
private val easeInCubic = Easing { t -> t * t * t }
private val easeOutCubic = Easing { t -> 1f - (1f - t).pow(3) }
private val easeInOutCubic = Easing { t ->
if (t < 0.5f) 4f * t * t * t else 1f - (-2f * t + 2f).pow(3) / 2f
}
/* ──────────────────────────────────────────────────────────────────────────────
Preview & list utilities
──────────────────────────────────────────────────────────────────────────── */
@Preview(showBackground = true)
@Composable
private fun PreviewStompList_Dark() {
MaterialTheme(colorScheme = darkColorScheme()) {
StompListScreen()
}
}
/** True if [index] is currently within the visible viewport. */
private fun LazyListState.isIndexVisible(index: Int): Boolean {
val visible = layoutInfo.visibleItemsInfo
if (visible.isEmpty()) return false
val first = visible.first().index
val last = visible.last().index
return index in first..last
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment