Hello everyone! This is the last article of this series. In the first one, we covered animations, and in the second one, we talked about gestures. Now, we will combine gestures and animations in Jetpack Compose to build a drag to reorder feature in a list. That’s the preview:

If you want to follow along or want the code of the UI, check out this repository:

Note that compose version is 1.0.0

This is a synopsis of this article:

  1. When an item is being dragged, we make it in front of the others, draw a shadow behind it and rotate it.
  2. Using the y-axis offset that defines the distance between the first and the current position, we update the items states to either slid to the top, slid to the bottom, or not slid.
  3. When the item is placed, we want to launch a stream of particles to express better that the slot is put.

1. Drag to reorder

1.1. Current item

The skeleton of the dragToReroder function would be the same as the swipeToDelete that we implemented in the last article. That is an extension compact function that wraps pointerInput(Unit) and offset() in Modifier.composed().

Modifier.composed() is used to implement stateful modifiers that have instance-specific state for each modified element, allowing the same Modifier instance to be safely reused for multiple elements while maintaining an element-specific state.

Since we want to drag the item freely, we will create horizontal and vertical offsets that are animating while changing the item position. And when it is released, we animate these offsets to the initial position, zero that is.

fun Modifier.dragToReorder(
onDrag: () -> Unit,
onStopDrag: () -> Unit,
): Modifier = composed {
val offsetX = remember { Animatable(0f) }
val offsetY = remember { Animatable(0f) }
pointerInput(Unit) {
// Wrap in a coroutine scope to use suspend functions for touch events and animation.
coroutineScope {
while (true) {
// Wait for a touch down event.
val pointerId = awaitPointerEventScope { awaitFirstDown().id }
// Interrupt any ongoing animation of other items.
offsetX.stop()
offsetY.stop()
// Wait for drag events.
awaitPointerEventScope {
drag(pointerId) { change ->
onDrag()
val horizontalDragOffset = offsetX.value + change.positionChange().x
launch {
offsetX.snapTo(horizontalDragOffset)
}
val verticalDragOffset = offsetY.value + change.positionChange().y
launch {
offsetY.snapTo(verticalDragOffset)
}
// Consume the gesture event, not passed to external
change.consumePositionChange()
}
}
launch {
offsetX.animateTo(0f)
}
launch {
offsetY.animateTo(0f)
onStopDrag()
}
}
}
}
.offset {
// Use the animating offset value here.
IntOffset(offsetX.value.roundToInt(), offsetY.value.roundToInt())
}
}

In the ShoesCard Composable, we want to create a boolean state informing us if the item is being dragged or not so that we can’t rely on it as a condition to swing between values to show if the item is held.
To make the current item on top of others, we can make use of Modifier.zIndex().

Modifier.zIndex() Creates a modifier that controls the drawing order for the children of the same layout parent. A child with a larger zIndex will be drawn on top of all the children with a smaller zIndex. The default zIndex is 0.

Not to forget to mention, we want to rotate the item on being dragged, and show a shadow behind the slot child.

@ExperimentalAnimationApi
@Composable
fun ShoesCard(shoesArticle: ShoesArticle) {
/**/
val isDragged = remember { mutableStateOf(false) }
val zIndex = if (isDragged.value) 1.0f else 0.0f
val rotation = if (isDragged.value) 5.0f else 0.0f
val elevation = if (isDragged.value) 8.dp else 0.dp
Box(
Modifier
.padding(horizontal = 16.dp)
.dragToReorder(
{ isDragged.value = true },
{ isDragged.value = false }
)
.zIndex(zIndex)
.rotate(rotation)
) {
Column(
modifier = Modifier
.shadow(elevation, RoundedCornerShape(8.dp))
.clip(RoundedCornerShape(8.dp))
.background(
color = shoesArticle.color
)
.padding(slotPaddingDp)
.align(Alignment.CenterStart)
.fillMaxWidth()
) { /**/ }
Image(/**/)
}
}
view raw ShoesCard.kt hosted with ❤ by GitHub

1.2. Other items

We want to map each shoes’ article to a state representing if the item is either in its place or swiped towards the top or the bottom.

enum class SlideState {
NONE,
UP,
DOWN
}
view raw SlideState.kt hosted with ❤ by GitHub

After creating this map, we want to pass it to the ShoesList Composable where we provide every ShoesCard with its proper SlideState.

