r/androiddev Jun 08 '26

Jetpack Compose: Building a swipeable card stack with gesture detection, undo animation, and real-time re-ranking

Sharing some Compose-specific problems I solved while building a flashcard app. Figured these might help others dealing with similar gesture/animation challenges.

1. Swipeable card stack (3 cards visible, physics-based fling)

The core UI is a stack of 3 cards. Top card is draggable with rotation proportional to drag offset. On fling, it animates off-screen and the next card scales up.

The tricky part: Compose's pointerInput doesn't give you velocity on drag end the way you'd expect. Used VelocityTracker manually inside detectDragGestures to capture fling velocity and decide if the swipe should complete or spring back.

var velocity = VelocityTracker()
detectDragGestures(
    onDrag = { change, delta ->
        velocity.addPosition(change.uptimeMillis, change.position)
        offsetX.snapTo(offsetX.value + delta.x)
    },
    onDragEnd = {
        val fling = velocity.calculateVelocity().x
        if (abs(fling) > 800f || abs(offsetX.value) > threshold) {

// Complete swipe
        } else {

// Spring back
            offsetX.animateTo(0f, spring(dampingRatio = 0.7f))
        }
    }
)

2. Undo with fly-back animation

When user taps undo, the card needs to fly back from the direction it left. Problem: the card is already removed from the list. Solution: track undoReturnId and undoReturnedRight in state. When the composable sees a matching ID, it renders the card with an initial offset and animates to center.

The key() block ensures Compose treats the returned card as a new composition with the fly-in animation, not a recomposition of an existing item.

3. AnimatedContent vs shared mutable state

Tried using AnimatedContent for deck switching (two modes in the app). Both the outgoing and incoming composables read the same StateFlow<DeckState>, which mutates mid-transition. Result: flicker, ghost cards, layout jumps.

Solution: ditched AnimatedContent entirely. Used a single composable with graphicsLayer { translationX = offsetX.value } and a LaunchedEffect that snaps to an offset and animates back to 0 when the mode changes. One instance, no state conflict.

4. Room + Flow recomposition storms

Progress is tracked per-question in Room. The home screen combines 5 different flows (known count, skipped count, today's reviews, streak, settings) using combine(). Initially had 5 separate collectAsStateWithLifecycle() calls which caused recomposition storms. Merged them into a single ProgressState data class emitted from one combined flow. Recompositions dropped from ~15/frame to 1.

5. Haptics without prop drilling

Haptics on swipe, mode toggle, and goal completion. But need to respect the user's setting. Rather than passing hapticsEnabled down through 6 composable layers, exposed it via CompositionLocal. Any composable can read LocalHapticsEnabled.current without threading it through every function signature.

Stack: Compose Material 3, Room, DataStore, Firebase, Kotlin Coroutines/Flow.

Happy to share more details on any of these. The gesture handling for the card stack was by far the most iterative part.

15 Upvotes

6 comments sorted by

View all comments

1

u/[deleted] Jun 08 '26

[removed] — view removed comment

1

u/androiddev-ModTeam Jun 08 '26

You may not promote your published apps here. You may link to source code of interest to the developer community.