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
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)
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")
}
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.
Full samples and more samples about paths are available here.