introduce
The project needs to use a radar diagram to show the different proportions of each property, and the text is wrapped according to the size of the control.
rendering
How to implement
1. Draw three circles in the background
Draw from the outer circle to the inner circle so that the color of the inner circle correctly covers the outer circle, style = Stroke(2f) is used to draw the border of the circle.
val CIRCLE_TURN = 3
val center = Offset(size.width / 2, size.height / 2)
val textNeedRadius = 25.dp.toPx() // Text draw range
val radarRadius = center.x - textNeedRadius
val turnRadius = radarRadius / CIRCLE_TURN
for (turn in 0 until CIRCLE_TURN) {
drawCircle(colors[turn], radius = turnRadius * (CIRCLE_TURN - turn))
drawCircle(colors[3], radius = turnRadius * (CIRCLE_TURN - turn), style = Stroke(2f))}Copy the code
2. Draw a dotted line inside the ring
Figure out the Angle required for each block using 360/data.size.
We know that the vertical direction is -90 degrees. When the number of blocks is odd, the first dotted line should be in the vertical direction, i.e. the initial drawing Angle is -90 degrees. When the number of blocks is even, the dotted line drawing should be left and right symmetric, so set the initial Angle to -90-ItemAngle / 2. InCircleOffset () Computes the cosine of the Angle x given in radians (kotlin.math.cos()/sin() Computes the cosine of the Angle x given in radians) We’re going to have to pass in a radian, and the Angle conversion radians are derived as follows.
val itemAngle = 360 / data.size
val startAngle = if (data.size % 2= =0) {-90 - itemAngle / 2
} else{-90
}
for (index in data.indices) {
// Draw a dotted line
val currentAngle = startAngle + itemAngle * index
val xy = inCircleOffset(center, progress * radarRadius, currentAngle)
drawLine(colors[4], center, xy, pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f.10f)))}/** * Get the xy coordinates of the circle based on the center, radius, and Angle */
fun DrawScope.inCircleOffset(center: Offset, radius: Float, angle: Int): Offset {
return Offset((center.x + radius * cos(angle * PI / 180)).toFloat(), (center.y + radius * sin(angle * PI / 180)).toFloat())
}
Copy the code
3. Draw the radar range
In the case of a maximum of 100, the RADIUS of the point that should be drawn is translated based on the bean’s value. And calculate the corresponding xy position, and record it in the path to facilitate the closed range drawing.
data class RadarBean(
val text: String,
val value: Float
)
for (index in data.indices) {
val pointData = data[index]
val pointRadius = radarRadius * pointData.value / 100
val fixPoint = inCircleOffset(center, pointRadius, currentAngle)
if (index == 0) {
path.moveTo(fixPoint.x, fixPoint.y)
} else {
path.lineTo(fixPoint.x, fixPoint.y)
}
}
drawPath(path, colors[5]) // Draw the closed interval
drawPath(path, colors[6], style = Stroke(5f)) // Draw a dark stroke for the range
Copy the code
4. Draw text position
The next step is to draw the position of the most important textStaticLayoutSection 1.4 explains how StaticLayout is used.Observing the effect drawing, we first analyze the drawing law of the position:
- The x axis of the vertical text is right in the middle of the width of the text, and the Y axis is at the bottom of the text
- Horizontal text x and Y axis are in the middle of the text
- The X-axis of the text in the upper left corner is at the far right of the text, and the Y-axis is in the middle of the last line of text
- The X-axis of the text in the upper right corner is at the far left of the text and the Y-axis is in the middle of the last line of text
- The X-axis of the text in the lower left corner is at the far right of the text, and the Y-axis is in the middle of the first line
- The X-axis of the text in the lower right corner is at the far left of the text, and the Y-axis is in the middle of the first line of text
According to the above rules, it is necessary to distinguish the text drawing areas:
private fun quadrant(angle: Int): Int {
return if (angle == -90 || angle == 90) {
0 / / vertical
} else if (angle == 0) {-1 // Level right
} else if (angle == 180) {-2 // Horizontal left
} else if (angle > -90 && angle < 0) {
1 / / the top right corner
} else if (angle > 0 && angle < 90) {
2 / / the bottom right hand corner
} else if (angle > 90 && angle < 180) {
3 / / the bottom left corner
} else {
4 / / the top left corner}}Copy the code
Set the maximum width of the text: the green dotted line is the maximum width of the left half of the text, and the blue dotted line is the maximum width of the right half of the text. Quadrant (currentAngle) was used to obtain the region to be drawn by the text. The maximum width of the text in the vertical region was set to half of the radar control, the maximum width of the text in the green dotted line was offset. X, and the maximum width of the text in the blue dotted line was sie.wider-offset.
fun DrawScope.wrapText(
text: String.// Draw the text
textPaint: TextPaint.// Text brushes
width: Float.// Width of the radar control
offset: Offset.// The xy position of the text drawn before adjustment
currentAngle: Int.// The Angle at which the current text is drawn
chineseWrapWidth: Float? = null // This is used to handle UI requirements for Chinese newlines every two characters
) {
val quadrant = quadrant(currentAngle)
var textMaxWidth = width
when (quadrant) {
0 -> {
textMaxWidth = width / 2
}
-1.1.2 -> {
textMaxWidth = size.width - offset.x
}
-2.3.4 -> {
textMaxWidth = offset.x
}
}
}
Copy the code
Create StaticLayout, passing in textMaxWidth, the maximum width of the text to draw. The control wraps the text based on the maximum width set.
val staticLayout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
StaticLayout.Builder.obtain(text, 0, text.length, textPaint, textMaxWidth.toInt()).apply {
this.setAlignment(Layout.Alignment.ALIGN_NORMAL)
}.build()
} else {
StaticLayout(text, textPaint, textMaxWidth.toInt(), Layout.Alignment.ALIGN_NORMAL, 1.0 f.0f.false)}Copy the code
StaticLayout gets the height of the text, the number of lines of the text. We can’t use staticlayout.width to get the width of the text, because let’s say textMaxWidth is set to 100 and the text is drawn with a width of 50, we get a width of 100 by Staticlayout.width, which is not what we want. If the text is not newline, textPaint. MeasureText gets the true width of the text directly. If the newline staticLayout. GetLineWidth (0) is used to obtain the width of the text first line is the actual width of the text.
val textHeight = staticLayout.height
val lines = staticLayout.lineCount
val isWrap = lines > 1
val textTrueWidth = if (isWrap) staticLayout.getLineWidth(0) else textPaint.measureText(text)
Copy the code
Draw text using canvas, where save() translate() staticlayout.draw (canvas) Restore () is a four-step drawing using staticLayout.
// Draw text
val textPointRadius = progress * radarRadius + 10f
val offset = inCircleOffset(center, textPointRadius, currentAngle)
val text = data[index].text
wrapText(
text,
textPaint,
size.width,
offset,
currentAngle,
if (specialHandle) textPaint.textSize * 2 else null
)
drawContext.canvas.nativeCanvas.save()
when (quadrant) {
0- > {/ / 1
drawContext.canvas.nativeCanvas.translate(offset.x - textTrueWidth / 2, offset.y - textHeight)
}
-1- > {/ / 2
drawContext.canvas.nativeCanvas.translate(offset.x, offset.y - textHeight / 2)} -2- > {/ / 2
drawContext.canvas.nativeCanvas.translate(offset.x - textTrueWidth, offset.y - textHeight / 2)}1- > {/ / rule 4
drawContext.canvas.nativeCanvas.translate(
offset.x,
if(! isWrap) offset.y - textHeight /2 else offset.y - (textHeight - textHeight / lines / 2))}2- > {/ / 6
drawContext.canvas.nativeCanvas.translate(offset.x, if(! isWrap) offset.y - textHeight /2 else offset.y - textHeight / lines / 2)}3- > {/ / rule 5
drawContext.canvas.nativeCanvas.translate(
offset.x - textTrueWidth,
if(! isWrap) offset.y - textHeight /2 else offset.y - textHeight / lines / 2)}4- > {/ / 3
drawContext.canvas.nativeCanvas.translate(
offset.x - textTrueWidth,
if(! isWrap) offset.y - textHeight /2 else offset.y - (textHeight - textHeight / lines / 2)
)
}
}
staticLayout.draw(drawContext.canvas.nativeCanvas)
drawContext.canvas.nativeCanvas.restore()
Copy the code
This is done, but the product does not like the effect of line breaks after looking at the renderings, and wants every two words to be line breaks, so add the following judgment.
// Special processing is required for newline && containing Chinese characters && text to draw a line width > the maximum width of the text
if(chineseWrapWidth ! =null && isContainChinese(text) && textPaint.measureText(text) > textMaxWidth) {
textMaxWidth = chineseWrapWidth
}
private fun isContainChinese(str: String): Boolean {
val p = Pattern.compile("[\u4e00-\u9fa5]")
val m = p.matcher(str)
return m.find()
}
Copy the code
5. Add a small animation
When the radar diagram appears on the screen, animate the drawing value from 0 to the actual value
var enable by remember {
mutableStateOf(false)}val progress by animateFloatAsState(if (enable) 1f else 0f, animationSpec = tween(2000))
Modifier.onGloballyPositioned {
enable = it.boundsInRoot().top >= 0 && it.boundsInRoot().right > 0
}
Copy the code
How to use
private val list = listOf(
RadarBean("Basic finance".43f),
RadarBean("Basic Financial accounting".90f),
RadarBean("Base".90f),
RadarBean("Basic Financial accounting".90f),
RadarBean("Basic finance".83f),
RadarBean("Technology timing".50f),
RadarBean("Booming industry".83f)
)
ComposeRadarView(
modifier = Modifier
.padding(horizontal = 4.dp)
.size(180.dp),
list
)
Copy the code
The project address
Finally, paste the project’s address: ComposeRadar
If you feel helpful to you click 👍 ~