Hi all! This is the 2nd article of this series where we’re going to learn how to use gestures in Jetpack Compose. Let’s start, as we used to, with a 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

That’s a digest of what we are going to build:
The item, the funnel-shaped view, and the explosion will be updated based on the horizontal offset of the item.

1. UI

1.1. Side shape

We want to draw a funnel shape and a particle. The bottom radius of the funnel would be calculated using the particle radius.

  1. Long radius.
  2. Short radius.

As shown above, an arc is a segment of the ellipse bounded by a given rectangle. The two red rectangles are horizontally translated by the minus value of the short radius to avoid cutting vertically the funnel.
To draw this shape, we need to add two line segments, which are AD and BC. forceMoveTo parameter in Path.arcTo() performs this while creating these arcs. However, we need to follow a pattern while producing this shape. BADC, CDAB, DCBA, or ABCD. We will go with the last one.
Now we need two other values to create an arc. These are startAngleDegrees and sweepAngleDegrees. The zero degrees is the point on the right-hand side of the oval that crosses the horizontal line that intersects the center of the rectangle and with positive angles going clockwise around the oval.
So, for AB, we will go from 180° to 90°. So, the sweep angle would be -90°. And for CD, we will go from 270° to 180°. Therefore, we will use the same sweep angle, which is -90°. That’s it.

fun drawFunnel(upperRadius: Float, lowerRadius: Float, width: Float): Path {
return Path().apply {
// Top arc
arcTo(
rect = Rect(
left = lowerRadius,
top = upperRadius lowerRadius,
right = width * 2 lowerRadius,
bottom = upperRadius lowerRadius
),
startAngleDegrees = 180.0f,
sweepAngleDegrees = 90.0f,
forceMoveTo = false
)
// Bottom arc
arcTo(
rect = Rect(
left = lowerRadius,
top = upperRadius + lowerRadius,
right = width * 2 lowerRadius,
bottom = upperRadius * 3 + lowerRadius
),
startAngleDegrees = 270.0f,
sweepAngleDegrees = 90.0f,
forceMoveTo = false
)
close()
}
}
view raw Funnel.kt hosted with ❤ by GitHub

Since we want to draw something besides the swiped item, we will wrap the item in a Box. We will draw the funnel using DrawScope.drawPath(), and we will add the particle by drawing a circle.

val particleRadiusDp = dimensionResource(id = R.dimen.particle_radius)
val particleRadius: Float
val itemHeightDp = dimensionResource(id = R.dimen.image_size)
val itemHeight: Float
with(LocalDensity.current) {
particleRadius = particleRadiusDp.toPx()
itemHeight = itemHeightDp.toPx()
}
val radius = itemHeight * 0.5f
val funnelWidth = radius * 3
Box {
Canvas(
Modifier.height(itemHeightDp)
){
drawPath(
path = drawFunnel(
upperRadius = radius,
lowerRadius = particleRadius * 3 / 4f,
width = funnelWidth
),
color = shoesArticle.color
)
drawCircle(color = shoesArticle.color, radius = particleRadius)
}
Box(/**/) {
/**/
}
}
view raw ShoesCard.kt hosted with ❤ by GitHub

1.2. Explosion

We can create a simple explosion by translating some points from the center of a circle to its edges.

As depicted above, the horizontal and vertical translations are calculated with the sin or cos of the particle angle multiplied by the explosion radius.
Following the red arrows, we will determine the particle translations. So, we will make it twice as faster. However, technically, the algorithm stays at the same complexity, which is O(n).
Finally, we check if the current angle is not equal to 0 or PI to avoid drawing the circle twice on the x-axis.

