You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

在Jetpack Compose中复刻Wordscapes环形输入的回退删除问题

Jetpack Compose 环形输入组件回退删除功能修复方案

我正在用Jetpack Compose复刻Wordscapes的环形输入组件,目前已完成95%开发,仅缺失回退删除功能——当拖拽回到上一个选中的圆点时,需要将该圆点从已选中列表中移除。尝试过多种实现方案,始终遇到线条异常抖动、删除与添加操作频繁触发的问题,试过记录最后一个圆点、添加状态校验等方式都没解决。外部逻辑中onDotRemoved功能正常,但组件内部实现存在问题。

原有问题代码

@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*/ }
        )
    }
}

尝试过的无效修改

我先给组件添加了onDotRemoved参数,外部逻辑正常:

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

之后在ACTION_MOVE中添加了以下代码,但效果不符合预期:

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

修复方案

核心问题在于原逻辑没有清晰区分“添加新点”和“回退删除”的触发条件,且缺少状态锁避免重复操作。以下是修复后的完整代码:

@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 = {},
    onDotRemoved: (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>>() }
    // 标记是否刚执行过删除操作,避免重复触发
    var justRemoved by remember { mutableStateOf(false) }

    Canvas(
        modifier.pointerInteropFilter { event ->
            when (event.action) {
                MotionEvent.ACTION_DOWN -> {
                    justRemoved = false
                    dotsList.find { dot ->
                        event.x in dot.offset.x - sensitivity..dot.offset.x + sensitivity &&
                        event.y in dot.offset.y - sensitivity..dot.offset.y + sensitivity
                    }?.let { targetDot ->
                        connectedDots.add(targetDot)
                        onStart(targetDot)
                        scope.launch {
                            targetDot.size.animateTo(
                                dotsSize * 1.8f,
                                tween(animationDuration)
                            )
                            delay(animationDelay)
                            targetDot.size.animateTo(dotsSize, tween(animationDuration))
                        }
                        previewLine = previewLine.copy(start = targetDot.offset)
                    }
                }
                MotionEvent.ACTION_MOVE -> {
                    previewLine = previewLine.copy(end = Offset(event.x, event.y))
                    dotsList.find { dot ->
                        event.x in dot.offset.x - sensitivity..dot.offset.x + sensitivity &&
                        event.y in dot.offset.y - sensitivity..dot.offset.y + sensitivity
                    }?.let { targetDot ->
                        when {
                            // 情况1:目标点是已选中列表的倒数第二个(上一个点),且不是刚删除完,执行删除
                            connectedDots.size >= 2 && 
                            targetDot == connectedDots[connectedDots.size - 2] && 
                            !justRemoved -> {
                                // 移除对应的连线
                                connectedLines.removeLastOrNull()
                                // 移除最后一个选中的点
                                val removedDot = connectedDots.removeLast()
                                onDotRemoved(removedDot)
                                // 更新预览线起点为新的最后一个点
                                connectedDots.lastOrNull()?.let {
                                    previewLine = previewLine.copy(start = it.offset)
                                }
                                justRemoved = true
                                // 给删除操作加短暂延迟,避免重复触发
                                scope.launch {
                                    delay(150)
                                    justRemoved = false
                                }
                            }
                            // 情况2:目标点未被选中,执行添加
                            targetDot !in connectedDots && !justRemoved -> {
                                connectedLines.add(
                                    Line(
                                        start = previewLine.start,
                                        end = targetDot.offset
                                    )
                                )
                                connectedDots.add(targetDot)
                                onDotConnected(targetDot)
                                scope.launch {
                                    targetDot.size.animateTo(
                                        dotsSize * 1.8f,
                                        tween(animationDuration)
                                    )
                                    delay(animationDelay)
                                    targetDot.size.animateTo(dotsSize, tween(animationDuration))
                                }
                                previewLine = previewLine.copy(start = targetDot.offset)
                                justRemoved = false
                            }
                        }
                    }
                }
                MotionEvent.ACTION_UP -> {
                    justRemoved = false
                    previewLine = Line(Offset(0f, 0f), 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
            )
        }
        dotsList.forEach { dot ->
            drawCircle(
                color = dotsColor,
                radius = dotsSize * 2,
                style = Stroke(width = 2.dp.value),
                center = dot.offset
            )
            drawIntoCanvas {
                it.nativeCanvas.drawText(
                    optionToString(dot.id),
                    dot.offset.x,
                    dot.offset.y + (dot.size.value / 3),
                    Paint().apply {
                        color = letterColor.toArgb()
                        textSize = dot.size.value
                        textAlign = Paint.Align.CENTER
                    }
                )
            }
        }
        connectedLines.forEach { line ->
            drawLine(
                color = linesColor,
                start = line.start,
                end = line.end,
                strokeWidth = linesStroke,
                cap = StrokeCap.Round
            )
        }
    }
}

// 保持Dot和Line数据类不变
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, color = Color.White, fontSize = 32.sp)
        PatternInput2(
            options = listOf("h", "e", "l", "l", "o", "!", "!"),
            modifier = Modifier
                .width(500.dp)
                .height(500.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 = {
相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.11 15:30:49