Exploring Jetpack Compose Anchored Draggable Modifier
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 thepositionalTheshold
. If the drag velocity exceeds this threshold, then we will animate to the next anchor, otherwise thepositionalThreshold
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
}
)
}
}
- We use
remember
to ensure our state persists across recompositions. - The state requires an initial value to snap to when first rendered, so we specify that as the
Start
value of our enum. - 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. - The
velocityTheshold
determines the minimum velocity that will trigger the dragged content to animate to the next anchor, irrespective of whether the threshold specified bypositionalThreshold
has been reached or not. - The
animationSpec
specifies how we animate to the next anchor when releasing the drag gesture; here we use atween
animation which defaults to aFastOutSlowIn
interpolator. - Next we define the anchors for the content, using the
updateAnchors
method we described earlier. - Finally we specify which anchors we want to use, using the
DraggableAnchors
helper method. What we are doing here is creating a map ofDragAnchors
to actual offset positions for the content. In this case, when the state isStart
the content will be at offset 0 pixels, and when the state isEnd
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,
)
}
- We apply an
offset
to the content we want to drag, so that it actually moves when the drag state changes. - The value to offset the content by is provided by the draggable state’s
requireOffset
method, which we described earlier. - 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
}
}
)
}
)
- We update our state so that we do not specify any initial anchors.
- 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. - We add the
onSizeChanged
modifier to theBox
wrapping the draggable content, which we need in order to know the available size. - 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. - 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.