When to Use Gestures and Animations

Use Gestures When:

  • You want to provide intuitive ways to interact with your app
  • You need to handle touch input beyond simple taps
  • You want to create natural, app-like interactions
  • You're building interactive elements like sliders or draggable items
  • You want to provide alternative ways to perform actions

Use Animations When:

  • You want to provide visual feedback for user actions
  • You need to guide users' attention to important elements
  • You want to make transitions between screens smoother
  • You're showing loading states or progress
  • You want to make your app feel more polished and responsive

Common Gesture and Animation Components

Component What It Does When to Use It
clickable Makes an element respond to taps For buttons, cards, or any tappable element
pointerInput Handles complex touch gestures For custom gesture recognition
animate*AsState Animates state changes smoothly For smooth transitions between states
AnimatedVisibility Animates elements appearing/disappearing For showing/hiding content with animation

Practical Examples

Tap Animation Example
@Composable
fun TapAnimationExample() {
    var isPressed by remember { mutableStateOf(false) }
    val scale by animateFloatAsState(
        targetValue = if (isPressed) 0.95f else 1f,
        animationSpec = tween(durationMillis = 100), label = "tapScale"
    )
    val buttonColor by animateColorAsState(
        targetValue = if (isPressed)
            Color(0xFFFF0000) // Red color when pressed
        else
            MaterialTheme.colorScheme.primary, // Normal theme color
        animationSpec = tween(durationMillis = 100), label = "tapColor"
    )
    Card(
        modifier = Modifier
            .scale(scale)
            .pointerInput(Unit) {
                detectTapGestures(
                    onPress = {
                        isPressed = true
                        try {
                            awaitRelease()
                        } finally {
                            isPressed = false
                        }
                    }
                )
            },
        colors = CardDefaults.cardColors(
            containerColor = buttonColor
        ),
        shape = MaterialTheme.shapes.medium
    ) {
        Box(
            modifier = Modifier
                .padding(horizontal = 24.dp, vertical = 12.dp),
            contentAlignment = Alignment.Center
        ) {
            Text(
                text = "Tap Me!",
                color = MaterialTheme.colorScheme.onPrimary,
                style = MaterialTheme.typography.labelLarge
            )
        }
    }
}

Code Breakdown

Press state and animated values

var isPressed by remember { mutableStateOf(false) }
val scale by animateFloatAsState(targetValue = if (isPressed) 0.95f else 1f, ...)
val buttonColor by animateColorAsState(targetValue = if (isPressed) Color(0xFFFF0000) else MaterialTheme.colorScheme.primary, ...)

var isPressed by remember { mutableStateOf(false) } creates a Boolean state variable that survives recomposition and starts as not pressed. The two animation lines read that same state: when isPressed is true, scale targets 0.95f (slight shrink) and buttonColor targets red; when false, they return to normal size and theme primary color. Because both animated values are derived from one state source, the size and color transitions stay synchronized and Compose updates both automatically whenever press state changes.

Gesture handler that controls the state

.pointerInput(Unit) {
    detectTapGestures(
        onPress = {
            isPressed = true
            try { awaitRelease() } finally { isPressed = false }
        }
    )
}

pointerInput(Unit) attaches a gesture detector to this composable, and using Unit means the detector does not restart unless this modifier is recreated. Inside it, detectTapGestures(onPress = ...) runs when the pointer goes down, immediately setting isPressed = true so the animations begin. awaitRelease() suspends until the finger lifts (or the gesture is canceled), and the finally block always runs, forcing isPressed = false so the UI reliably animates back to normal.

Apply animated values to UI

Card(
    modifier = Modifier.scale(scale),
    colors = CardDefaults.cardColors(containerColor = buttonColor)
)

Modifier.scale(scale) applies the current animated float to the entire card transform, so all content inside the card shrinks/expands together. CardDefaults.cardColors(containerColor = buttonColor) binds the card background to the animated color state, so color transitions happen through the same animation system instead of abrupt changes. These two bindings turn gesture state into two synchronized visual channels (shape and color), which is why the feedback feels responsive and polished.