@ExperimentalAnimationApi
@Composable
fun Home() {
val slideStates = remember {
mutableStateMapOf<ShoesArticle, SlideState>()
.apply {
shoesArticles.map { shoesArticle ->
shoesArticle to SlideState.NONE
}.toMap().also {
putAll(it)
}
}
}
Scaffold(/**/) { innerPadding ->
ShoesList(
modifier = Modifier.padding(innerPadding),
shoesArticles = shoesArticles,
slideStates = slideStates
)
}
}
@ExperimentalAnimationApi
@Composable
fun ShoesList(
modifier: Modifier,
shoesArticles: MutableList<ShoesArticle>,
slideStates: Map<ShoesArticle, SlideState>
) {
val lazyListState = rememberLazyListState()
LazyColumn(
state = lazyListState,
modifier = modifier.padding(top = dimensionResource(id = R.dimen.list_top_padding))
) {
items(shoesArticles.size) { index ->
val shoesArticle = shoesArticles.getOrNull(index)
if (shoesArticle != null) {
key(shoesArticle) {
val slideState = slideStates[shoesArticle] ?: SlideState.NONE
ShoesCard(
shoesArticle = shoesArticle,
slideState = slideState
)
}
}
}
}
}
view raw MainActivity.kt hosted with ❤ by GitHub

And inside the card, we animate its vertical translation based on the given slide state.

@ExperimentalAnimationApi
@Composable
fun ShoesCard(
shoesArticle: ShoesArticle,
slideState: SlideState
) {
/**/
val verticalTranslation by animateIntAsState(
targetValue = when (slideState) {
SlideState.UP -> itemHeight
SlideState.DOWN -> itemHeight
else -> 0
},
)
/**/
Box(
Modifier
.padding(horizontal = 16.dp)
.dragToReorder(
{ isDragged.value = true },
{ isDragged.value = false }
)
.offset { IntOffset(0, verticalTranslation) }
.zIndex(zIndex)
.rotate(rotation)
) { /**/ }
}
view raw ShoesCard.kt hosted with ❤ by GitHub

Next, we need to prepare two callbacks that will help us update outside references from each item. The first one is to modify a SlideState based on a ShoesArticle. The other one is changing the item position in shoesArticles when it’s placed, and update every slide state to SlideState.NONE.

@ExperimentalAnimationApi
@Composable
fun Home() {
/**/
Scaffold(/**/) { innerPadding ->
ShoesList(
modifier = Modifier.padding(innerPadding),
shoesArticles = shoesArticles,
slideStates = slideStates,
updateSlidedState = { shoesArticle, slideState -> slideStates[shoesArticle] = slideState },
updateItemPosition = { currentIndex, destinationIndex ->
val shoesArticle = shoesArticles[currentIndex]
shoesArticles.removeAt(currentIndex)
shoesArticles.add(destinationIndex, shoesArticle)
slideStates.apply {
shoesArticles.map { shoesArticle ->
shoesArticle to SlideState.NONE
}.toMap().also {
putAll(it)
}
}
}
)
}
}
@ExperimentalAnimationApi
@Composable
fun ShoesList(
modifier: Modifier,
shoesArticles: MutableList<ShoesArticle>,
slideStates: Map<ShoesArticle, SlideState>,
updateSlidedState: (shoesArticle: ShoesArticle, slideState: SlideState) -> Unit,
updateItemPosition: (currentIndex: Int, destinationIndex: Int) -> Unit
) {
val lazyListState = rememberLazyListState()
LazyColumn(
state = lazyListState,
modifier = modifier.padding(top = dimensionResource(id = R.dimen.list_top_padding))
) {
items(shoesArticles.size) { index ->
val shoesArticle = shoesArticles.getOrNull(index)
if (shoesArticle != null) {
key(shoesArticle) {
val slideState = slideStates[shoesArticle] ?: SlideState.NONE
ShoesCard(
shoesArticle = shoesArticle,
slideState = slideState,
shoesArticles = shoesArticles,
updateSlidedState = updateSlidedState,
updateItemPosition = updateItemPosition
)
}
}
}
}
}
view raw MainActivity.kt hosted with ❤ by GitHub

Now, inside ShoesCard composable, we will pass updateSlideState() callback to Modifier.DragToReroder() to use it later and call updateItemPosition() when the item is placed.

