Last active
October 28, 2025 14:36
-
-
Save Kyriakos-Georgiopoulos/db6de8fc3bd7f157dbb0eb71e667d255 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. | |
| */ | |
| 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