Search code examples
androidandroid-jetpack-composeuicomponents

Trying to Recreate Wordscapes circle input thingy in Jetpack-Compose


I'm trying to recreate Wordscapes' circle input thingy.

enter image description here

That guy.

I have about 95% of it recreated. The only thing I'm missing is the removal side of it. When you drag back to the previous circle and it's removed.

I've tried a few things already and I keep running into similar issues. The line wigs out because it's removing and adding constantly. I've tried keeping a check, the last dot variable, nothing I seem to do has the intended effect to match that moving back to the circle to remove that from the list.

I have my code here

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun <T : Any> PatternInput2(
    options: List<T>,
    modifier: Modifier = Modifier,
    optionToString: (T) -> String = { it.toString() },
    dotsColor: Color,
    dotsSize: Float = 50f,
    letterColor: Color = dotsColor,
    sensitivity: Float = dotsSize,
    linesColor: Color = dotsColor,
    linesStroke: Float,
    circleStroke: Stroke = Stroke(width = linesStroke),
    animationDuration: Int = 200,
    animationDelay: Long = 100,
    onStart: (Dot<T>) -> Unit = {},
    onDotConnected: (Dot<T>) -> Unit = {},
    onResult: (List<Dot<T>>) -> Unit = {}
) {
    val scope = rememberCoroutineScope()
    val dotsList = remember(options) { mutableListOf<Dot<T>>() }
    var previewLine by remember {
        mutableStateOf(Line(Offset(0f, 0f), Offset(0f, 0f)))
    }
    val connectedLines = remember { mutableListOf<Line>() }
    val connectedDots = remember { mutableListOf<Dot<T>>() }

    Canvas(
        modifier.pointerInteropFilter {
            when (it.action) {
                MotionEvent.ACTION_DOWN -> {
                    for (dots in dotsList) {
                        if (
                            it.x in Range(dots.offset.x - sensitivity, dots.offset.x + sensitivity) &&
                            it.y in Range(dots.offset.y - sensitivity, dots.offset.y + sensitivity)
                        ) {
                            connectedDots.add(dots)
                            onStart(dots)
                            scope.launch {
                                dots.size.animateTo(
                                    (dotsSize * 1.8).toFloat(),
                                    tween(animationDuration)
                                )
                                delay(animationDelay)
                                dots.size.animateTo(dotsSize, tween(animationDuration))
                            }
                            previewLine = previewLine.copy(start = Offset(dots.offset.x, dots.offset.y))
                        }
                    }
                }
                MotionEvent.ACTION_MOVE -> {
                    previewLine = previewLine.copy(end = Offset(it.x, it.y))
                    dotsList.find { dots ->
                        it.x in Range(
                            dots.offset.x - sensitivity,
                            dots.offset.x + sensitivity
                        ) && it.y in Range(
                            dots.offset.y - sensitivity,
                            dots.offset.y + sensitivity
                        )
                    }
                        ?.let { dots ->
                            if (dots !in connectedDots) {
                                if (previewLine.start != Offset(0f, 0f)) {
                                    connectedLines.add(
                                        Line(
                                            start = previewLine.start,
                                            end = dots.offset
                                        )
                                    )
                                }
                                connectedDots.add(dots)
                                onDotConnected(dots)
                                scope.launch {
                                    dots.size.animateTo(
                                        (dotsSize * 1.8).toFloat(),
                                        tween(animationDuration)
                                    )
                                    delay(animationDelay)
                                    dots.size.animateTo(dotsSize, tween(animationDuration))
                                }
                                previewLine = previewLine.copy(start = dots.offset)
                            }
                        }
                }
                MotionEvent.ACTION_UP -> {
                    previewLine = previewLine.copy(start = Offset(0f, 0f), end = Offset(0f, 0f))
                    onResult(connectedDots)
                    connectedLines.clear()
                    connectedDots.clear()
                }
            }
            true
        }
    ) {
        drawCircle(
            color = dotsColor,
            radius = size.width / 2 - circleStroke.width,
            style = circleStroke,
            center = center
        )

        val radius = (size.width / 2) - (dotsSize * 2) - circleStroke.width

        if (dotsList.size < options.size) {
            options.forEachIndexed { index, t ->
                val angleInDegrees = ((index.toFloat() / options.size.toFloat()) * 360.0) + 50.0
                val x = -(radius * sin(Math.toRadians(angleInDegrees))).toFloat() + (size.width / 2)
                val y = (radius * cos(Math.toRadians(angleInDegrees))).toFloat() + (size.height / 2)

                dotsList.add(
                    Dot(
                        id = t,
                        offset = Offset(x = x, y = y),
                        size = Animatable(dotsSize)
                    )
                )
            }
        }
        if (previewLine.start != Offset(0f, 0f) && previewLine.end != Offset(0f, 0f)) {
            drawLine(
                color = linesColor,
                start = previewLine.start,
                end = previewLine.end,
                strokeWidth = linesStroke,
                cap = StrokeCap.Round
            )
        }
        for (dots in dotsList) {
            drawCircle(
                color = dotsColor,
                radius = dotsSize * 2,
                style = Stroke(width = 2.dp.value),
                center = dots.offset
            )
            drawIntoCanvas {
                it.nativeCanvas.drawText(
                    optionToString(dots.id),
                    dots.offset.x,
                    dots.offset.y + (dots.size.value / 3),
                    Paint().apply {
                        color = letterColor.toArgb()
                        textSize = dots.size.value
                        textAlign = Paint.Align.CENTER
                    }
                )
            }
        }
        for (line in connectedLines) {
            drawLine(
                color = linesColor,
                start = line.start,
                end = line.end,
                strokeWidth = linesStroke,
                cap = StrokeCap.Round
            )
        }

    }
}

