Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to track position, angle or position is in Path or track letter drawing in Jetpack Compose?

This is a share your knowledge, Q&A-style question inspired from a developer asking about how to track letters in Jetpack Compose Path as in image below. And includes how to track angle as well to animate any drawable based on current tangent of the path

enter image description here

enter image description here

enter image description here

like image 351
Thracian Avatar asked Nov 17 '25 17:11

Thracian


1 Answers

To get path segments and any information about Path segments, position or angle we can use PathMeasure class. Compose counterpart is more compatible than previous one.

val pathMeasure = remember {
    PathMeasure()
}

To animate drawable inside path i used Animatable

val animatable = remember {
    Animatable(0f)
}

After creating sinus path we should set path to PathMeasure as

pathMeasure.setPath(path = path, forceClosed = false)

Animate position and rotation of ImageVector on a Path

And to get position, angle and to draw tracking path in first gif we need

val pathLength = pathMeasure.length
val progress = animatable.value.coerceIn(0f, 1f)
val distance = pathLength * progress

val position = pathMeasure.getPosition(distance)
val tangent = pathMeasure.getTangent(distance)
val tan = (360 + atan2(tangent.y, tangent.x) * 180 / Math.PI) % 360
pathMeasure.getSegment(startDistance = 0f, stopDistance = distance, trackPath)

And to draw ImageVector rotate as much as path is curved and position it at the position we get from PathMeasure

withTransform(
    transformBlock = {
        rotate(degrees = tan.toFloat() + 28, pivot = position)
        translate(
            left = position.x - iconSize / 2,
            top = position.y - iconSize / 2
        )
    }
) {
    with(painter) {
        draw(
            size = painter.intrinsicSize
        )
    }
} 

To start animation

Button(
    modifier = Modifier.fillMaxWidth(),
    onClick = {
        coroutineScope.launch {
            trackPath.reset()
            animatable.snapTo(0f)
            animatable.animateTo(
                targetValue = 1f,
                animationSpec = tween(3000, easing = LinearEasing)
            )
        }
    }
) {
    Text("Animate")
}

Track letter drawing or user position on a Path

For this i created a data class

data class PathSegmentInfo(
    val index: Int,
    val position: Offset,
    val distance: Float,
    val tangent: Double,
    val isCompleted: Boolean = false
)

After creating any Path fill a List as

val segmentInfoList = remember {
    mutableStateListOf<PathSegmentInfo>()
}

fill it as

if (path.isEmpty) {

path.addPath(createPolygonPath(cx, cy, 8, radius))
pathMeasure.setPath(path = path, forceClosed = false)

val step = 1
val pathLength = pathMeasure.length / 100f

for ((index, percent) in (0 until 100 step step).withIndex()) {

    val destination = Path()

    val distance = pathLength * percent
    pathMeasure.getSegment(
        startDistance = distance,
        stopDistance = pathLength * (percent + step),
        destination = destination
    )

    val position = pathMeasure.getPosition(distance = distance)
    val tangent = pathMeasure.getTangent(distance = distance)

    val tan = (360 + atan2(tangent.y, tangent.x) * 180 / Math.PI) % 360

    segmentInfoList.add(
        PathSegmentInfo(
            index = index,
            position = position,
            distance = distance,
            tangent = tan
        )
    )
    pathSegmentList.add(destination)
}

}

And before drag starts check how close touch position to first or first few position of segments in our info list with

onDragStart = {

    isTouched = false

    val currentPosition = it
    var distance = Float.MAX_VALUE
    var tempIndex = -1

    segmentInfoList.forEachIndexed { index, pathSegmentInfo ->
        val currentDistance =
            pathSegmentInfo.position.minus(currentPosition)
                .getDistanceSquared()


        if (currentDistance < nearestTouchDistance * nearestTouchDistance &&
            currentDistance < distance
        ) {
            distance = currentDistance
            tempIndex = index
        }
    }

    val validTouch = if (completedIndex == segmentInfoList.lastIndex) {
        trackPath.reset()
        tempIndex == 0
    } else {
        (tempIndex in completedIndex..completedIndex + 2)
    }

    if (validTouch) {
        currentIndex = tempIndex.coerceAtLeast(0)
        isTouched = true
        text = "Touched index $currentIndex"
        userPath.moveTo(currentPosition.x, currentPosition.y)
    } else {
        text =
            "Not correct position" +
                    "\ntempIndex: $tempIndex, nearestPositionIndex: $currentIndex"
    }
}

Check here is checking if user touched close to last completed index or finished loop and touched first index.

And on drag if it's a valid touch from start do

onDrag = { change: PointerInputChange, _ ->

    if (isTouched) {

        val currentPosition = change.position
        var distance = Float.MAX_VALUE
        var tempIndex = -1
        userPath.lineTo(currentPosition.x, currentPosition.y)

        segmentInfoList.forEachIndexed { index, pathSegmentInfo ->
            val currentDistance =
                pathSegmentInfo.position.minus(currentPosition)
                    .getDistanceSquared()

            if (currentDistance < distance) {
                distance = currentDistance
                tempIndex = index
            }
        }

        val dragMinDistance =
            (nearestTouchDistance * .65f * nearestTouchDistance * .65)

        if (completedIndex in segmentInfoList.lastIndex - 2..segmentInfoList.lastIndex ||
            tempIndex == 0
        ) {
            trackPath.reset()
            currentIndex = tempIndex
        } else if (distance > dragMinDistance) {
            text = "on drag You moved out of path"
            isTouched = false
        } else if (tempIndex < completedIndex
        ) {
            text =
                "on drag You moved back" +
                        "\ntempIndex: $tempIndex, completedIndex: $completedIndex"
            isTouched = false
        } else {
            currentIndex = tempIndex
        }
    }
}

First thing to check whether user moves pointer out of accepted threshold from path which i set nearestTouchDistance and check if it's starting loop, this is for demonstration tho not required for letter tracking, and check if it moves back to previous position in the list.

Edit

If you are checking complex letter that have path positions close to each other when checking distance onDragStart only check for the ones that are close to completedIndex instead of whole list, also for optimization it would always be better to check the ones close to completed last index since distance to to other ones are not required in the first place.

Full samples and more samples about paths are available here.

https://github.com/SmartToolFactory/Jetpack-Compose-Tutorials/blob/master/Tutorial1-1Basics/src/main/java/com/smarttoolfactory/tutorial1_1basics/chapter6_graphics/Tutorial6_1_7PathMeasure.kt

like image 195
Thracian Avatar answered Nov 20 '25 08:11

Thracian



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!