Swipe to Delete Example
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SwipeToDeleteExample() {
    var items by remember { mutableStateOf((1..3).map { "Item $it" }.toMutableList()) }
    val scope = rememberCoroutineScope()
    Column {
        items.forEach { item ->
            key(item) {
                SwipeableListItem(
                    item = item,
                    onDelete = {
                        scope.launch {
                            delay(250) // Let dismiss animation/background settle briefly before removal.
                            val updatedItems = items.toMutableList()
                            updatedItems.remove(item)
                            items = updatedItems
                        }
                    }
                )
                Spacer(modifier = Modifier.height(8.dp))
            }
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SwipeableListItem(item: String, onDelete: () -> Unit) {
    val dismissState = rememberSwipeToDismissBoxState(
        confirmValueChange = { value ->
            if (value == SwipeToDismissBoxValue.EndToStart) {
                onDelete()
                true
            } else {
                false
            }
        }
    )
    SwipeToDismissBox(
        state = dismissState,
        backgroundContent = {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .background(Color.Red)
                    .padding(horizontal = 20.dp),
                contentAlignment = Alignment.CenterEnd
            ) {
                Icon(
                    imageVector = Icons.Default.Delete,
                    contentDescription = "Delete",
                    tint = Color.White
                )
            }
        }
    ) {
        Card(
            modifier = Modifier.fillMaxWidth()
        ) {
            ListItem(
                headlineContent = { Text(item) }
            )
        }
    }
}

Code Breakdown

Parent list state

var items by remember { mutableStateOf((1..3).map { "Item $it" }.toMutableList()) }
val scope = rememberCoroutineScope()
items.forEach { item ->
    key(item) {
        SwipeableListItem(
            item = item,
            onDelete = {
                scope.launch {
                    delay(250)
                    val updatedItems = items.toMutableList()
                    updatedItems.remove(item)
                    items = updatedItems
                }
            }
        )
        Spacer(modifier = Modifier.height(8.dp))
    }
}

The first line creates mutable UI state that holds a list of three strings, and the (1..3).map { "Item $it" } expression generates those labels programmatically. rememberCoroutineScope() provides a scope that can launch coroutines from the onDelete callback (the source file imports kotlinx.coroutines.delay and kotlinx.coroutines.launch). key(item) ties each row’s composition identity to its string so swipe/dismiss state does not slide to the wrong row when another item is removed. The forEach loop renders one SwipeableListItem per value. onDelete uses scope.launch { delay(250) … } so the dismiss animation can finish before the list mutates; then it copies the list, removes the item, and assigns items = updatedItems so Compose observes a new list reference and the row leaves the UI cleanly.

Dismiss-state gate for allowed swipe direction

val dismissState = rememberSwipeToDismissBoxState(
    confirmValueChange = { value ->
        if (value == SwipeToDismissBoxValue.EndToStart) {
            onDelete()
            true
        } else {
            false
        }
    }
)

rememberSwipeToDismissBoxState stores swipe progress and target dismissal value for this row. The confirmValueChange callback runs before the state commits to a new value, so it is the correct place to enforce business rules. Here, only SwipeToDismissBoxValue.EndToStart is accepted: if matched, onDelete() is called and true confirms the dismiss; otherwise false cancels state change and snaps the row back. SwipeToDismissBox (shown below) is the container composable that enables this interaction: it wraps a row, tracks horizontal drag gestures, and transitions the content into a dismissed state when the gesture reaches the configured threshold. These swipe APIs come from Jetpack Compose Material 3 (for example, androidx.compose.material3.SwipeToDismissBox, androidx.compose.material3.SwipeToDismissBoxValue, and androidx.compose.material3.rememberSwipeToDismissBoxState).

Background and foreground layers

SwipeToDismissBox(
    state = dismissState,
    backgroundContent = { /* red background + delete icon */ }
) {
    Card { ListItem(headlineContent = { Text(item) }) }
}

SwipeToDismissBox renders two layers: backgroundContent (red container with delete icon) and the main content lambda (card/list row). During swipe, the foreground row translates while the background is gradually revealed, which visually explains the pending delete action before commit. Keeping the icon in the background layer, rather than inside the row, ensures it stays anchored while the row moves away.

Draggable Card Example
@Composable
fun DraggableCardExample() {
    var offset by remember { mutableStateOf(Offset.Zero) }
    val animatedOffset by animateOffsetAsState(
        targetValue = offset,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessLow
        ), label = "dragOffset"
    )
    Card(
        modifier = Modifier
            .offset { IntOffset(animatedOffset.x.roundToInt(), animatedOffset.y.roundToInt()) }
            .pointerInput(Unit) {
                detectDragGestures { change, dragAmount ->
                    change.consume()
                    offset += Offset(dragAmount.x, dragAmount.y)
                }
            }
            .size(200.dp)
    ) {
        Box(
            modifier = Modifier.fillMaxSize(),
            contentAlignment = Alignment.Center
        ) {
            Text("Drag me around!")
        }
    }
}

Code Breakdown

Position state and animated output

var offset by remember { mutableStateOf(Offset.Zero) }
val animatedOffset by animateOffsetAsState(
    targetValue = offset,
    animationSpec = spring(...)
)

offset is the mutable state that stores the current drag translation in x/y coordinates relative to the card's starting location. animateOffsetAsState(targetValue = offset, ...) creates a second value that continuously interpolates toward that target with spring physics. Using this animated value for rendering prevents abrupt jumps and gives physically smoother motion whenever drag updates arrive quickly.

Drag detection and state updates

.pointerInput(Unit) {
    detectDragGestures { change, dragAmount ->
        change.consume()
        offset += Offset(dragAmount.x, dragAmount.y)
    }
}

detectDragGestures emits many small movement deltas rather than one final position, so the handler accumulates each delta into offset using offset += Offset(...). change.consume() marks the pointer event as handled, which prevents parent/neighbor gesture detectors from also treating that same movement as their own gesture. This keeps drag behavior deterministic and avoids conflicting interactions in nested layouts.

Render position on screen

.offset {
    IntOffset(animatedOffset.x.roundToInt(), animatedOffset.y.roundToInt())
}

The offset modifier lambda must return IntOffset, so floating-point animation coordinates are converted with roundToInt(). This conversion happens every frame, mapping the smooth animation output to valid pixel positions that the layout system can place on screen. By using animatedOffset here instead of raw state, the card's visible position remains smooth even as state updates happen rapidly.

Animated Visibility Example
@Composable
fun AnimatedVisibilityExample() {
    var visible by remember { mutableStateOf(false) }
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Button(onClick = { visible = !visible }) {
            Text(if (visible) "Hide" else "Show")
        }
        AnimatedVisibility(
            visible = visible,
            enter = slideInVertically(
                initialOffsetY = { -it },
                animationSpec = tween(durationMillis = 300)
            ) + fadeIn(animationSpec = tween(durationMillis = 300)),
            exit = slideOutVertically(
                targetOffsetY = { -it },
                animationSpec = tween(durationMillis = 300)
            ) + fadeOut(animationSpec = tween(durationMillis = 300))
        ) {
            Card(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp)
            ) {
                Text(
                    text = "This content animates in and out!",
                    modifier = Modifier.padding(16.dp)
                )
            }
        }
    }
}