data class Dot<T : Any>(
    val id: T,
    val offset: Offset,
    val size: Animatable<Float, AnimationVector1D>
)

data class Line(
    val start: Offset,
    val end: Offset
)

@Preview
@Composable
fun PatternInput2Preview() {
    var wordGuess by remember { mutableStateOf("") }
    Column {
        Text(wordGuess)
        PatternInput2(
            options = listOf("h", "e", "l", "l", "o", "!", "!"),
            modifier = Modifier
                .width(500.dp)
                .height(1000.dp)
                .background(Color.Black),
            optionToString = { it },
            dotsColor = Color.White,
            dotsSize = 100f,
            letterColor = Color.White,
            sensitivity = 50.sp.value,
            linesColor = Color.White,
            linesStroke = 30f,
            circleStroke = Stroke(width = 30f),
            animationDuration = 200,
            animationDelay = 100,
            onStart = {
                wordGuess = ""
                wordGuess = it.id
            },
            onDotConnected = { wordGuess = "$wordGuess${it.id}" },
            onResult = { /*Does a final thing*/ }
        )
    }
}

I have tried to put in:

onDotRemoved = { wordGuess = wordGuess.removeSuffix(it.id.toString()) },

The logic to the outside code is working as intended, but it's the component itself that's having the issue.

I've tried to do this in the ACTION_MOVE after the current code there, but this isn't working as it should:

val dots = connectedDots.lastOrNull()
if (removableDot != null && connectedDots.size >= 2) {
    if (
        it.x in Range(
            removableDot!!.offset.x - sensitivity,
            removableDot!!.offset.x + sensitivity
        ) &&
        it.y in Range(
            removableDot!!.offset.y - sensitivity,
            removableDot!!.offset.y + sensitivity
        )// && canRemove
    ) {
        canRemove = false
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            connectedLines.removeIf { it.end == removableDot!!.offset }
        }
        connectedDots.removeLastOrNull()
        onDotRemoved(removableDot!!)
        removableDot = null
        connectedDots.lastOrNull()?.let { previewLine = previewLine.copy(start = it.offset) }
    } else if (
        it.x !in Range(
            removableDot!!.offset.x - sensitivity,
            removableDot!!.offset.x + sensitivity
        ) &&
        it.y !in Range(
            removableDot!!.offset.y - sensitivity,
            removableDot!!.offset.y + sensitivity
        )
    ) {
        canRemove = true
    }
}

Solution

  • I actually found the solution to this! I was doing a few things funky here. For starters:

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                                    connectedLines.removeIf { it.end == removableDot!!.offset }
                                }
    

    Was the cause of the funky line issues I was having. Replacing it with:

    connectedLines.removeLastOrNull()
    

    Was the perfect fix for it! I didn't even think of just removing the last one. I slightly over engineered this part.

    The second issue I had ran into was the onDotRemoved not working correctly. Since I'm doing the removal side of this like onDotRemoved = { wordGuess = wordGuess.removeSuffix(it.id.toString()) }, so, I was curious as to why it wasn't working:

    connectedDots.removeLastOrNull()
            onDotRemoved(removableDot!!)
    

    It didn't make sense to me why this wasn't working...BUT! removableDot SHOULD be the last item in connectedDots so instead of using removableDot, I ended up doing:

    connectedDots.removeLastOrNull()?.let(onDotRemoved)
    

    and it worked perfectly!