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
            )
        }
    }
}

Detailed Explanation:

  • State Management: var isPressed by remember { mutableStateOf(false) } creates a state variable that tracks whether the button is currently being pressed. The remember function ensures this state persists across recompositions, and mutableStateOf creates a mutable state that triggers recomposition when changed.
  • Scale Animation: animateFloatAsState creates an animated float value that smoothly transitions from its current value to the target value. When isPressed is true, the target is 0.95f (95% scale), otherwise 1f (100% scale). The tween animation spec creates a linear interpolation over 100 milliseconds. The label parameter helps with debugging and performance profiling.
  • Color Animation: animateColorAsState works similarly to animateFloatAsState but animates between Color values. When pressed, it transitions from primary to red (Color(0xFFFF0000)), providing visual feedback that the button is active.
  • Gesture Detection: The pointerInput(Unit) modifier attaches gesture detection to the Card. The Unit key means this input handler is created once and doesn't depend on any changing values. detectTapGestures provides callbacks for various tap events, but we only use onPress.
  • Press Handling: Inside onPress, we set isPressed = true immediately. The awaitRelease() function suspends execution until the user lifts their finger. We wrap it in a try-finally block to ensure isPressed is always reset to false, even if an exception occurs. This ensures the button always returns to its normal state.
  • Visual Feedback: The .scale(scale) modifier applies the animated scale value to the entire Card, making it shrink when pressed. The containerColor uses the animated buttonColor, so the color transitions smoothly during the press and release.
Swipe to Delete Example
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SwipeToDeleteExample() {
    var items by remember { mutableStateOf((1..3).map { "Item $it" }.toMutableList()) }
    Column {
        items.forEach { item ->
            SwipeableListItem(item = item, onDelete = { items.remove(item) })
            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) }
            )
        }
    }
}

Detailed Explanation:

  • List Management: SwipeToDeleteExample maintains a list of items using remember { mutableStateOf(...) }. The list is created using Kotlin's range operator (1..3) to generate "Item 1", "Item 2", and "Item 3". When an item is deleted, the list is modified directly using items.remove(item), which triggers recomposition.
  • SwipeToDismissBoxState: rememberSwipeToDismissBoxState manages the swipe state for each item. The confirmValueChange lambda is called whenever the swipe value changes. It checks if the swipe direction is EndToStart (right-to-left in LTR layouts), and if so, calls onDelete() and returns true to confirm the dismissal. Returning false prevents the dismissal.
  • Background Content: The backgroundContent parameter defines what appears behind the item when swiping. Here, a red Box with a delete icon is shown. The contentAlignment = Alignment.CenterEnd positions the icon at the trailing edge (right side in LTR).
  • Item Content: The lambda after SwipeToDismissBox contains the actual item content (the Card with ListItem). This content slides over the background when swiped.
  • Experimental API: The @OptIn(ExperimentalMaterial3Api::class) annotation is required because SwipeToDismissBox is an experimental Material3 API. This tells the compiler that you're aware you're using an experimental feature.
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!")
        }
    }
}

Detailed Explanation:

  • Offset Tracking: var offset by remember { mutableStateOf(Offset.Zero) } stores the current position offset from the card's original position. Offset.Zero represents no offset (the card's starting position).
  • Animated Offset: animateOffsetAsState creates an animated Offset that smoothly transitions to the target value. The spring animation spec creates a bouncy, physics-based animation. DampingRatioMediumBouncy provides noticeable bounce, while StiffnessLow makes the animation slower and more elastic.
  • Drag Gesture Detection: detectDragGestures provides a lambda that receives change (information about the pointer event) and dragAmount (the distance moved since the last event).
  • Consuming Events: change.consume() marks the event as consumed, preventing other gesture handlers from processing it. This is important to avoid conflicts with other gestures.
  • Updating Position: offset += Offset(dragAmount.x, dragAmount.y) accumulates the drag amounts. Each time the user moves their finger, we add the movement delta to the current offset. This makes the card follow the finger.
  • Applying Offset: The .offset { IntOffset(...) } modifier applies the animated offset to the Card. We convert the Float offset to IntOffset by rounding, since UI positions must be integers. The animatedOffset ensures smooth animation when the drag ends, even if the user releases their finger mid-drag.
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)
                )
            }
        }
    }
}