Code Breakdown

Visibility toggle state

var visible by remember { mutableStateOf(false) }
Button(onClick = { visible = !visible }) {
    Text(if (visible) "Hide" else "Show")
}

visible is a remembered Boolean state and acts as the single source of truth for this interaction. The button click toggles that state with visible = !visible, and the button text is derived from the same value using an inline if. This means the control label ("Show"/"Hide") and the actual visibility behavior stay logically synchronized without extra bookkeeping.

AnimatedVisibility enter/exit definitions

AnimatedVisibility(
    visible = visible,
    enter = slideInVertically(initialOffsetY = { -it }, ...) + fadeIn(...),
    exit = slideOutVertically(targetOffsetY = { -it }, ...) + fadeOut(...)
) { ... }

AnimatedVisibility listens to visible; when true it runs enter, and when false it runs exit. In the enter transition, slideInVertically(initialOffsetY = { -it }) starts content above its final position, where it is the content height, while fadeIn animates alpha from transparent to opaque. The + operator composes these transitions so position and opacity animate concurrently with the same timing model.

Animated content body

AnimatedVisibility(...) {
    Card { Text("This content animates in and out!") }
}

The snippet shows the animation boundary explicitly: only the card/text block inside AnimatedVisibility participates in enter/exit transitions. Elements outside that block (like the toggle button and parent column) keep their normal layout lifecycle and are not animated by this API. This separation is important because it lets you animate a target region without causing unrelated UI to shift unexpectedly.

