Gestures and Animations
What are Gestures and Animations?
Think of gestures and animations like the way you interact with a physical book. When you flip a page, you use a swipe gesture, and the page animates as it turns. When you tap on a button, it might animate to show it was pressed. Gestures are the ways users interact with your app using touch, and animations are the visual responses that make those interactions feel natural and engaging.
Gestures include tapping, swiping, dragging, pinching, and more. Animations include transitions, scaling, fading, and moving elements. Together, they create a smooth, intuitive user experience that feels responsive and polished.
Quick Reference
| Type | Description | When to Use |
|---|---|---|
| Tap Gestures | Single finger tap on screen | Buttons, list items, navigation |
| Swipe Gestures | Finger drag across screen | Navigation, deletion, scrolling |
| Drag Gestures | Long press and move | Reordering, dragging items |
| Scale Gestures | Two finger pinch or spread | Zooming images, maps |
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. Therememberfunction ensures this state persists across recompositions, andmutableStateOfcreates a mutable state that triggers recomposition when changed. - Scale Animation:
animateFloatAsStatecreates an animated float value that smoothly transitions from its current value to the target value. WhenisPressedis true, the target is 0.95f (95% scale), otherwise 1f (100% scale). Thetweenanimation spec creates a linear interpolation over 100 milliseconds. Thelabelparameter helps with debugging and performance profiling. - Color Animation:
animateColorAsStateworks similarly toanimateFloatAsStatebut animates between Color values. When pressed, it transitions fromprimaryto red (Color(0xFFFF0000)), providing visual feedback that the button is active. - Gesture Detection: The
pointerInput(Unit)modifier attaches gesture detection to the Card. TheUnitkey means this input handler is created once and doesn't depend on any changing values.detectTapGesturesprovides callbacks for various tap events, but we only useonPress. - Press Handling: Inside
onPress, we setisPressed = trueimmediately. TheawaitRelease()function suspends execution until the user lifts their finger. We wrap it in a try-finally block to ensureisPressedis 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. ThecontainerColoruses the animatedbuttonColor, 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:
SwipeToDeleteExamplemaintains a list of items usingremember { 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 usingitems.remove(item), which triggers recomposition. - SwipeToDismissBoxState:
rememberSwipeToDismissBoxStatemanages the swipe state for each item. TheconfirmValueChangelambda is called whenever the swipe value changes. It checks if the swipe direction isEndToStart(right-to-left in LTR layouts), and if so, callsonDelete()and returns true to confirm the dismissal. Returning false prevents the dismissal. - Background Content: The
backgroundContentparameter defines what appears behind the item when swiping. Here, a red Box with a delete icon is shown. ThecontentAlignment = Alignment.CenterEndpositions the icon at the trailing edge (right side in LTR). - Item Content: The lambda after
SwipeToDismissBoxcontains 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 becauseSwipeToDismissBoxis 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.Zerorepresents no offset (the card's starting position). - Animated Offset:
animateOffsetAsStatecreates an animated Offset that smoothly transitions to the target value. Thespringanimation spec creates a bouncy, physics-based animation.DampingRatioMediumBouncyprovides noticeable bounce, whileStiffnessLowmakes the animation slower and more elastic. - Drag Gesture Detection:
detectDragGesturesprovides a lambda that receiveschange(information about the pointer event) anddragAmount(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. TheanimatedOffsetensures 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 = !visibletoggles this state. - AnimatedVisibility: This composable automatically animates its content when the
visibleprop changes. Whenvisiblebecomes true, it triggers the enter animation; when false, it triggers the exit animation. - Enter Animation:
slideInVerticallyanimates the content sliding in from above. The lambda{ -it }means the initial offset is negative (above the final position), whereitrepresents the height of the content. Combined withfadeInusing the+operator, the content both slides and fades in simultaneously. - Exit Animation:
slideOutVerticallywith{ -it }slides the content upward (negative offset) as it exits. Combined withfadeOut, 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:
animateFloatAsStateanimates the scale from 1f (normal) to 0.75f (75% size) whenisLongPressedis true. The card shrinks when long pressed, providing visual feedback. The 200ms duration provides quick but smooth feedback. - Color Animation:
animateColorAsStateanimates 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:
detectTapGesturesprovides two callbacks:onLongPressfires when the user holds down for the long press duration (typically around 500ms), andonPressfires for any press (including short taps). - Press Handling: When
onPressis called (any press), we immediately setisLongPressed = falseto reset the state. Then we callawaitRelease()to wait for the user to lift their finger. Thefinallyblock ensuresisLongPressedis reset even if something goes wrong. - Long Press Behavior: When
onLongPressfires, it setsisLongPressed = 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
onPressfires for all presses (including long presses), whileonLongPressonly 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:
animateFloatAsStateuses different animation specs based onisDragging. When dragging (isDragging = true), it usestween(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
surfaceVariantcolor for a subtle background appearance. TheonSizeChangedmodifier captures the actual width of the track after layout, storing it intrackWidth. - 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 usestrackWidthas 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:
onDragStartsetsisDragging = truewhen the user begins dragging.onDragEndsets it back to false when they release.onDragis called continuously during the drag. - Value Calculation: In
onDrag,val dragPercentage = dragAmount.x / trackWidthcalculates the percentage of the track that was dragged. We then updatesliderValue = (sliderValue + dragPercentage).coerceIn(0f, 1f)to add this percentage to the current value, keeping it between 0 and 1. Using the dynamictrackWidthinstead 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 * trackWidthconverts the 0-1 value to pixels.val thumbHalfWidth = (24.dp.toPx() / 2)gets half the thumb size to center it properly. The final offset isIntOffset((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), andoffset(position). Each needs its own state because they're independent transformations. - Separate Animations: Each transformation has its own
animate*AsStatecall:animateFloatAsStatefor scale and rotation,animateOffsetAsStatefor position. This allows each to animate independently with spring physics. - Transform Gestures:
detectTransformGesturesdetects multi-touch gestures. The lambda receives four parameters:centroid(center point of touches),pan(translation movement),zoom(scale factor), androtationChange(rotation delta in radians). - Multiplicative Updates:
scale *= zoommultiplies 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 += rotationChangeadds the rotation delta (converting from radians to degrees would require additional conversion). - Pan Movement:
offset += panadds 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:
draggedIndexstores which item is being dragged (null when none).dragOffsettracks 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.currentprovides 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 == indexchecks if this specific item is being dragged. Each item animates independently based on its own state. - Conditional Animation: For
animatedOffset, when dragging we usetween(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
animateFloatAsStatefor smooth transitions. - Drag Start:
onDragStartsetsdraggedIndex = indexto mark this item as being dragged, and resetsdragOffsetto zero. This happens once when dragging begins. - Drag Update:
onDragis 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 convertboxHeightto pixels usingwith(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 toitems, triggering recomposition. - Cleanup: After reordering (or if no reorder occurred), we reset
draggedIndexto null anddragOffsetto 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
repeatloops: outerrepeat(gridSize)creates rows, innerrepeat(gridSize)creates columns. For each position, we calculate the index usingindex = row * gridSize + col. This converts 2D coordinates (row, col) to a 1D index. - Index to Position: The formula
row = index / gridSizeandcol = index % gridSizeconverts 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
draggedIndexanddragOffset. Each tile checks if it's being dragged withisDragging = 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
isAdjacentfunction 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 withtiles.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,
animatedOffsetusestween(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
gridSizefrom 2 to 3, you'd get a 3x3 grid (like the Sliding Numbers puzzle). The adjacency logic and index calculations scale automatically.
How the Examples Render
I created one example that combines all the custom components into one screen. You can view the full code on my GitHub page and look at the chapter14 gestures.kt file.
This is the rended application. It uses a scrollable tabbed navigation to show the different examples.
Tips for Success
- Use gestures to provide intuitive ways to interact with your app
- Provide visual feedback for all user interactions
- Keep animations smooth and not too distracting
- Use appropriate animation durations (100-300ms for quick feedback)
- Test gestures on different screen sizes and devices
- Provide alternative ways to perform actions (accessibility)
- Use spring animations for natural, bouncy feel
- Consider the user's context when choosing gesture types
Common Mistakes to Avoid
- Making gestures too complex or hard to discover
- Using animations that are too slow or distracting
- Not providing visual feedback for gesture interactions
- Forgetting to handle edge cases in gesture detection
- Using gestures that conflict with system gestures
- Not considering accessibility for users who can't use gestures
- Making animations that interfere with usability
- Not testing gestures on different devices and screen sizes
Best Practices
- Use standard gestures that users expect (tap, swipe, pinch)
- Provide clear visual feedback for all interactions
- Keep animations smooth and performant
- Use appropriate animation curves and durations
- Test gestures thoroughly on different devices
- Provide alternative interaction methods for accessibility
- Follow platform guidelines for gesture behavior
- Consider the user's mental model when designing interactions