@ExperimentalAnimationApi
@Composable
fun ShoesCard(
shoesArticle: ShoesArticle,
slideState: SlideState,
shoesArticles: MutableList<ShoesArticle>,
updateSlideState: (shoesArticle: ShoesArticle, slideState: SlideState) -> Unit,
updateItemPosition: (currentIndex: Int, destinationIndex: Int) -> Unit
) {
val currentIndex = remember { mutableStateOf(0) }
val destinationIndex = remember { mutableStateOf(0) }
val isPlaced = remember { mutableStateOf(false) }
LaunchedEffect(isPlaced.value) {
if (isPlaced.value) {
if (currentIndex.value != destinationIndex.value) {
updateItemPosition(currentIndex.value, destinationIndex.value)
}
isPlaced.value = false
}
}
Box(
Modifier
.padding(horizontal = 16.dp)
.dragToReorder(
shoesArticle,
shoesArticles,
itemHeight,
updateSlideState,
{ isDragged.value = true },
{ cIndex, dIndex ->
isDragged.value = false
isPlaced.value = true
currentIndex.value = cIndex
destinationIndex.value = dIndex
}
)
.offset { IntOffset(0, verticalTranslation) }
.zIndex(zIndex)
.rotate(rotation)
) { /**/ }
}
view raw ShoesCard.kt hosted with ❤ by GitHub

Finally, we have to implement the algorithm that will trigger items to be slid. While the item is being dragged, we will use the y-axis offset to calculate the number of items that should be slid and we will update their state accordingly. We want to keep track of the previous number of items because when the new value is less than the last we should update the latter’s state to none.

fun Modifier.dragToReorder(
shoesArticle: ShoesArticle,
shoesArticles: MutableList<ShoesArticle>,
itemHeight: Int,
updateSlideState: (shoesArticle: ShoesArticle, slideState: SlideState) -> Unit,
onDrag: () -> Unit,
onStopDrag: (currentIndex: Int, destinationIndex: Int) -> Unit,
): Modifier = composed {
/**/
pointerInput(Unit) {
// Wrap in a coroutine scope to use suspend functions for touch events and animation.
coroutineScope {
while (true) {
/**/
val shoesArticleIndex = shoesArticles.indexOf(shoesArticle)
val offsetToSlide = itemHeight / 4
var numberOfItems = 0
var previousNumberOfItems: Int
var listOffset = 0
// Wait for drag events.
awaitPointerEventScope {
drag(pointerId) { change ->
onDrag()
/**/
launch {
offsetY.snapTo(verticalDragOffset)
val offsetSign = offsetY.value.sign.toInt()
previousNumberOfItems = numberOfItems
numberOfItems = calculateNumberOfSlidItems(
offsetY.value * offsetSign,
itemHeight,
offsetToSlide,
previousNumberOfItems
)
if (previousNumberOfItems > numberOfItems) {
updateSlideState(
shoesArticles[shoesArticleIndex + previousNumberOfItems * offsetSign],
SlideState.NONE
)
} else if (numberOfItems != 0) {
try {
updateSlideState(
shoesArticles[shoesArticleIndex + numberOfItems * offsetSign],
if (offsetSign == 1) SlideState.UP else SlideState.DOWN
)
} catch (e: IndexOutOfBoundsException) {
numberOfItems = previousNumberOfItems
Log.i("DragToReorder", "Item is outside or at the edge")
}
}
listOffset = numberOfItems * offsetSign
}
/**/
}
}
/**/
launch {
offsetY.animateTo(itemHeight * numberOfItems * offsetY.value.sign)
onStopDrag(currentIndex, currentIndex + listOffset)
}
}
}
} /**/
}
private fun calculateNumberOfSlidItems(
offsetY: Float,
itemHeight: Int,
offsetToSlide: Int,
previousNumberOfItems: Int
): Int {
val numberOfItemsInOffset = (offsetY / itemHeight).toInt()
val numberOfItemsPlusOffset = ((offsetY + offsetToSlide) / itemHeight).toInt()
val numberOfItemsMinusOffset = ((offsetY offsetToSlide 1) / itemHeight).toInt()
return when {
offsetY offsetToSlide 1 < 0 -> 0
numberOfItemsPlusOffset > numberOfItemsInOffset -> numberOfItemsPlusOffset
numberOfItemsMinusOffset < numberOfItemsInOffset -> numberOfItemsInOffset
else -> previousNumberOfItems
}
}

2. Stream of particles

Now, we want to spice things up a little bit. We want to express that the dragged item is placed more explicitly. So, we decided that adding a stream of particles that emanates from the item’s bottom corners would be favorable. Let’s start by interpreting how we’re going to position a particle:

The big dashed black box is the one that refers to the item, and the violet represents the slot. Thus, we will call the distance between them slotItemDifference. Now, to translate the particle from position 1 to 2, we would horizontally translate it by its radius and we would vertically translate it by the accumulation of its radius, item’s height minus slotItemDifference, and particlesStreamRadius, which is the one indicating the radius of the dashed circle in the figure above.