Long Press Scale Example
@Composable
fun LongPressScaleExample() {
    var isLongPressed by remember { mutableStateOf(false) }
    val scale by animateFloatAsState(
        targetValue = if (isLongPressed) .75f else 1f, // 0.75f = 75% size
        animationSpec = tween(durationMillis = 200), label = "longPressScale"
    )
    val color by animateColorAsState(
        targetValue = if (isLongPressed) {
            Color(0xFF00FF00) // Green color for long press
        } else {
            MaterialTheme.colorScheme.primary // Default theme color
        },
        animationSpec = tween(durationMillis = 200),
        label = "longPressColor"
    )
    Card(
        modifier = Modifier
            .scale(scale) // Apply animated scale
            .pointerInput(Unit) {
                detectTapGestures(
                    onLongPress = { isLongPressed = true }, // Triggered when user holds down
                    onPress = {
                        isLongPressed = false // Reset on regular press
                        try {
                            awaitRelease() // Wait for finger to lift
                        } finally {
                            isLongPressed = false // Always reset when done
                        }
                    }
                )
            }
            .size(150.dp),
        colors = CardDefaults.cardColors(
            containerColor = color)
    ) {
        Box(
            modifier = Modifier.fillMaxSize(),
            contentAlignment = Alignment.Center
        ) {
            Text("Long press me!")
        }
    }
}

Code Breakdown

Long-press state and animated outputs

var isLongPressed by remember { mutableStateOf(false) }
val scale by animateFloatAsState(targetValue = if (isLongPressed) .75f else 1f, ...)
val color by animateColorAsState(targetValue = if (isLongPressed) Color(0xFF00FF00) else MaterialTheme.colorScheme.primary, ...)

isLongPressed captures whether the long-press condition is active, and both animation targets read that value so behavior is centralized. When true, animateFloatAsState drives scale toward 0.75f and animateColorAsState drives background toward green; when false, both return to default values. This "single state, multiple derived outputs" pattern is key in Compose because it keeps visual effects consistent and prevents state mismatch bugs.

Gesture callbacks

detectTapGestures(
    onLongPress = { isLongPressed = true },
    onPress = {
        isLongPressed = false
        try { awaitRelease() } finally { isLongPressed = false }
    }
)

onLongPress is the semantic event that activates long-press mode, so it sets isLongPressed = true once the hold threshold is reached. onPress begins on initial touch down and handles the full press lifecycle, including release/cancel, which is why it resets the state before and after awaitRelease(). The try/finally structure ensures cleanup always happens even if the gesture is interrupted, preventing stale long-press visuals.

Apply visual feedback to card

Card(
    modifier = Modifier.scale(scale),
    colors = CardDefaults.cardColors(containerColor = color)
)

The card binds animation outputs in two places: Modifier.scale(scale) applies geometric feedback to the whole component, and containerColor = color applies color feedback to the background. Because both are tied to animated state values, the transition into and out of long-press mode is gradual rather than abrupt. This makes recognition of the gesture clearer while still feeling smooth.

Interactive Slider Example
@Composable
fun InteractiveSliderExample() {
    var sliderValue by remember { mutableStateOf(0.5f) } // Start at 50%
    var isDragging by remember { mutableStateOf(false) }
    
    // Store the actual width of the slider track in pixels
    // We need this to calculate thumb position accurately
    var trackWidth by remember { mutableStateOf(0f) }
    
    val animatedValue by animateFloatAsState(
        targetValue = sliderValue,
        animationSpec = if (isDragging) {
            tween(durationMillis = 0) // No animation while dragging (instant response)
        } else {
            spring(dampingRatio = Spring.DampingRatioMediumBouncy) // Bouncy animation when released
        }, label = "sliderValue"
    )
    
    Column(
        modifier = Modifier.padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("Value: ${(animatedValue * 100).toInt()}%")
        Spacer(modifier = Modifier.height(16.dp))
        
        // This is the parent Box that contains both the track and the thumb
        Box(
            modifier = Modifier.fillMaxWidth(),
            contentAlignment = Alignment.CenterStart // Align thumb to the start
        ) {
            // Slider Track
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(8.dp)
                    .background(
                        color = MaterialTheme.colorScheme.surfaceVariant,
                        shape = MaterialTheme.shapes.small
                    )
                    .onSizeChanged { newSize ->
                        // Capture the actual width when the track is measured
                        // This happens after the layout is calculated
                        trackWidth = newSize.width.toFloat()
                    }
                    .pointerInput(trackWidth) { // Re-trigger if width changes
                        if (trackWidth == 0f) return@pointerInput // Safety check: avoid division by zero
                        detectDragGestures(
                            onDragStart = { isDragging = true }, // User started dragging
                            onDragEnd = { isDragging = false }, // User released
                            onDrag = { change, dragAmount ->
                                change.consume()
                                // Calculate how much the drag represents as a percentage
                                // dragAmount.x is pixels moved, trackWidth is total width
                                val dragPercentage = dragAmount.x / trackWidth
                                // Update slider value, keeping it between 0 and 1
                                sliderValue = (sliderValue + dragPercentage).coerceIn(0f, 1f)
                            }
                        )
                    }
            )
            
            // Slider Thumb (the draggable circle)
            Box(
                modifier = Modifier
                    .offset {
                        // Calculate thumb position: animatedValue (0-1) * trackWidth = position in pixels
                        val thumbCenterOffset = animatedValue * trackWidth
                        // Center the thumb: subtract half its width so it's centered on the position
                        val thumbHalfWidth = (24.dp.toPx() / 2)
                        IntOffset((thumbCenterOffset - thumbHalfWidth).roundToInt(), 0)
                    }
                    .size(24.dp)
                    .background(
                        color = MaterialTheme.colorScheme.primary,
                        shape = CircleShape
                    )
            )
        }
    }
}

