在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 = {
相关产品推荐
相关产品推荐

