Exploring Jetpack Compose Anchored Draggable Modifier

Francesc Vilarino Guell
10 min readJun 22, 2023

--

In this short article we will learn how to use the new anchoredDraggable modifier released with Jetpack Compose Foundation 1.6.0-alpha01 .

anchoredDraggable is a new modifier that allows us to drag content either horizontally or vertically, specifying optional anchor points along the drag zone where the content will snap to. Until now if you wanted to drag some content along the screen you would define a pointerInput and have to handle the drag gestures yourself; with anchoredDraggable this becomes much simpler.

Using the correct dependencies

Before we start, we will need to specify the correct dependencies for our build. If you use the compose BOM to specify your Jetpack Compose dependencies, you will need to add an additional line to your build.gradle file to import the necessary dependencies for the anchoredDraggable modifier, as the BOM only pulls in stable dependencies. The line you need to add to your gradle file is

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

Note that the foundation library has been compiled against SDK 34, so you will also need to set your compileSdk to 34 in the same gradle file.

How the anchored draggable modifier works

The new anchored draggable has 2 main parts, one is the Modifier that is applied to the content to be dragged, and the other is its state, AnchoredDraggableState, which specifies how the drag will operate.

The draggable state

Let’s start by looking at the state. We get an instance of the state by using its constructor, defined as shown below:

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

In this constructor, we have

  • The initialValue, a parameterized argument that will be used to snap the draggable content when first rendered.
  • A positionalThreshold lambda that is used to determine whether the content will animate to the next anchor or return to the original anchor, based on the distance between anchors.
  • A velocityTheshold lambda that returns a velocity used to determine if we should animate to the next anchor, irrespective of the positionalTheshold. If the drag velocity exceeds this threshold, then we will animate to the next anchor, otherwise the positionalThreshold is used.
  • An animationSpec to determine how to animate the draggable content.
  • An optional confirmValueChange lambda that can be used to veto changes to the draggable content.

It’s worth of note that there is no rememberDraggableState factory method currently available, so we will need to remember the state in our composables manually.

Besides the constructor, there are a couple of other APIs that we need to familiarise ourselves with in order to use the anchoredDraggable modifier, these are updateAnchors and requireOffset. Let’s have a look.

    fun updateAnchors(
newAnchors: DraggableAnchors<T>,
newTarget: T = if (!offset.isNaN()) {
newAnchors.closestAnchor(offset) ?: targetValue
} else targetValue
)

We use the method updateAnchors to specify the stop points along the drag zone where the content will snap to. At a minimum, we need to specify 2 anchors so that the content can be dragged between those 2, but we can add as many as we need. There is also a helper method that simplifies creating the anchors, called DraggableAnchors that we will later use.

The other method we will be using is requireOffset, this method simply returns the offset of the draggable content, so that we can apply it to the content. Note that the anchoredDraggable modifier does not actually move the content on drag, it simply computes the offset when the user drags on the screen, it is our responsibility to update the content based on the offset provided by requireOffset. We will see how we can do that later in an example.

The anchoredDraggable modifier

The modifier itself is very simple:

fun <T> Modifier.anchoredDraggable(
state: AnchoredDraggableState<T>,
orientation: Orientation,
enabled: Boolean = true,
reverseDirection: Boolean = false,
interactionSource: MutableInteractionSource? = null
)

This modifier accepts;

  • The state, an instance of DraggableState.
  • The direction we want to drag the content in, either horizontally or vertically.
  • An optional flag to enable/disable drag gestures.
  • An optional flag to reverse the drag direction.
  • And an optional interaction source that will be used for the drag gestures.

Using the anchored draggable modifier

Fixed anchors

Our first example will use just 2 fixed anchors, a start and end position for the content to drag.

To specify the drag points, we will create an enum to specify the 2 anchors,

enum class DragAnchors {
Start,
End,
}

Then, in our composable where we want to have the draggable content, we instantiate the state,

    // 1