Code Breakdown with Snippets:

Core slider state

var sliderValue by remember { mutableStateOf(0.5f) }
var isDragging by remember { mutableStateOf(false) }
var trackWidth by remember { mutableStateOf(0f) }

sliderValue stores the domain value in normalized form (0 to 1), which makes it easy to convert between percentage text and pixel position. isDragging tracks interaction mode so animation behavior can switch between "direct-follow" and "settle" modes. trackWidth is measured at runtime because track size depends on device/layout, and drag deltas must be divided by this width to produce accurate value changes.

Conditional animation policy

val animatedValue by animateFloatAsState(
    targetValue = sliderValue,
    animationSpec = if (isDragging) tween(durationMillis = 0) else spring(...)
)

This animation line uses one target (sliderValue) with two possible specs. While dragging, tween(durationMillis = 0) effectively disables interpolation so thumb/value stay locked to finger movement with no lag. After release, the spring spec adds eased settling, which removes abrupt stop behavior and gives a more natural finish.

Track measurement and drag conversion

.onSizeChanged { newSize -> trackWidth = newSize.width.toFloat() }
.pointerInput(trackWidth) {
    detectDragGestures(
        onDrag = { change, dragAmount ->
            change.consume()
            val dragPercentage = dragAmount.x / trackWidth
            sliderValue = (sliderValue + dragPercentage).coerceIn(0f, 1f)
        }
    )
}

onSizeChanged captures the actual pixel width once layout is measured, and pointerInput(trackWidth) rebuilds the gesture detector if width changes. During drag, dragAmount.x (pixels moved this frame) is divided by trackWidth to compute fractional value change, then added to the current value. coerceIn(0f, 1f) enforces valid range so the thumb cannot move beyond the visual track.

Thumb position calculation

.offset {
    val thumbCenterOffset = animatedValue * trackWidth
    val thumbHalfWidth = (24.dp.toPx() / 2)
    IntOffset((thumbCenterOffset - thumbHalfWidth).roundToInt(), 0)
}

animatedValue * trackWidth converts normalized value back into a pixel coordinate along the track. Because offset positions the top-left corner of the thumb, the code subtracts half the thumb diameter (24.dp.toPx() / 2) so the thumb center, not its edge, marks the current value. Final conversion to IntOffset gives the layout engine integer pixel coordinates for placement.

Multi-Gesture Card Example
@Composable
fun MultiGestureCardExample() {
    var scale by remember { mutableStateOf(1f) }
    var rotation by remember { mutableStateOf(0f) }
    var offset by remember { mutableStateOf(Offset.Zero) }
    val animatedScale by animateFloatAsState(
        targetValue = scale,
        animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), label = "multiScale"
    )
    val animatedRotation by animateFloatAsState(
        targetValue = rotation,
        animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), label = "multiRotation"
    )
    val animatedOffset by animateOffsetAsState(
        targetValue = offset,
        animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy), label = "multiOffset"
    )
    Card(
        modifier = Modifier
            .offset { IntOffset(animatedOffset.x.roundToInt(), animatedOffset.y.roundToInt()) }
            .scale(animatedScale)
            .rotate(animatedRotation)
            .pointerInput(Unit) {
                detectTransformGestures { _, pan, zoom, rotationChange ->
                    scale *= zoom
                    rotation += rotationChange
                    offset += pan
                }
            }
            .size(200.dp)
    ) {
        Box(
            modifier = Modifier.fillMaxSize(),
            contentAlignment = Alignment.Center
        ) {
            Text("Pinch, rotate, and drag me!")
        }
    }
}

Code Breakdown with Snippets:

Independent transform states

var scale by remember { mutableStateOf(1f) }
var rotation by remember { mutableStateOf(0f) }
var offset by remember { mutableStateOf(Offset.Zero) }