Detailed Explanation:

  • Visibility State: var visible by remember { mutableStateOf(false) } controls whether the content is shown or hidden. When the button is clicked, visible = !visible toggles this state.
  • AnimatedVisibility: This composable automatically animates its content when the visible prop changes. When visible becomes true, it triggers the enter animation; when false, it triggers the exit animation.
  • Enter Animation: slideInVertically animates the content sliding in from above. The lambda { -it } means the initial offset is negative (above the final position), where it represents the height of the content. Combined with fadeIn using the + operator, the content both slides and fades in simultaneously.
  • Exit Animation: slideOutVertically with { -it } slides the content upward (negative offset) as it exits. Combined with fadeOut, it creates a smooth disappearing effect.
  • Animation Spec: Both animations use tween(durationMillis = 300), which creates a linear interpolation over 300 milliseconds. This provides a smooth, predictable animation timing.
  • Combining Animations: The + operator combines multiple animation effects. You can chain multiple animations together to create complex effects like sliding while fading and rotating simultaneously.
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!")
        }
    }
}

Detailed Explanation:

  • Long Press State: var isLongPressed by remember { mutableStateOf(false) } tracks whether a long press is currently active. This is separate from a regular tap - a long press requires holding for a longer duration.
  • Scale Animation: animateFloatAsState animates the scale from 1f (normal) to 0.75f (75% size) when isLongPressed is true. The card shrinks when long pressed, providing visual feedback. The 200ms duration provides quick but smooth feedback.
  • Color Animation: animateColorAsState animates the color from the default primary color to green (Color(0xFF00FF00)) when long pressed. This provides additional visual feedback that the long press gesture was recognized.
  • Gesture Callbacks: detectTapGestures provides two callbacks: onLongPress fires when the user holds down for the long press duration (typically around 500ms), and onPress fires for any press (including short taps).
  • Press Handling: When onPress is called (any press), we immediately set isLongPressed = false to reset the state. Then we call awaitRelease() to wait for the user to lift their finger. The finally block ensures isLongPressed is reset even if something goes wrong.
  • Long Press Behavior: When onLongPress fires, it sets isLongPressed = true, which triggers both the scale and color animations. The card visually shrinks and changes to green to indicate the long press was detected. This provides clear feedback that the gesture was recognized.
  • Why Both Callbacks: We need both callbacks because onPress fires for all presses (including long presses), while onLongPress only fires for long presses. This allows us to reset the state properly regardless of how the press ends.
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
                    )
            )
        }
    }
}

