Skip to content

Instantly share code, notes, and snippets.

@Kyriakos-Georgiopoulos
Last active October 28, 2025 14:36
Show Gist options
  • Select an option

  • Save Kyriakos-Georgiopoulos/db6de8fc3bd7f157dbb0eb71e667d255 to your computer and use it in GitHub Desktop.

Select an option

Save Kyriakos-Georgiopoulos/db6de8fc3bd7f157dbb0eb71e667d255 to your computer and use it in GitHub Desktop.

Revisions

  1. Kyriakos-Georgiopoulos revised this gist Oct 28, 2025. No changes.
  2. Kyriakos-Georgiopoulos created this gist Oct 28, 2025.
    446 changes: 446 additions & 0 deletions StompListScreen.kt
    Original 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
    }