The snippet separates transform concerns into three independent state variables: scale for zoom factor, rotation for angle, and offset for translation. Keeping these separate avoids coupling math across unrelated transformations and makes gesture updates easier to reason about. Each gesture delta updates only the property it belongs to.

Animated transform outputs

val animatedScale by animateFloatAsState(targetValue = scale, animationSpec = spring(...))
val animatedRotation by animateFloatAsState(targetValue = rotation, animationSpec = spring(...))
val animatedOffset by animateOffsetAsState(targetValue = offset, animationSpec = spring(...))

Each animate*AsState call wraps one transform channel and interpolates toward the latest target value. This is important in transform gestures because pan/zoom/rotation can update at high frequency and sometimes uneven intervals. Spring animation smooths these updates so the card feels stable rather than jittery.

Multi-touch gesture update logic

detectTransformGestures { _, pan, zoom, rotationChange ->
    scale *= zoom
    rotation += rotationChange
    offset += pan
}

detectTransformGestures emits four values per frame; this snippet uses three of them: pan, zoom, and rotationChange. Zoom is applied multiplicatively (scale *= zoom) because zoom is a ratio around 1.0, while rotation and translation are accumulated additively because they are incremental deltas. This preserves mathematically correct behavior for continuous multi-touch transformations.

Apply transforms to card

Modifier
    .offset { IntOffset(animatedOffset.x.roundToInt(), animatedOffset.y.roundToInt()) }
    .scale(animatedScale)
    .rotate(animatedRotation)

The modifier chain binds the animated transform values back to rendering: offset sets position, scale sets size ratio, and rotate sets angle. Because the values are animated versions of the raw state, the visual card trails rapid gesture updates smoothly instead of snapping. Applying all transforms on the same element allows pinch, rotate, and pan to compose into a single continuous interaction.

Rearrange Example
@Composable
fun RearrangeExample() {
    var items by remember { mutableStateOf((1..5).map { "Box $it" }) }
    var draggedIndex by remember { mutableStateOf<Int?>(null) }
    var dragOffset by remember { mutableStateOf(Offset.Zero) }
    val boxHeight = 80.dp
    val density = LocalDensity.current
    
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        items.forEachIndexed { index, item ->
            val isDragging = draggedIndex == index
            val animatedOffset by animateOffsetAsState(
                targetValue = if (isDragging) dragOffset else Offset.Zero,
                animationSpec = if (isDragging) {
                    tween(durationMillis = 0)
                } else {
                    spring(dampingRatio = Spring.DampingRatioMediumBouncy)
                }, label = "rearrangeOffset"
            )
            val scale by animateFloatAsState(
                targetValue = if (isDragging) 1.05f else 1f,
                animationSpec = tween(durationMillis = 200), label = "rearrangeScale"
            )
            val alpha by animateFloatAsState(
                targetValue = if (isDragging) 0.8f else 1f,
                animationSpec = tween(durationMillis = 200), label = "rearrangeAlpha"
            )
            
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .height(boxHeight)
                    .padding(vertical = 4.dp)
                    .offset { IntOffset(animatedOffset.x.roundToInt(), animatedOffset.y.roundToInt()) }
                    .scale(scale)
                    .alpha(alpha)
                    .shadow(
                        elevation = if (isDragging) 8.dp else 2.dp,
                        shape = MaterialTheme.shapes.medium
                    )
                    .background(
                        color = MaterialTheme.colorScheme.primaryContainer,
                        shape = MaterialTheme.shapes.medium
                    )
                    .pointerInput(index) {
                        detectDragGestures(
                            onDragStart = {
                                draggedIndex = index
                                dragOffset = Offset.Zero
                            },
                            onDragEnd = {
                                val boxHeightPx = with(density) { boxHeight.toPx() }
                                val newIndex = when {
                                    dragOffset.y < -boxHeightPx / 2 && index > 0 -> index - 1
                                    dragOffset.y > boxHeightPx / 2 && index < items.size - 1 -> index + 1
                                    else -> index
                                }
                                
                                if (newIndex != index) {
                                    val newItems = items.toMutableList()
                                    val itemToMove = newItems.removeAt(index)
                                    newItems.add(newIndex, itemToMove)
                                    items = newItems
                                }
                                
                                draggedIndex = null
                                dragOffset = Offset.Zero
                            },
                            onDrag = { change, dragAmount ->
                                change.consume()
                                dragOffset += Offset(dragAmount.x, dragAmount.y)
                            }
                        )
                    },
                contentAlignment = Alignment.Center
            ) {
                Text(
                    text = item,
                    style = MaterialTheme.typography.titleMedium,
                    color = MaterialTheme.colorScheme.onPrimaryContainer
                )
            }
        }
    }
}

