How to Implement Swipe-to-Action using AnchoredDraggable in Jetpack Compose

How to use AnchoredDraggable modifier to make something cool — swipe-to-action.
Sep 19 2023 · 6 min read

Background

In this blog post, we will delve into an exciting new tool: the AnchoredDraggable modifier. We’ll see how to use this modifier to make something really cool — swipe-to-action. It’s that feature you often see in modern apps where you swipe something and it reveals actions like delete, edit, and share etc...

AnchoredDraggable is designed to create components you can drag between specific states, just like those modal bottom sheets you’ve seen. The best part? This API takes over the role of Material’s Swipeable API, offering a more versatile approach.

This implementation is inspired by flutter_slidable.

What do we implement in this blog? Three variants of swipe-to-action

Behind Motion Swipe

Scroll Motion Swipe

Drawer Motion Swipe

The source code is available on GitHub

We are what we repeatedly do. Excellence, then, is not an act, but a habit. Try out Justly and start building your habits today!

Add dependency

Let’s first add a required dependency to use Anchored draggable API.

implementation("androidx.compose.foundation:foundation:1.6.0-alpha04")

AnchoredDraggable Modifier

Before we go further into implementation, let’s have a quick look at the AnchoredDraggable Modifier.

AnchoredDraggableState

@ExperimentalFoundationApi
class AnchoredDraggableState<T>(
    initialValue: T,
    internal val positionalThreshold: (totalDistance: Float) -> Float,
    internal val velocityThreshold: () -> Float,
    val animationSpec: AnimationSpec<Float>,
    internal val confirmValueChange: (newValue: T) -> Boolean = { true }
)

The AnchoredDraggableState helps you manage and control draggable elements with anchor points in your app. Here's what you need to know:

Initial Position: You can set the starting position of a draggable element using the initialValue.

Thresholds: It allows you to define two thresholds:

  1. positionalThreshold: This determines when the element should snap to a new position while you’re dragging it. It’s like saying, “Move it this far, and then snap to the closest anchor.”
  2. velocityThreshold: This sets the speed at which the element needs to be moving when you release it for it to snap to a new position, even if it didn’t reach the positional threshold

Animations: You can specify how the element should smoothly transition between states using the animationSpec.

Control: There’s an optional callback called confirmValueChange that gives you control over whether a state change should happen or not.

Modifier.anchoredDraggable

@ExperimentalFoundationApi
fun <T> Modifier.anchoredDraggable(
    state: AnchoredDraggableState<T>,
    orientation: Orientation,
    enabled: Boolean = true,
    reverseDirection: Boolean = false,
    interactionSource: MutableInteractionSource? = null
) = draggable(
    state = state.draggableState,
    orientation = orientation,
    enabled = enabled,
    interactionSource = interactionSource,
    reverseDirection = reverseDirection,
    startDragImmediately = state.isAnimationRunning,
    onDragStopped = { velocity -> launch { state.settle(velocity) } }
)

Key Parameters:

  • state: Associates the draggable element with an AnchoredDraggableState, managing its position and animation.
  • orientation: Specifies the drag orientation as horizontal or vertical.
  • enabled: Determines whether the draggable behaviour is active or not.
  • reverseDirection: Optional parameter to reverse the drag direction.
  • interactionSource: An optional source for interaction events.

How it Works:

  1. When the user initiates a drag gesture, the composable’s position updates according to the drag delta, creating a visually responsive effect.
  2. Upon releasing the composable, it smoothly animates towards the nearest predefined anchor point.
  3. The AnchoredDraggableState keeps track of the current anchor, allowing you to react to changes accordingly.

Now let’s start the implementation of the first and simplest Swipe-to-action. To simplify we have given names to all three variants as it’s motion.

To keep the implementation simple I’m not showing the composable of Actions and content. You can find the full sample on GitHub.

We’ll use common DraggableItem composable in all examples.

enum class DragAnchors {
    Start,
    Center,
    End,
}

@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DraggableItem(
    state: AnchoredDraggableState<DragAnchors>,
    content: @Composable BoxScope.() -> Unit,
    startAction: @Composable (BoxScope.() -> Unit)? = {},
    endAction: @Composable (BoxScope.() -> Unit)? = {}
) {

    Box(
        modifier = Modifier
            .padding(16.dp)
            .fillMaxWidth()
            .height(100.dp)
            .clip(RectangleShape)
    ) {

        endAction?.let {
            endAction()
        }

        startAction?.let {
            startAction()
        }
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .align(Alignment.CenterStart)
                .offset {
                    IntOffset(
                        x = -state
                            .requireOffset()
                            .roundToInt(),
                        y = 0,
                    )
                }
                .anchoredDraggable(state, Orientation.Horizontal, reverseDirection = true),
            content = content
        )
    }
}

Parameters:

  • state: This parameter is of type AnchoredDraggableState<DragAnchors>, It controls how the item is dragged, anchored, and animated.
  • content: It represents the main content of the draggable item.
  • startAction and endAction: These are optional parameters. It defines the swipe-to-action buttons that appear when the item is dragged to the start or end position.

BehindMotionSwipe

