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

216 Views Asked by At

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

1

There are 1 best solutions below

5
On BEST ANSWER

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
            )
        }
    }
}