Code Breakdown with Snippets:

Reorder state

var items by remember { mutableStateOf((1..5).map { "Box $it" }) }
var draggedIndex by remember { mutableStateOf<Int?>(null) }
var dragOffset by remember { mutableStateOf(Offset.Zero) }

items is the current ordered dataset shown on screen, and reordering works by creating/reassigning this list. draggedIndex identifies which row is currently being moved (or null when none), and dragOffset stores cumulative drag distance for that active row. These three values together define both list structure and in-progress interaction state.

Per-item drag styling and position

val isDragging = draggedIndex == index
val animatedOffset by animateOffsetAsState(targetValue = if (isDragging) dragOffset else Offset.Zero, ...)
val scale by animateFloatAsState(targetValue = if (isDragging) 1.05f else 1f, ...)
val alpha by animateFloatAsState(targetValue = if (isDragging) 0.8f else 1f, ...)

isDragging = draggedIndex == index computes row-local drag state inside the loop, so each row can render differently based on whether it is active. The animated offset drives movement for the active row, while animated scale/alpha add visual emphasis so users can track which row is being moved. Inactive rows evaluate the same expressions but resolve to default targets, so they remain stable.

Drag lifecycle and threshold decision

onDragEnd = {
    val boxHeightPx = with(density) { boxHeight.toPx() }
    val newIndex = when {
        dragOffset.y < -boxHeightPx / 2 && index > 0 -> index - 1
        dragOffset.y > boxHeightPx / 2 && index < items.size - 1 -> index + 1
        else -> index
    }
}

At drag end, boxHeight is converted from dp to pixels because drag distances are reported in pixels. The when block checks whether vertical movement crossed half-row threshold upward or downward, which prevents accidental reorder from tiny finger jitter. Boundary checks (index > 0 and index < items.size - 1) stop moves beyond first/last positions.

Immutable reorder update

val newItems = items.toMutableList()
val itemToMove = newItems.removeAt(index)
newItems.add(newIndex, itemToMove)
items = newItems

The code copies the current list to a mutable working list, removes the dragged item from its old index, and inserts it at the new index. Reassigning items = newItems publishes a new list instance to state, which Compose observes as a change and recomposes the rows in new order. This immutable-style state update pattern is more reliable than mutating a shared list in place.

