Creating an Animated Arrow Pointer in Jetpack Compose

Kappdev
6 min readMay 26, 2024

--

Welcome 👋

In this article, we’ll explore how to craft a stunning animated arrow pointer using Jetpack Compose and enhance your app’s appearance in 5 minutes.

Stay tuned, and let’s dive in! 🚀

Defining the Function

Let’s start by declaring the AnimatedArrowPointer function and go through its parameters.

@Composable
fun AnimatedArrowPointer(
modifier: Modifier,
color: Color,
isVisible: Boolean = true,
strokeWidth: Dp = 2.dp,
pointerSize: Dp = 12.dp,
dashLength: Dp? = 4.dp,
strokeCap: StrokeCap = StrokeCap.Round,
pointerShape: Shape = SimpleArrow,
animationSpec: AnimationSpec<Float> = tween(3000)
)

⚒️ ️️Parameters breakdown

modifier ➜ Modifier to be applied to the pointer layout.

color ➜ Color of the arrow pointer.

isVisible ➜ Determines if the arrow pointer is visible.

strokeWidth ➜ Width of the arrow pointer’s stroke.

pointerSize ➜ Size of the arrow pointer.

dashLength ➜ Length of the stroke dashes; null means a solid stroke.

strokeCap ➜ Style of stroke endings in the arrow pointer.

pointerShape ➜ Shape of the arrow pointer.

animationSpec ➜ Specifies arrow animation behavior.

Paths

Alright, let’s proceed to the backbone of today's article.

ArrowPath

The createArrowPath function takes the width and height of the canvas and returns a Path drawn within these boundaries:

fun createArrowPath(width: Float, height: Float): Path {
return Path().apply {
moveTo(width * 0.2f, 0f) // 0
cubicTo(
x1 = 0f, y1 = height * 0.25f, // 1
x2 = width * 0.1f, y2 = height * 0.7f, // 2
x3 = width * 0.65f, y3 = height * 0.6f // 3
)
cubicTo(
x1 = width, y1 = height * 0.50f, // 4
x2 = width * 0.48f, y2 = height * 0.20f, // 5
x3 = width * 0.3f, y3 = height * 0.5f // 6
)
cubicTo(
x1 = width * 0.2f, y1 = height * 0.70f, // 7
x2 = width * 0.5f, y2 = height, // 8
x3 = width, y3 = height // 9
)
}
}

If the code looks confusing, check out the picture below 👇

SimplePointer

Next, we need to craft a pointer. Let’s define an object that implements the Shape interface:

object SimpleArrow : Shape {
override fun createOutline(
size: Size,
layoutDirection: LayoutDirection,
density: Density
): Outline {
val width = size.width
val height = size.height

val path = Path().apply {
moveTo(0f, 0f) // 1
lineTo(width, height * 0.5f) // 2
lineTo(0f, height) // 3
lineTo(width * 0.5f, height * 0.5f) // 4
close() // line to 1
}
return Outline.Generic(path)
}
}

For clarity, you can check out the picture 👇

Drawing

Now, we are close to the implementation of the function. But before that, we need to define two support functions for drawing the path and the pointer head.

Arrow Path Drawing

Drawing the path is straightforward; we just draw a normal path on the canvas with specified properties:

fun DrawScope.drawPathSegment(
path: Path,
color: Color,
strokeWidth: Dp,
strokeCap: StrokeCap,
dashLength: Dp? = null
) {
drawPath(
path = path,
color = color,
style = Stroke(
width = strokeWidth.toPx(),
cap = strokeCap,
// Draw dashed strokes if dashLength is specified
// Otherwise, use a solid line
pathEffect = dashLength?.let { dash ->
PathEffect.dashPathEffect(
floatArrayOf(dash.toPx(), dash.toPx())
)
}
)
)
}

Pointer Head Drawing

To draw the head dynamically at the end of the current path segment, we will utilize getPosition and getTangent from PathMeasure to get the position of the pointer and the angle to point in the right direction.

