Last active
October 28, 2025 14:36
-
-
Save Kyriakos-Georgiopoulos/db6de8fc3bd7f157dbb0eb71e667d255 to your computer and use it in GitHub Desktop.
Revisions
-
Kyriakos-Georgiopoulos revised this gist
Oct 28, 2025 . No changes.There are no files selected for viewing
-
Kyriakos-Georgiopoulos created this gist
Oct 28, 2025 .There are no files selected for viewing
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 charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,446 @@ /* * 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 }