Search code examples
androidandroid-jetpack-composeandroid-jetpack-compose-canvasandroid-jetpack-compose-animationandroid-jetpack-compose-gesture

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


Solution

  • 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