Detailed Explanation:

  • Slider State: var sliderValue by remember { mutableStateOf(0.5f) } stores the slider's value as a float between 0 and 1. Starting at 0.5f means the slider begins at 50%.
  • Dragging State: var isDragging by remember { mutableStateOf(false) } tracks whether the user is currently dragging. This is crucial for controlling animation behavior.
  • Track Width State: var trackWidth by remember { mutableStateOf(0f) } stores the actual measured width of the slider track in pixels. This is essential because the track width can vary based on screen size, so we can't use a hardcoded value.
  • Conditional Animation: animateFloatAsState uses different animation specs based on isDragging. When dragging (isDragging = true), it uses tween(durationMillis = 0) which means no animation - the value updates instantly. When not dragging, it uses a spring animation for smooth movement. This prevents lag during dragging while still providing smooth animation on release.
  • Track Creation: The outer Box creates the slider track with a fixed height (8dp) and full width. It uses surfaceVariant color for a subtle background appearance. The onSizeChanged modifier captures the actual width of the track after layout, storing it in trackWidth.
  • Dynamic Width Measurement: onSizeChanged { newSize -> trackWidth = newSize.width.toFloat() } is called after the layout phase, giving us the actual pixel width of the track. This is necessary because the width depends on the screen size and padding, which we can't know at compile time.
  • Pointer Input Key: The pointerInput(trackWidth) modifier uses trackWidth as a key, meaning the gesture handler will be recreated whenever the track width changes. This ensures the gesture detection always uses the correct width value.
  • Drag Gesture Handlers: onDragStart sets isDragging = true when the user begins dragging. onDragEnd sets it back to false when they release. onDrag is called continuously during the drag.
  • Value Calculation: In onDrag, val dragPercentage = dragAmount.x / trackWidth calculates the percentage of the track that was dragged. We then update sliderValue = (sliderValue + dragPercentage).coerceIn(0f, 1f) to add this percentage to the current value, keeping it between 0 and 1. Using the dynamic trackWidth instead of a hardcoded value makes the slider work correctly on any screen size.
  • Thumb Position: The thumb uses .offset { ... } with a lambda that calculates the position dynamically. val thumbCenterOffset = animatedValue * trackWidth converts the 0-1 value to pixels. val thumbHalfWidth = (24.dp.toPx() / 2) gets half the thumb size to center it properly. The final offset is IntOffset((thumbCenterOffset - thumbHalfWidth).roundToInt(), 0), which centers the thumb on the calculated position.
  • Display: The Text shows the current value as a percentage: (animatedValue * 100).toInt() converts 0-1 to 0-100.
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!")
        }
    }
}

Detailed Explanation:

  • Multiple State Variables: Three separate state variables track different transformations: scale (zoom factor), rotation (angle in degrees), and offset (position). Each needs its own state because they're independent transformations.
  • Separate Animations: Each transformation has its own animate*AsState call: animateFloatAsState for scale and rotation, animateOffsetAsState for position. This allows each to animate independently with spring physics.
  • Transform Gestures: detectTransformGestures detects multi-touch gestures. The lambda receives four parameters: centroid (center point of touches), pan (translation movement), zoom (scale factor), and rotationChange (rotation delta in radians).
  • Multiplicative Updates: scale *= zoom multiplies the current scale by the zoom factor. This is multiplicative (not additive) because zoom is typically a ratio (e.g., 1.1x means 10% larger). Similarly, rotation += rotationChange adds the rotation delta (converting from radians to degrees would require additional conversion).
  • Pan Movement: offset += pan adds the pan movement to the current offset. This allows dragging the card while it's being scaled or rotated.
  • Modifier Order: The order of modifiers matters: .offset, then .scale, then .rotate. Transformations are applied in reverse order of how they appear in code. So rotation happens first, then scaling, then translation. This order creates the expected visual result.
  • Spring Animation: All animations use spring physics with DampingRatioMediumBouncy, creating a natural, bouncy feel. When the user releases, all transformations smoothly settle with spring physics.
  • Label Parameters: Each animation has a unique label ("multiScale", "multiRotation", "multiOffset") which helps with debugging and performance profiling, especially important when multiple animations are running simultaneously.
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
                )
            }
        }
    }
}