Tile Swap Example
@Composable
fun TileSwapExample() {
    var tiles by remember { mutableStateOf(listOf(1, 2, 3, 0)) } // 0 represents empty space
    var draggedIndex by remember { mutableStateOf<Int?>(null) }
    var dragOffset by remember { mutableStateOf(Offset.Zero) }
    val gridSize = 2 // 2x2 grid
    
    Column(
        modifier = Modifier.fillMaxWidth(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        Text(
            text = "Drag tiles to swap with empty space",
            style = MaterialTheme.typography.bodyMedium,
            modifier = Modifier.padding(bottom = 16.dp)
        )
        
        // 2x2 Grid layout
        Column(
            verticalArrangement = Arrangement.spacedBy(8.dp)
        ) {
            repeat(gridSize) { row ->
                Row(
                    horizontalArrangement = Arrangement.spacedBy(8.dp)
                ) {
                    repeat(gridSize) { col ->
                        val index = row * gridSize + col
                        val tileValue = tiles[index]
                        val isDragging = draggedIndex == index
                        
                        val animatedOffset by animateOffsetAsState(
                            targetValue = if (isDragging) dragOffset else Offset.Zero,
                            animationSpec = if (isDragging) {
                                tween(durationMillis = 0)
                            } else {
                                spring(dampingRatio = Spring.DampingRatioMediumBouncy)
                            }, label = "tileOffset"
                        )
                        
                        val scale by animateFloatAsState(
                            targetValue = if (isDragging) 1.1f else 1f,
                            animationSpec = tween(durationMillis = 200), label = "tileScale"
                        )
                        
                        val alpha by animateFloatAsState(
                            targetValue = if (isDragging) 0.8f else 1f,
                            animationSpec = tween(durationMillis = 200), label = "tileAlpha"
                        )
                        
                        if (tileValue == 0) {
                            // Empty space
                            Box(
                                modifier = Modifier
                                    .size(100.dp)
                                    .background(
                                        color = MaterialTheme.colorScheme.surfaceVariant,
                                        shape = RoundedCornerShape(12.dp)
                                    )
                            )
                        } else {
                            // Tile with number
                            Card(
                                modifier = Modifier
                                    .size(100.dp)
                                    .offset { IntOffset(animatedOffset.x.roundToInt(), animatedOffset.y.roundToInt()) }
                                    .scale(scale)
                                    .alpha(alpha)
                                    .shadow(
                                        elevation = if (isDragging) 8.dp else 4.dp,
                                        shape = RoundedCornerShape(12.dp)
                                    )
                                    .pointerInput(index) {
                                        detectDragGestures(
                                            onDragStart = {
                                                draggedIndex = index
                                                dragOffset = Offset.Zero
                                            },
                                            onDragEnd = {
                                                val emptyIndex = tiles.indexOf(0)
                                                
                                                if (isAdjacent(index, emptyIndex, gridSize)) {
                                                    val newTiles = tiles.toMutableList()
                                                    newTiles[emptyIndex] = tiles[index]
                                                    newTiles[index] = 0
                                                    tiles = newTiles
                                                }
                                                
                                                draggedIndex = null
                                                dragOffset = Offset.Zero
                                            },
                                            onDrag = { change, dragAmount ->
                                                change.consume()
                                                dragOffset += Offset(dragAmount.x, dragAmount.y)
                                            }
                                        )
                                    },
                                colors = CardDefaults.cardColors(
                                    containerColor = MaterialTheme.colorScheme.primaryContainer
                                ),
                                shape = RoundedCornerShape(12.dp)
                            ) {
                                Box(
                                    modifier = Modifier.fillMaxSize(),
                                    contentAlignment = Alignment.Center
                                ) {
                                    Text(
                                        text = tileValue.toString(),
                                        style = MaterialTheme.typography.headlineMedium,
                                        color = MaterialTheme.colorScheme.onPrimaryContainer,
                                        fontWeight = FontWeight.Bold
                                    )
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}

private fun isAdjacent(index1: Int, index2: Int, gridSize: Int): Boolean {
    val row1 = index1 / gridSize
    val col1 = index1 % gridSize
    val row2 = index2 / gridSize
    val col2 = index2 % gridSize
    
    return (row1 == row2 && kotlin.math.abs(col1 - col2) == 1) ||
           (col1 == col2 && kotlin.math.abs(row1 - row2) == 1)
}

Code Breakdown with Snippets:

Puzzle state and grid mapping

var tiles by remember { mutableStateOf(listOf(1, 2, 3, 0)) } // 0 = empty
val gridSize = 2
val index = row * gridSize + col

tiles stores the puzzle in row-major order as a flat list, with 0 reserved to represent the empty space. gridSize defines how that one-dimensional data should be interpreted as a square grid. The mapping formula row * gridSize + col converts visual row/column coordinates into the correct list index for reading/updating tile values.

Render empty space vs numbered tile

if (tileValue == 0) {
    Box(modifier = Modifier.size(100.dp) /* empty slot */)
} else {
    Card(modifier = Modifier.size(100.dp) /* draggable tile */) { ... }
}

The conditional split is a rules-enforcement step: if value is 0, render only a visual placeholder box; if value is non-zero, render an interactive card tile. By not attaching drag handlers to the empty slot, the code ensures only numbered tiles can be dragged. This mirrors the behavior of real sliding puzzles, where movement always starts from a numbered tile.

Drag end swap logic

val emptyIndex = tiles.indexOf(0)
if (isAdjacent(index, emptyIndex, gridSize)) {
    val newTiles = tiles.toMutableList()
    newTiles[emptyIndex] = tiles[index]
    newTiles[index] = 0
    tiles = newTiles
}

On drag end, tiles.indexOf(0) finds the empty cell at that moment, then isAdjacent(...) validates whether the dragged tile is a legal neighbor. Only when legal does the code create a mutable copy and perform the two assignments needed for a swap: move dragged value into empty index and write 0 into the old tile position. Assigning the updated list back to tiles commits the move and refreshes the board UI.

Adjacency function

return (row1 == row2 && abs(col1 - col2) == 1) ||
       (col1 == col2 && abs(row1 - row2) == 1)

This return expression encodes Manhattan adjacency: same row and one column apart, or same column and one row apart. Diagonal positions fail both conditions, so they are correctly treated as non-adjacent. Encoding adjacency this way keeps move validation concise while still matching puzzle movement rules exactly.