fun DrawScope.drawPointerHead(
pathMeasure: PathMeasure,
stopDistance: Float,
pointerSize: Dp,
color: Color,
pointerShape: Shape
) {
// Calculate the point and tangent at the specified distance
val headPoint = pathMeasure.getPosition(stopDistance)
val tangent = pathMeasure.getTangent(stopDistance)

// Calculate the rotation angle of the arrowhead based on the tangent
val angle = atan2(tangent.y.toDouble(), tangent.x.toDouble()).toFloat() * 180 / Math.PI.toFloat()

// Define the size and outline of the arrowhead
val headSize = Size(pointerSize.toPx(), pointerSize.toPx())
val headOutline = pointerShape.createOutline(headSize, layoutDirection, this)

// Translate and rotate the canvas to position and orient the arrowhead
translate(headPoint.x - (headSize.width / 2), headPoint.y - (headSize.height / 2)) {
rotate(angle, pivot = headSize.center) {
// Draw the arrowhead outline
drawOutline(headOutline, color = color)
}
}
}

The Implementation

Finally, let’s put it all together and apply the animation.

@Composable
fun AnimatedArrowPointer(
/* Parameters... */
) {
// Define an Animatable for the animation progress value
val pathCompletion = remember { Animatable(0f) }

// Launch the animation based on the visibility state change
LaunchedEffect(isVisible) {
if (isVisible) {
// Animate path
pathCompletion.animateTo(1f, animationSpec)
} else {
// Immediately hide the path
pathCompletion.snapTo(0f)
}
}

// Draw the animated arrow on a canvas
Canvas(
// Ensure appropriate ratio to avoid path distortion
modifier.aspectRatio(0.6f)
) {
// Create the path for the arrow based on the canvas size
val arrowPath = createArrowPath(size.width, size.height)

// Measure the path to get its length and segment information
val pathMeasure = PathMeasure().apply {
setPath(arrowPath, false)
}

// Create a path segment based on the current animation progress
val pathSegment = Path()
val stopDistance = pathCompletion.value * pathMeasure.length
pathMeasure.getSegment(0f, stopDistance, pathSegment, true)

// Draw the current segment of the arrow path with the specified properties
drawPathSegment(pathSegment, color, strokeWidth, strokeCap, dashLength)

// If the path has been drawn to some extent, draw the arrowhead
if (pathCompletion.value > 0) {
drawPointerHead(pathMeasure, stopDistance, pointerSize, color, pointerShape)
}
}
}

Congratulations 🥳! We’ve successfully built it 👏. For the complete code implementation, you can access it on GitHub Gist 🧑‍💻. Now, let’s explore how we can put it to use.

Advertisement

Are you learning a foreign language and struggling with new vocabulary? Then, I strongly recommend you check out this words-learning app, which will make your journey easy and convenient!

WordBook

Usage

You can easily customize the position of the arrow pointer by applying simple transformation modifiers to the layout. Here are common scenarios:

Bottom-Right (Default)

The bottom-right position is the default pointing direction:

AnimatedArrowPointer(
modifier = Modifier.size(140.dp),
color = Color.Red
)

Top-Right

To point to the top-right, rotate the layout by -90 degrees:

AnimatedArrowPointer(
modifier = Modifier
.size(140.dp)
.rotate(-90f),
color = Color.Red
)

Bottom-Left

To point to the bottom-left, reflect the layout horizontally by scaling it with -1f on the x-axis:

AnimatedArrowPointer(
modifier = Modifier
.size(140.dp)
.scale(-1f, 1f),
color = Color.Red
)

Top-Left

For the top-left direction, first reflect the layout horizontally, then rotate it by -90 degrees:

AnimatedArrowPointer(
modifier = Modifier
.size(140.dp)
.scale(-1f, 1f)
.rotate(-90f),
color = Color.Red
)

You might also like 👇

Thank you for reading this article! ❤️ I hope you’ve found it enjoyable and valuable. Feel free to show your appreciation by hitting the clap 👏 if you liked it and follow Kappdev for more exciting articles 😊

Happy coding!

--

--

Kappdev

💡 Curious Explorer 🧭 Kotlin and Compose enthusiast 👨‍💻 Passionate about self-development and growth ❤️‍🔥 Push your boundaries 🚀