Here we’ll implement a swipe animation that reveals action as you drag. Let’s first set the AnchoredDraggableState

  val density = LocalDensity.current
    val defaultActionSize = 80.dp
    val endActionSizePx = with(density) { (defaultActionSize * 2).toPx() }
    val startActionSizePx = with(density) { defaultActionSize.toPx() }

    val state = remember {
        AnchoredDraggableState(
            initialValue = DragAnchors.Center,
            anchors = DraggableAnchors {
                DragAnchors.Start at -startActionSizePx
                DragAnchors.Center at 0f
                DragAnchors.End at endActionSizePx
            },
            positionalThreshold = { distance: Float -> distance * 0.5f },
            velocityThreshold = { with(density) { 100.dp.toPx() } },
            animationSpec = tween(),
        )
    }

DraggableAnchors { ... }: Defines the anchor points for the draggable element. It uses the DSL (domain-specific language) DraggableAnchors to specify three anchor points:

  • “Start” anchor at a negative distance of startActionSizePx pixels from the centre.
  • “Center” anchor at the centre (0 pixels from the centre).
  • “End” anchor at endActionSizePx pixels from the centre.

Now, we just have to use this to animate DraggableItem. Here’s how

Box(
    modifier = Modifier
        .fillMaxWidth()
        .align(Alignment.CenterStart)
        .offset {
            IntOffset(
                x = -state
                    .requireOffset()
                    .roundToInt(),
                y = 0,
            )
        }
        .anchoredDraggable(state, Orientation.Horizontal, reverseDirection = true),
    content = content
)

And we’re done. Pretty simple right? Here’s what we’ll have from the above implementation

The full source code of BehindMotionSwipe is available on GitHub.

ScrollMotionSwipe

To have a slideable effect as we drag we need to animate the actions' position.

Our AnchoredDraggableState would be the same as the above implementation.

Let’s first see the start action which is the Save Action.

DraggableItem(state = state,
    startAction = {
        Box(
            modifier = Modifier
                .fillMaxHeight()
                .align(Alignment.CenterStart),
        ) {
            SaveAction(
                Modifier
                    .width(defaultActionSize)
                    .fillMaxHeight()
                    .offset {
                        IntOffset(
                            ((-state
                                .requireOffset() - actionSizePx))
                                .roundToInt(), 0
                        )
                    }
            )
        }
    }, endAction = { ... }, content = { ... }

((-state.requireOffset() - actionSizePx)).roundToInt(): This calculation determines the X-coordinate of the SaveAction. Let's break it down:

  • state.requireOffset(): This retrieves the current offset from the state object. The offset represents how far the draggable element has been dragged.
  • - actionSizePx: It subtracts the actionSizePx from the offset. This is used to adjust the position of the SaveAction relative to the draggable element's position.
    Now let’s see the end actions swipe animation.

Now let’s see the end actions swipe animation.

endAction = {
    Row(
        modifier = Modifier
            .fillMaxHeight()
            .align(Alignment.CenterEnd)
            .offset {
                IntOffset(
                    (-state
                        .requireOffset() + endActionSizePx)
                        .roundToInt(), 0
                )
            }
        )
    {
        // Action composables
    }
}

Here -state.requireOffset() + endActionSizePx calculation determines the X-coordinate of the Row and + endActionSizePx adds endActionSizePx to the offset. This adjustment is used to position the "end action" relative to the draggable element's position.

Now we’re done with the setup. Let’s see the output.

DrawerMotionSwipe

This swipe effect is quite similar to the previous one, with just a slight adjustment to one action’s horizontal position.

In this case, instead of altering the offset of the parent composable (as done in the previous implementation, where it was a Row), we’ll adjust the offset of each individual action.

Let’s first see the EditAction.

EditAction(
    Modifier
        .width(defaultActionSize)
        .fillMaxHeight()
        .offset {
            IntOffset(
                ((-state
                    .requireOffset()) + actionSizePx)
                    .roundToInt(), 0
            )
        }
)

Nothing special just changing horizontal offset of Action based on the draggable state.

DeleteAction(
    Modifier
        .width(defaultActionSize)
        .fillMaxHeight()
        .offset {
            IntOffset(
                ((-state
                    .requireOffset() * 0.5f) + actionSizePx)
                    .roundToInt(), 0
            )
        }
)

Like the ‘EditAction,’ the ‘DeleteAction’ is also positioned based on the current state offset. However, there’s a distinct difference in how it’s calculated. The ‘DeleteAction’ is intentionally designed to reduce its horizontal movement by half compared to the ‘EditAction’ making it appear like a drawer that smoothly slides out. And here’s the output.

Conclusion

By implementing swipe-to-reveal functionality using AnchoredDraggable, we’ve showcased how Compose’s composability and declarative approach can simplify complex interactions. As you experiment with this technique, you’ll find endless possibilities to enhance user experiences.

Happy coding!

The source code is available on GitHub.


radhika-s image
Radhika saliya
Mobile App Developer | Sharing knowledge of Jetpack Compose & android development


radhika-s image
Radhika saliya
Mobile App Developer | Sharing knowledge of Jetpack Compose & android development

canopas-logo
We build products that customers can't help but love!
Get in touch
contact-footer
Say Hello!
footer
Subscribe Here!
Follow us on
2024 Canopas Software LLP. All rights reserved.