I'm trying to recreate Wordscapes' circle input thingy.
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
}
}
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!