android-jetpack-composeandroid-jetpack-compose-canvasjetpack-compose-animationandroid-jetpack-compose-animationandroid-jetpack-compose-gesture

How can I determine whether a 2D Point is within a Polygon or Complex Path with Jetpack Compose?


This is a share your knowledge, Q&A-style to explain how to detect whether a polygon or a complex shapes such as some section of path is touched as in gif below. Also it contains how to animate path scale, color using linear interpolation and using Matrix with Jetpack Compose Paths thanks to this quesiton.

How to scale group inside Jetpack Compose Vector

enter image description here


Solution

  • Easiest way to do to is creating a very small rectangle in touch position with

    val touchPath = Path().apply {
        addRect(
            Rect(
                center = it,
                radius = .5f
            )
        )
    }
    

    Then checking

    val differencePath =
        Path.combine(
            operation = PathOperation.Difference,
            touchPath,
            path
        )
    

    with path operation if difference path of in position and small rectangle path is empty.

    For map implementation first create a class that contains Path for drawing, Animatable for animating selected or deselected Paths.

    @Stable
    internal class AnimatedMapData(
        val path: Path,
        selected: Boolean = false,
        val animatable: Animatable<Float, AnimationVector1D> = Animatable(1f)
    ) {
        var isSelected by mutableStateOf(selected)
    }
    

    Inside tap gesture get rectangle and set selected and deselected datas.

    @Preview
    @Composable
    private fun AnimatedMapSectionPathTouchSample() {
    
        val animatedMapDataList = remember {
            Netherlands.PathMap.entries.map {
                val path = Path()
    
                path.apply {
                    it.value.forEach {
                        addPath(it)
                    }
    
                    val matrix = Matrix().apply {
                        preScale(5f, 5f)
                        postTranslate(-140f, 0f)
                    }
                    this.asAndroidPath().transform(matrix)
                }
    
                AnimatedMapData(path = path)
            }
        }
    
        // This is for animating paths on selection or deselection animations
        animatedMapDataList.forEach {
            LaunchedEffect(key1 = it.isSelected) {
                val targetValue = if (it.isSelected) 1.2f else 1f
    
                it.animatable.animateTo(targetValue, animationSpec = tween(1000))
            }
        }
    
        Column {
            Box(
                modifier = Modifier
                    .fillMaxWidth()
                    .aspectRatio(1f)
                    .background(Blue400)
            ) {
    
    
                Canvas(
                    modifier = Modifier
                        .pointerInput(Unit) {
                            detectTapGestures {
    
                                val touchPath = Path().apply {
                                    addRect(
                                        Rect(
                                            center = it,
                                            radius = .5f
                                        )
                                    )
                                }
    
                                animatedMapDataList.forEachIndexed { index, data ->
    
                                    val path = data.path
                                    val differencePath =
                                        Path.combine(
                                            operation = PathOperation.Difference,
                                            touchPath,
                                            path
                                        )
    
                                    val isInBounds = differencePath.isEmpty
                                    if (isInBounds) {
                                        data.isSelected = data.isSelected.not()
                                    } else {
                                        data.isSelected = false
                                    }
                                }
    
                            }
                        }
                        .fillMaxWidth()
                        .aspectRatio(1f)
                        .clipToBounds()
                ) {
    
                    animatedMapDataList.forEach { data ->
    
                        val path = data.path
    
                        if (data.isSelected.not()) {
                            withTransform(
                                {
                                    val scale = data.animatable.value
                                    scale(
                                        scaleX = scale,
                                        scaleY = scale,
                                        // Set scale position as center of path
                                        pivot = data.path.getBounds().center
                                    )
                                }
                            ) {
                                drawPath(path, Color.Black)
                                drawPath(path, color = Color.White, style = Stroke(1.dp.toPx()))
                            }
                        }
                    }
    
                    // Draw selected path above other paths
                    animatedMapDataList.firstOrNull { it.isSelected }?.let { data ->
    
                        val path = data.path
    
                        withTransform(
                            {
                                val scale = data.animatable.value
                                scale(
                                    scaleX = scale,
                                    scaleY = scale,
                                    // Set scale position as center of path
                                    pivot = data.path.getBounds().center
                                )
                            }
                        ) {
                            drawPath(
                                path = path,
                                color = lerp(
                                    start = Color.Black,
                                    stop = Orange400,
                                    // animate color via linear interpolation
                                    fraction = (data.animatable.value - 1f) / 0.2f
                                )
                            )
                            drawPath(path, color = Color.White, style = Stroke(1.dp.toPx()))
    
                        }
                    }
                }
            }
        }
    }
    

    Map that contains some section of Netherlands and other samples available link below

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

    For touching and dragging non-uniform shapes you need set a drag gesture and holding touched index and setting Matrix of selected path with

    modifier = Modifier
        .background(Blue400)
        .fillMaxWidth()
        .aspectRatio(1f)
        .pointerInput(Unit) {
            detectDragGestures(
                onDragStart = { offset: Offset ->
    
                    val touchPath = Path().apply {
                        addRect(
                            Rect(
                                center = offset,
                                radius = .5f
                            )
                        )
                    }
    
                    pathDataList.forEachIndexed { index, data ->
    
                        val path = data.path
    
                        val differencePath =
                            Path.combine(
                                operation = PathOperation.Difference,
                                touchPath,
                                path
                            )
    
                        val isInBounds = differencePath.isEmpty
    
                        if (isInBounds) {
                            touchIndex = index
                        }
                    }
                },
                onDrag = { change: PointerInputChange, dragAmount: Offset ->
                    val pathData = pathDataList.getOrNull(touchIndex)
    
                    pathData?.let {
    
                        val matrix = Matrix().apply {
                            postTranslate(dragAmount.x, dragAmount.y)
                        }
    
                        pathData.path.asAndroidPath().transform(matrix)
    
                        pathDataList[touchIndex] = it.copy(
                            center = dragAmount
                        )
    
                    }
    
                },
                onDragCancel = {
                    touchIndex = -1
                },
                onDragEnd = {
                    touchIndex = -1
                }
            )
        }
    

    Data class is

    @Immutable
    data class PathData(
        val path: Path,
        val center: Offset
    )
    

    Full sample

    @Preview
    @Composable
    private fun PathTouchSample() {
    
        var touchIndex by remember {
            mutableIntStateOf(-1)
        }
        val pathDataList = remember {
            mutableStateListOf<PathData>().apply {
                repeat(5) {
                    val cx = 170f * (it + 1)
                    val cy = 170f * (it + 1)
                    val radius = 120f
                    val sides = 3 + it
                    val path = createPolygonPath(cx, cy, sides, radius)
                    add(
                        PathData(
                            path = path,
                            center = Offset(0f, 0f)
                        )
                    )
                }
            }
        }
    
        Canvas(
            modifier = Modifier
                .background(Blue400)
                .fillMaxWidth()
                .aspectRatio(1f)
                .pointerInput(Unit) {
                    detectDragGestures(
                        onDragStart = { offset: Offset ->
    
                            val touchPath = Path().apply {
                                addRect(
                                    Rect(
                                        center = offset,
                                        radius = .5f
                                    )
                                )
                            }
    
                            pathDataList.forEachIndexed { index, data ->
    
                                val path = data.path
    
                                val differencePath =
                                    Path.combine(
                                        operation = PathOperation.Difference,
                                        touchPath,
                                        path
                                    )
    
                                val isInBounds = differencePath.isEmpty
    
                                if (isInBounds) {
                                    touchIndex = index
                                }
                            }
                        },
                        onDrag = { change: PointerInputChange, dragAmount: Offset ->
                            val pathData = pathDataList.getOrNull(touchIndex)
    
                            pathData?.let {
    
                                val matrix = Matrix().apply {
                                    postTranslate(dragAmount.x, dragAmount.y)
                                }
    
                                pathData.path.asAndroidPath().transform(matrix)
    
                                pathDataList[touchIndex] = it.copy(
                                    center = dragAmount
                                )
    
                            }
    
                        },
                        onDragCancel = {
                            touchIndex = -1
                        },
                        onDragEnd = {
                            touchIndex = -1
                        }
                    )
                }
        ) {
    
            pathDataList.forEachIndexed { index: Int, pathData: PathData ->
    
                val path = pathData.path
    
                if (touchIndex != index) {
                    drawPath(
                        path,
                        color = Color.Black
                    )
                }
            }
    
            pathDataList.getOrNull(touchIndex)?.let { pathData ->
    
                val path = pathData.path
    
                drawPath(
                    path = path,
                    color = Color.Green
                )
            }
        }
    }