val state = remember {
AnchoredDraggableState(
// 2
initialValue = DragAnchors.Start,
// 3
positionalThreshold = { distance: Float -> distance * 0.5f },
// 4
velocityThreshold = { with(density) { 100.dp.toPx() } },
// 5
animationSpec = tween(),
).apply {
// 6
updateAnchors(
// 7
DraggableAnchors {
DragAnchors.Start at 0f
DragAnchors.End at 400f
}
)
}
}
  1. We use remember to ensure our state persists across recompositions.
  2. The state requires an initial value to snap to when first rendered, so we specify that as the Start value of our enum.
  3. The positionalThreshold lambda determines if we animate to the next anchor, based on the distance travelled. Here we specify that the threshold to determine if we move to the next anchor is half the distance to that next anchor — if we have moved beyond the half point between two anchors we will animate to the next one, otherwise we will return to the origin anchor.
  4. The velocityTheshold determines the minimum velocity that will trigger the dragged content to animate to the next anchor, irrespective of whether the threshold specified by positionalThreshold has been reached or not.
  5. The animationSpec specifies how we animate to the next anchor when releasing the drag gesture; here we use a tween animation which defaults to a FastOutSlowIn interpolator.
  6. Next we define the anchors for the content, using the updateAnchors method we described earlier.
  7. Finally we specify which anchors we want to use, using the DraggableAnchors helper method. What we are doing here is creating a map of DragAnchors to actual offset positions for the content. In this case, when the state is Start the content will be at offset 0 pixels, and when the state is End the content offset will be 400 pixels.

Now that we have the state, we can apply the modifier to our content, let’s see how

    Box(
modifier = modifier,
) {
Image(
painter = painterResource(id = R.drawable.android_logo),
modifier = Modifier
.size(80.dp)
// 1
.offset {
IntOffset(
// 2
x = state.requireOffset().roundToInt(),
y = 0,
)
}
// 3
.anchoredDraggable(state, Orientation.Horizontal),
contentDescription = null,
)
}
  1. We apply an offset to the content we want to drag, so that it actually moves when the drag state changes.
  2. The value to offset the content by is provided by the draggable state’s requireOffset method, which we described earlier.
  3. Finally the last step is to apply the anchoredDraggable modifier to the content, providing the state and the drag direction.

This is pretty much all that is required, the full code is shown here:

fun HorizontalDraggableSample(
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
val state = remember {
AnchoredDraggableState(
initialValue = DragAnchors.Start,
positionalThreshold = { distance: Float -> distance * 0.5f },
velocityThreshold = { with(density) { 100.dp.toPx() } },
animationSpec = tween(),
).apply {
updateAnchors(
DraggableAnchors {
DragAnchors.Start at 0f
DragAnchors.End at 400f
}
)
}
}

Box(
modifier = modifier,
) {
Image(
painter = painterResource(id = R.drawable.android_logo),
modifier = Modifier
.size(80.dp)
.offset {
IntOffset(
x = state
.requireOffset()
.roundToInt(), y = 0
)
}
.anchoredDraggable(state, Orientation.Horizontal),
contentDescription = null,
)
}
}

With this we get a draggable content that will snap at either the left edge of the screen (0 pixels of offset), or at 400 pixels:

Dynamic Anchors

The example above used 2 anchors at fixed locations, let’s see how we can update the example to use anchors that leverage the screen width instead.

First we will update our enum to specify a 3rd anchor point, halfway between the start and end. The enum will also provide a fraction that we will use to compute the offset, this will be a fraction of the overall screen width:

enum class DragAnchors(val fraction: Float) {
Start(0f),
Half(.5f),
End(1f),
}

As described in the documentation of anchoredDraggable, to use dynamic anchors that depend on the size we need to use the onSizeChanged modifier so that we can read the width available to our composable, so that we can then in turn calculate the correct anchor points. Let’s see how we can do that:

   // 1
val state = remember {
AnchoredDraggableState(
initialValue = DragAnchors.Half,
positionalThreshold = { distance: Float -> distance * 0.5f },
velocityThreshold = { with(density) { 100.dp.toPx() } },
animationSpec = tween(),
)
}
// 2
val contentSize = 80.dp
val contentSizePx = with(density) { contentSize.toPx() }
Box(
modifier
// 3
.onSizeChanged { layoutSize ->
// 4
val dragEndPoint = layoutSize.width - contentSizePx
state.updateAnchors(
DraggableAnchors {
// 5
DragAnchors
.values()
.forEach { anchor ->
anchor at dragEndPoint * anchor.fraction
}
}
)
}
)
  1. We update our state so that we do not specify any initial anchors.
  2. We define the size of the content we want to drag, both as dp and as pixels — we will need to use this to adjust the drag anchor offsets.
  3. We add the onSizeChanged modifier to the Box wrapping the draggable content, which we need in order to know the available size.
  4. In the lambda passed to onSizeChanged we calculate what is the rightmost position we can drag our content to, which is the width available to us, minus the width of the content we are drawing.
  5. Once we have that size, we iterate over the set of anchors and specify their offset based on the available drag width.

The draggable content, inside the Box, does not change. The full code is show below:

fun HorizontalDraggableSample(
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
val state = remember {
AnchoredDraggableState(
initialValue = DragAnchors.Half,
positionalThreshold = { distance: Float -> distance * 0.5f },
velocityThreshold = { with(density) { 100.dp.toPx() } },
animationSpec = tween(),
)
}
val contentSize = 80.dp
val contentSizePx = with(density) { contentSize.toPx() }
Box(
modifier
.onSizeChanged { layoutSize ->
val dragEndPoint = layoutSize.width - contentSizePx
state.updateAnchors(
DraggableAnchors {
DragAnchors
.values()
.forEach { anchor ->
anchor at dragEndPoint * anchor.fraction
}
}
)
}
) {
Image(
painter = painterResource(id = R.drawable.android_logo),
modifier = Modifier
.size(contentSize)
.offset {
IntOffset(
x = state.requireOffset().roundToInt(),
y = 0,
)
}
.anchoredDraggable(state, Orientation.Horizontal),
contentDescription = null,
)
}
}

We can also make the animation a bit more playful, all that is required is to update the animation spec:

val state = remember {
AnchoredDraggableState(
initialValue = DragAnchors.Half,
positionalThreshold = { distance: Float -> distance * 0.5f },
velocityThreshold = { with(density) { 100.dp.toPx() } },
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMediumLow,
),
)
}

Vertical drag

Just as we can drag horizontally, we can do the same vertically, all it requires is to change the offset modifier to update the y coordinate instead of the x one, and to specify the new direction when we set the anchoredDraggable modifier. These changes are minor, so I’ll just show the final code here (note that I added 2 additional anchor points):

enum class DragAnchors(val fraction: Float) {
Start(0f),
OneQuarter(.25f),
Half(.5f),
ThreeQuarters(.75f),
End(1f),
}

@Composable
fun VerticalDraggableSample(
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
val state = remember {
AnchoredDraggableState(
initialValue = DragAnchors.Half,
positionalThreshold = { distance: Float -> distance * 0.5f },
velocityThreshold = { with(density) { 100.dp.toPx() } },
animationSpec = tween(),
)
}
val contentSize = 80.dp
val contentSizePx = with(density) { contentSize.toPx() }
Box(
modifier
.onSizeChanged { layoutSize ->
val dragEndPoint = layoutSize.height - contentSizePx
state.updateAnchors(
DraggableAnchors {
DragAnchors
.values()
.forEach { anchor ->
anchor at dragEndPoint * anchor.fraction
}
}
)
}
) {
Image(
painter = painterResource(id = R.drawable.android_logo),
modifier = Modifier
.size(contentSize)
.offset {
IntOffset(
x = 0,
y = state.requireOffset().roundToInt(),
)
}
.anchoredDraggable(state, Orientation.Vertical),
contentDescription = null,
)
}
}

Saving the state

Something you may have noticed is that, if we trigger a configuration change (by rotating the device, or changing the theme), the draggable content resets to its original position, it does not retain its last dragged to position. This is because we are remembering the state, which only survives as long as the composable is in the composition, and is lost when it leaves the composition, as happens on configuration change.

To fix this we can use rememberSaveable and use the Saver provided by the state itself, which is defined as shown below

    companion object {
/**
* The default [Saver] implementation for [AnchoredDraggableState].
*/
@ExperimentalFoundationApi
fun <T : Any> Saver(
animationSpec: AnimationSpec<Float>,
positionalThreshold: (distance: Float) -> Float,
velocityThreshold: () -> Float,
confirmValueChange: (T) -> Boolean = { true },
) = Saver<AnchoredDraggableState<T>, T>(
save = { it.currentValue },
restore = {
AnchoredDraggableState(
initialValue = it,
animationSpec = animationSpec,
confirmValueChange = confirmValueChange,
positionalThreshold = positionalThreshold,
velocityThreshold = velocityThreshold
)
}
)
}

So to persist the draggable state across state changes we need to change our remember call to a rememberSaveable and provide the Saver shown above. The Saver accepts the same lambdas as the state itself, so we can extract those to helper properties in our composable and refactor our code as shown below:

    val positionalThreshold = { distance: Float -> distance * 0.5f }
val velocityThreshold = { with(density) { 100.dp.toPx() } }
val animationSpec = tween<Float>()
val state = rememberSaveable(
saver = AnchoredDraggableState.Saver(animationSpec, positionalThreshold, velocityThreshold)
) {
AnchoredDraggableState(
initialValue = DragAnchors.Half,
positionalThreshold = positionalThreshold,
velocityThreshold = velocityThreshold,
animationSpec = animationSpec,
)
}

We have to keep in mind that, if we are using dynamic anchors then we would need to use a key on the saver using the density, so that the state is recreated should the density change. The same applies to any other property that you may use for the state and anchors.

The sample code used on this article is available on this gist.

--

--