Swipe gestures provide a natural way to interact with elements in an app, adding intuitive controls for actions like dismissing items or revealing options. Jetpack Compose makes it easy to implement in various ways. With recent updates of the Compose libraries, new APIs make swipe-based interactions simpler and more maintainable.
In this article, we’ll explore how to implement the SwipeToDismiss and SwipeToReveal functionality and customize them for various use cases, empowering you to create dynamic, responsive UIs.
Base Implementation with detectHorizontalDragGestures
The first approach for implementing swipe-based interactions is to use detectHorizontalDragGestures, a flexible and foundational solution that allows for full customization. This method enables both SwipeToDismiss and SwipeToReveal functionalities by managing the horizontal drag manually. Below is an example of how to implement this in a composable:
@Composable fun LibraryBook( onClickRead: () -> Unit, onClickDelete: () -> Unit ) { var offsetX by remember { mutableFloatStateOf(0f) } Box( modifier .fillMaxSize() .pointerInput(Unit) { detectHorizontalDragGestures { _, dragAmount -> offsetX = (offsetX + dragAmount).coerceIn(-300f, 0f) } } ) { // Actions revealed Row( modifier = Modifier .fillMaxSize() .padding(horizontal = 16.dp), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically ) { IconButton(onClick = onClickDelete) { Icon(Icons.Default.Delete, contentDescription = "") } } // Main content Box( modifier = Modifier .offset { IntOffset(offsetX.roundToInt(), 0) } ) { InternalLibraryBook() } } }
In this implementation:
- We maintain an offsetX state to control the horizontal position of the item as it’s dragged.
- DetectHorizontalDragGestures handles horizontal dragging, updating offsetX within a specified range to prevent excessive movement.
- The main content is shifted based on offsetX, revealing the delete action as you swipe.
This approach is straightforward, but it provides the flexibility to expand and customize as needed. If you want to dive deeper into this solution, Philipp Lackner’s video provides an excellent walkthrough. Philipp shares various Compose techniques in his videos, so consider following him for more useful tips and tutorials.
Implementation with SwipeToDismissBox
With recent updates to the Compose libraries, we now have the SwipeToDismissBox, which provides a more structured and controllable approach to swipe-based interactions. This component simplifies the process of implementing dismiss gestures and offers better control over the swipe state. Here’s how it enhances the previous implementation:
@Composable fun LibraryBook2( modifier: Modifier = Modifier, onClickRead: () -> Unit, onClickDelete: () -> Unit ) { val dismissState = rememberSwipeToDismissBoxState(confirmValueChange = { when (it) { SwipeToDismissBoxValue.EndToStart -> { onClickDelete() true } SwipeToDismissBoxValue.StartToEnd -> { onClickRead() true } else -> false } }) SwipeToDismissBox( modifier = modifier, state = dismissState, backgroundContent = { Row( modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { // Read action on swipe from start to end AnimatedVisibility( visible = dismissState.targetValue == SwipeToDismissBoxValue.StartToEnd, enter = fadeIn() ) { Icon( imageVector = Icons.AutoMirrored.Outlined.MenuBook, contentDescription = "Read" ) } Spacer(modifier = Modifier.weight(1f)) // Delete action on swipe from end to start AnimatedVisibility( visible = dismissState.targetValue == SwipeToDismissBoxValue.EndToStart, enter = fadeIn() ) { Icon( imageVector = Icons.Default.Delete, contentDescription = "Delete" ) } } } ) { InternalLibraryBook() } }
In this updated example:
- SwipeToDismissBox manages the swipe state internally, which simplifies the swipe handling compared to the detectHorizontalDragGestures approach.
- The backgroundContent is displayed conditionally based on the swipe direction, using in my case, AnimatedVisibility to smoothly show icons for delete and read actions.
Resetting the Swipe Position
To reset the swipe position after an action is taken, you can leverage LaunchedEffect to monitor dismissState.currentValue and trigger a reset when a swipe is completed:
val dismissState = rememberSwipeToDismissBoxState() LaunchedEffect(dismissState.currentValue) { when (dismissState.currentValue) { SwipeToDismissBoxValue.EndToStart -> { onClickDelete() dismissState.reset() } SwipeToDismissBoxValue.StartToEnd -> { onClickRead() dismissState.reset() } else -> { /* No action needed */ } } }
Job Offers
Implementing SwipeToReveal with anchoredDraggable
The SwipeToDismissBox works well for swipe to dismiss interactions, but if we want to implement SwipeToReveal (where swiping reveals options rather than dismissing the item) we need a different approach. I found a powerful alternative with the anchoredDraggable API, as it allows us to define anchor points where specific actions can be triggered, making it ideal for reveal-based interactions.
Here’s the example of implementing SwipeToReveal with anchoredDraggable:
enum class SwipeToRevealValue { Read, Resting, Delete } @OptIn(ExperimentalFoundationApi::class) @Composable fun LibraryBook3( onClickRead: () -> Unit, onClickDelete: () -> Unit ) { val density = LocalDensity.current val decayAnimationSpec = rememberSplineBasedDecay<Float>() val dragState = remember { val actionOffset = with(density) { 100.dp.toPx() } AnchoredDraggableState( initialValue = SwipeToRevealValue.Resting, anchors = DraggableAnchors { SwipeToRevealValue.Read at -actionOffset SwipeToRevealValue.Resting at 0f SwipeToRevealValue.Delete at actionOffset }, positionalThreshold = { distance -> distance * 0.5f }, velocityThreshold = { with(density) { 100.dp.toPx() } }, snapAnimationSpec = tween(), decayAnimationSpec = decayAnimationSpec, ) } val overScrollEffect = ScrollableDefaults.overscrollEffect() Box( modifier = Modifier.fillMaxSize() ) { // Main content that moves with the swipe Box( modifier = Modifier .anchoredDraggable( dragState, Orientation.Horizontal, overscrollEffect = overScrollEffect ) .overscroll(overScrollEffect) .offset { IntOffset( x = dragState.requireOffset().roundToInt(), y = 0 ) } ) { InternalLibraryBook() } // actions for "Read" and "Delete" Row( modifier = Modifier.matchParentSize(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { // Read Action AnimatedVisibility( visible = dragState.currentValue == SwipeToRevealValue.Read, enter = slideInHorizontally(animationSpec = tween()) { it }, exit = slideOutHorizontally(animationSpec = tween()) { it } ) { IconButton(onClick = onClickRead) { Icon( imageVector = Icons.AutoMirrored.Outlined.MenuBook, contentDescription = "Read" ) } } Spacer(modifier = Modifier.weight(1f)) // Delete Action AnimatedVisibility( visible = dragState.currentValue == SwipeToRevealValue.Delete, enter = slideInHorizontally(animationSpec = tween()) { -it }, exit = slideOutHorizontally(animationSpec = tween()) { -it } ) { IconButton(onClick = onClickDelete) { Icon( imageVector = Icons.Default.Delete, contentDescription = "Delete" ) } } } } }
In this setup:
- AnchoredDraggableState allows us to set specific anchor points for different actions. Here, swiping left reveals the delete option, while swiping right reveals the read option.
- AnimatedVisibility and slideInHorizontally are used to animate the icons as they are revealed or hidden, creating a smooth interaction.
This approach work well also in the case of the swipe to dismiss interactions. In this case we need to add a LaunchedEffect to call our callbacks at the right moment:
LaunchedEffect(dragState) { snapshotFlow { dragState.settledValue } .collectLatest { when (it) { SwipeToRevealValue.Read -> onClickRead() SwipeToRevealValue.Delete -> onClickDelete() else -> {} } delay(30) dragState.animateTo(SwipeToRevealValue.Resting) } }
The LaunchedEffect triggers the appropriate action based on the settled value, then resets the swipe position to maintain a clean UI state after each swipe.
Conclusion
In this article, we’ve explored three powerful approaches to implementing swipe-based interactions in Jetpack Compose: detectHorizontalDragGestures, SwipeToDismissBox, and anchoredDraggable.
Each method has its strengths, allowing for a range of customization and control over swipe behaviors.
- detectHorizontalDragGestures provides a low-level, customizable approach, ideal if you need control over gesture handling.
- SwipeToDismissBox simplifies the setup for dismissible items with built-in state management, making it a great choice for straightforward swipe-to-dismiss interactions.
- anchoredDraggable offers precise control over anchored states, making it well-suited for swipe functionalities.
By choosing the right tool for the job, you can create smooth, intuitive swipe interactions that enhance your app’s UX. Compose continues to evolve, and with these options, you can build flexible and engaging interfaces that feel natural and responsive to users.
If you found this article interesting, feel free to follow me for more insightful content on Android development and Jetpack Compose. I publish new articles almost every week. Don’t hesitate to share your comments or reach out to me on LinkedIn if you prefer.
Have a great day!
This article is previously published on proandroiddev.com.