data class Particle(
val color: Color,
val x: Int,
val y: Int,
val radius: Float
)
view raw Particle.kt hosted with ❤ by GitHub

We will use polar coordinates to move a particle along a given radius and rotation. We want a few dependencies in our function, so we want to make them global.

private val particlesStreamRadii = mutableListOf<Float>()
private var itemHeight = 0
private var particleRadius = 0f
private var slotItemDifference = 0f
@ExperimentalAnimationApi
@Composable
fun ShoesCard(
shoesArticle: ShoesArticle,
slideState: SlideState,
shoesArticles: MutableList<ShoesArticle>,
updateSlideState: (shoesArticle: ShoesArticle, slideState: SlideState) -> Unit,
updateItemPosition: (currentIndex: Int, destinationIndex: Int) -> Unit
) {
val itemHeightDp = dimensionResource(id = R.dimen.image_size)
val slotPaddingDp = dimensionResource(id = R.dimen.slot_padding)
with(LocalDensity.current) {
itemHeight = itemHeightDp.toPx().toInt()
particleRadius = dimensionResource(id = R.dimen.particle_radius).toPx()
if (particlesStreamRadii.isEmpty())
particlesStreamRadii.addAll(arrayOf(6.dp.toPx(), 10.dp.toPx(), 14.dp.toPx()))
slotItemDifference = 18.dp.toPx()
}
/**/
}
private fun createParticles(rotation: Double, color: Color, isLeft: Boolean): List<Particle> {
val particles = mutableListOf<Particle>()
for (i in 0 until particlesStreamRadii.size) {
val currentParticleRadius = particleRadius * (i + 1) / particlesStreamRadii.size
val verticalOffset =
(itemHeight.toFloat() particlesStreamRadii[i] slotItemDifference + currentParticleRadius).toInt()
val horizontalOffset = currentParticleRadius.toInt()
particles.add(
Particle(
color = color.copy(alpha = (i + 1) / (particlesStreamRadii.size).toFloat()),
x = (particlesStreamRadii[i] * cos(rotation)).toInt() + if (isLeft) horizontalOffset else horizontalOffset,
y = (particlesStreamRadii[i] * sin(rotation)).toInt() + verticalOffset,
radius = currentParticleRadius
)
)
}
return particles
}
view raw ShoesCard.kt hosted with ❤ by GitHub

Finally, that’s how we’re going to create our two streams of particles.

/**/
val leftParticlesRotation = remember { Animatable((Math.PI / 4).toFloat()) }
val rightParticlesRotation = remember { Animatable((Math.PI * 3 / 4).toFloat()) }
LaunchedEffect(isPlaced.value) {
if (isPlaced.value) {
launch {
leftParticlesRotation.animateTo(
targetValue = Math.PI.toFloat(),
animationSpec = tween(durationMillis = 400)
)
leftParticlesRotation.snapTo((Math.PI / 4).toFloat())
}
launch {
rightParticlesRotation.animateTo(
targetValue = 0f,
animationSpec = tween(durationMillis = 400)
)
rightParticlesRotation.snapTo((Math.PI * 3 / 4).toFloat())
if (currentIndex.value != destinationIndex.value) {
updateItemPosition(currentIndex.value, destinationIndex.value)
}
isPlaced.value = false
}
}
}
val leftParticles =
createParticles(leftParticlesRotation.value.toDouble(), shoesArticle.color, isLeft = true)
val rightParticles =
createParticles(rightParticlesRotation.value.toDouble(), shoesArticle.color, isLeft = false)
Box(/**/) {
Canvas(modifier = Modifier) {
leftParticles.forEach {
drawCircle(it.color, it.radius, center = IntOffset(it.x, it.y).toOffset())
}
}
Canvas(modifier = Modifier.align(Alignment.TopEnd)) {
rightParticles.forEach {
drawCircle(it.color, it.radius, center = IntOffset(it.x, it.y).toOffset())
}
}
/**/
}
view raw ShoesCard.kt hosted with ❤ by GitHub

Animation | Jetpack Compose | Android Developers

Here is the full Github repository for this article:

That’s it for this series. I hope it was beneficial for you.

Become Author

We are making content on new declarative UI kit Jetpack Compose, if you are willing to build content join us and we will help you get started.

Here We Go Again : (

if (article == helpful) {
    println("Like and subscribe to blog newsletter.")
} else {
    println("Let me know what i should blog on.")
}

This site uses Akismet to reduce spam. Learn how your comment data is processed.