val explosionParticleRadius: Float
val explosionRadius: Float
with(LocalDensity.current) {
explosionParticleRadius = dimensionResource(id = R.dimen.explosion_particle_radius).toPx()
explosionRadius = dimensionResource(id = R.dimen.explosion_radius).toPx()
}
Box {
Canvas(/**/) {/**/}
Canvas(modifier = Modifier.height(itemHeightDp) {
val numberOfExplosionParticles = 10
val particleAngle = Math.PI * 2 / numberOfExplosionParticles
var angle = 0.0
repeat(numberOfExplosionParticles / 2 + 1) {
val hTranslation = (cos(angle).toFloat() * explosionRadius)
val vTranslation = (sin(angle).toFloat() * explosionRadius)
translate(hTranslation, vTranslation) {
drawCircle(
color = shoesArticle.color,
radius = explosionParticleRadius
)
}
if (vTranslation.round(2) != vTranslation.round(2)) {
translate(hTranslation, vTranslation) {
drawCircle(
color = shoesArticle.color,
radius = explosionParticleRadius
)
}
}
angle += particleAngle
}
}
Box(/**/) {/**/}
}
view raw ShoesCard.kt hosted with ❤ by GitHub

2. Gesture animation

To build a swipe animation, we need to create a modifier that is capable of receiving drag events. This is where Modifier.pointerInput() is beneficial.

Modifier.pointerInput() creates a modifier for processing pointer input within the region of the modified element.

Inside its block, in a nutshell, we await for a touch event, then for a positive horizontal drag because we are implementing this on one side. When we get those, based on the velocity and the target offset, we choose between sliding back or sliding away the element. The offset value will be a parameter since we want to share it with other Composables and will be used as an offset of the current element.

fun Modifier.swipeToDelete(
offsetX: Animatable<Float, AnimationVector1D>,
maximumWidth: Float,
onDeleted: () -> Unit
): Modifier = composed {
pointerInput(Unit) {
// Used to calculate a settling position of a fling animation.
val decay = splineBasedDecay<Float>(this)
// 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()
// Prepare for drag events and record velocity of a fling.
val velocityTracker = VelocityTracker()
// Wait for drag events.
awaitPointerEventScope {
horizontalDrag(pointerId) { change ->
if (change.positionChange().x > 0 || offsetX.value > 0f) {
val horizontalDragOffset = offsetX.value + change.positionChange().x
launch {
offsetX.snapTo(horizontalDragOffset)
}
// Record the velocity of the drag.
velocityTracker.addPosition(change.uptimeMillis, change.position)
// Consume the gesture event, not passed to external
change.consumePositionChange()
}
}
}
// Dragging finished. Calculate the velocity of the fling.
var velocity = velocityTracker.calculateVelocity().x
// Calculate the eventual position where the fling should settle
// based on the current offset value and velocity
val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)
// Set the upper and lower bounds so that the animation stops when it
// reaches the edge.
offsetX.updateBounds(
lowerBound = 0f,
upperBound = size.width.toFloat()
)
launch {
// Slide back the element if the settling position does not go beyond
// the size of the element. Remove the element if it does.
if (targetOffsetX.absoluteValue <= maximumWidth) {
// Not enough velocity; Slide back.
offsetX.animateTo(targetValue = 0f, initialVelocity = velocity)
} else {
if (velocity >= 0f) {
// If the velocity is low, we create a fake velocity to make the animation look smoother
if (velocity <= 500f) {
velocity = 2000f
}
// Enough velocity to slide away the element to the edge.
offsetX.animateDecay(velocity, decay)
// The element was swiped away.
onDeleted()
}
}
}
}
}
}
.offset {
// Use the animating offset value here.
IntOffset(offsetX.value.roundToInt(), 0)
}
}

That’s an altered version of what the google team provided here.

Afterward, we add the swipeToDelete modifier along with its parameters to the item layout.

@ExperimentalAnimationApi
@Composable
fun ShoesCard(shoesArticle: ShoesArticle, onDeleted: () -> Unit) {
/**/
val funnelWidth = radius * 3
val sideShapeWidth = funnelWidth + particleRadius * 2
val offsetX = remember { Animatable(0f) }
Box {
Canvas(/**/) { /**/ }
Canvas(/**/) { /**/ }
Box(Modifier
.padding(horizontal = 16.dp)
.swipeToDelete(offsetX, maximumWidth = sideShapeWidth) {
onDeleted()
}
) { /**/ }
}
}
view raw ShoesCard.kt hosted with ❤ by GitHub

