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

1

4

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

Tragus answered 30/11, 2023 at 15:59 Comment(1)
Same ol' Point in polygon problem! I suppose solving with Ray casting algorithm is rather hard because of how paths and polygons are implemented in Android in this case instead of your solution.Froghopper
T
5

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
            )
        }
    }
}
Tragus answered 30/11, 2023 at 15:59 Comment(4)
Hi, I see you've opted for regular Canvas drawing instead of using vectorPainter for the tappable map. How can I make it that the paths in the Canvas scale to fit the size of the Canvas? I've created this gist: gist.github.com/NielsMasdorp/d7096bd4c19b3166d05f77ed72b4c07c I want the Canvas to be 216dp x 252dp including 16dp padding, but struggling to do so. using the Matrix preScale and postTranslate doesn't seem the way to do it when scaling for different devices. Any thoughts?Sassaby
@NielsMasdorp here i added how to scale and offset your path based on canvas size. github.com/SmartToolFactory/Jetpack-Compose-Tutorials/blob/…Tragus
You can create a path for scale that you put all of the paths in the list. After getting its size you can get coefficients regarding to canvas size. Let's say total path size is 100x120px while your canvas size 1000x1200 you get scaleX and scaleY using these values where i used 5 for demonstration. I used fillMaxWidth for my tutorial but it should work with any size, i tested with 216dp x 252dp too.Tragus
You are welcome. Thank you for your question, i was looking forward to playing with paths to create map or detect paths via touch for some time. This was a good opportunity for me as well.Tragus

© 2022 - 2024 — McMap. All rights reserved.