Detailed Explanation:

  • List State: var items by remember { mutableStateOf((1..5).map { "Box $it" }) } creates an immutable list of strings. When reordering, we create a new list rather than modifying the existing one, which is important for Compose's state management.
  • Drag Tracking: draggedIndex stores which item is being dragged (null when none). dragOffset tracks the cumulative drag distance. These are separate because we need to know both which item is moving and how far it's moved.
  • LocalDensity: LocalDensity.current provides the density context needed to convert dp to pixels. Android devices have different screen densities, so 80dp might be 160px on one device and 240px on another. We need pixels for accurate distance calculations.
  • Per-Item Animations: Inside forEachIndexed, each item gets its own animation states. val isDragging = draggedIndex == index checks if this specific item is being dragged. Each item animates independently based on its own state.
  • Conditional Animation: For animatedOffset, when dragging we use tween(durationMillis = 0) (instant updates) so the item follows the finger precisely. When not dragging, we use spring animation for smooth repositioning.
  • Visual Feedback: Three separate animations provide feedback: scale (1.05f = 105%), alpha (0.8f = 80% opacity), and shadow elevation (8dp vs 2dp). These all use animateFloatAsState for smooth transitions.
  • Drag Start: onDragStart sets draggedIndex = index to mark this item as being dragged, and resets dragOffset to zero. This happens once when dragging begins.
  • Drag Update: onDrag is called continuously during dragging. dragOffset += Offset(dragAmount.x, dragAmount.y) accumulates the movement. change.consume() prevents other gesture handlers from processing the event.
  • Position Calculation: In onDragEnd, we convert boxHeight to pixels using with(density) { boxHeight.toPx() }. We check if the drag distance exceeds half the box height (boxHeightPx / 2). If dragged up more than half a box height and not at the top, move up one position. If dragged down more than half a box height and not at the bottom, move down one position.
  • List Reordering: When reordering, we create a mutable copy with toMutableList(), remove the item at the old index, and insert it at the new index. Then we assign the new list back to items, triggering recomposition.
  • Cleanup: After reordering (or if no reorder occurred), we reset draggedIndex to null and dragOffset to zero. This ensures the next drag starts cleanly.
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)
}

Detailed Explanation:

  • Grid Representation: The tiles are stored as a flat list: listOf(1, 2, 3, 0) where 0 represents the empty space. The list represents a 2x2 grid in row-major order: [0,1] = top-left, [0,2] = top-right, [1,3] = bottom-left, [2,0] = bottom-right (empty).
  • Grid Layout: We create the visual grid using nested repeat loops: outer repeat(gridSize) creates rows, inner repeat(gridSize) creates columns. For each position, we calculate the index using index = row * gridSize + col. This converts 2D coordinates (row, col) to a 1D index.
  • Index to Position: The formula row = index / gridSize and col = index % gridSize converts back from index to row/column. Division gives the row (how many complete rows fit), modulo gives the column (remainder after removing complete rows).
  • Empty Space Handling: When tileValue == 0, we render an empty Box instead of a Card. This creates the visual empty space. The empty space doesn't have gesture handlers, so it can't be dragged.
  • Drag State: Similar to RearrangeExample, we track draggedIndex and dragOffset. Each tile checks if it's being dragged with isDragging = draggedIndex == index.
  • Visual Feedback: While dragging, tiles scale to 110%, reduce opacity to 80%, and increase shadow elevation. These provide clear visual feedback that a tile is being moved.
  • Adjacency Check: The isAdjacent function checks if two tiles are next to each other. It converts both indices to row/column coordinates, then checks if they're in the same row with adjacent columns (row1 == row2 && abs(col1 - col2) == 1) OR in the same column with adjacent rows (col1 == col2 && abs(row1 - row2) == 1).
  • Swap Logic: In onDragEnd, we find the empty space index with tiles.indexOf(0). We only swap if the dragged tile is adjacent to the empty space. To swap, we create a mutable copy, place the dragged tile's value at the empty index, and set the dragged tile's position to 0 (empty).
  • Why Adjacency Matters: This prevents tiles from "jumping" across the grid. Only adjacent tiles can swap, which is the core rule of sliding puzzles. This constraint makes the puzzle solvable and provides logical gameplay.
  • Animation Behavior: During dragging, animatedOffset uses tween(durationMillis = 0) for instant updates. When released, if no swap occurs, the tile animates back to its original position using spring animation. If a swap occurs, the list updates and Compose automatically animates the tiles to their new positions.
  • Scalability: This pattern works for any grid size. By changing gridSize from 2 to 3, you'd get a 3x3 grid (like the Sliding Numbers puzzle). The adjacency logic and index calculations scale automatically.