2.1. Side shape

At first state, we want that the funnel and the particle placed together just outside the screen. Since we didn’t specify a width to our canvas, the circle, the particle that is, will be drawn around a center placed where its parent starts. Hence, half of it will be visible to the user. So, minus its radius is the initial translation. And it will be added to the offset of the item to allow it to follow the item along the swiping process.
For the funnel shape, the initial translation should be minus its width and minus the particle radius so that the circle takes the lead. Now, we want to move it toward the opposite direction when it totally comes out. That is when its translation is positive. To solve this, we create an extension function that negates the translation whenever being positive.

val funnelInitialTranslation = funnelWidth particleRadius
val funnelTranslation = remember { mutableStateOf(funnelInitialTranslation) }
funnelTranslation.value = (offsetX.value + funnelInitialTranslation).negateIfPositive()
Box {
Canvas(
Modifier.height(itemHeightDp)
) {
translate(funnelTranslation.value) {
/* Funnel */
}
translate(offsetX.value particleRadius) {
/* Circle */
}
}
/**/
}
view raw ShoesCard.kt hosted with ❤ by GitHub

2.2. Explosion

Considering that we are going to translate the explosion particles, we need to translate their parent, which is the Canvas. So, we will use Modifier.offset() as we did when we implemented the swipe feature. We want to make the explosion blow up from the beginning of the particle of the side shape. Therefore, its initial translation will be minus the diameter of the circle. And to obstruct the explosion from trailing the item, we will use coerseAtMost() with the funnel width.

Now, we want to glide the explosion particles to create a wave motion. For that, we will create a state that holds a percentage of this procedure. It will be updated whenever funnelTranslation is positive(before being negated by the function we created). Afterward, we multiply the translations values with this percentage. And finally, we want to update the alpha of these particles too.

@ExperimentalAnimationApi
@Composable
fun ShoesCard(shoesArticle: ShoesArticle, onDeleted: () -> Unit) {
/**/
val screenWidth: Int
with(LocalConfiguration.current) {
screenWidth = this.screenWidthDp
}
/**/
val explosionPercentage = remember { mutableStateOf(0f) }
/**/
funnelTranslation.value = (offsetX.value + funnelInitialTranslation).negateIfPositive {
explosionPercentage.value = (offsetX.value + funnelInitialTranslation) / screenWidth
}
Box {
/**/
Canvas(modifier = Modifier
.height(itemHeightDp)
.offset {
IntOffset(
(offsetX.value.roundToInt() 2 * particleRadius.toInt()).coerceAtMost(
funnelWidth.toInt()
), 0
)
})
{
/**/
repeat(numberOfExplosionParticles / 2 + 1) {
val hTranslation = (cos(angle).toFloat() * explosionRadius) * explosionPercentage.value
val vTranslation = (sin(angle).toFloat() * explosionRadius) * explosionPercentage.value
translate(hTranslation, vTranslation) {
drawCircle(
color = shoesArticle.color,
radius = explosionParticleRadius,
alpha = explosionPercentage.value / 2
)
}
if (angle != 0.0 && angle != Math.PI) {
translate(hTranslation, vTranslation) {
drawCircle(
color = shoesArticle.color,
radius = explosionParticleRadius,
alpha = explosionPercentage.value / 2
)
}
}
angle += particleAngle
}
}
/**/
}
/**/
}
/**/
private fun Float.negateIfPositive(onPositive: () -> Unit): Float {
return if (this > 0) {
onPositive()
this
} else this
}
view raw ShoesCard.kt hosted with ❤ by GitHub

Animation | Jetpack Compose | Android Developers

Here is the full Github repository for this article:

That’s a wrap. Here, we reach the end of this article. See you in the next final one.

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.