代码的世界虽逻辑繁华,却又大道至简。
画环形饼图常见的大概有两种画法:
1.画个半径略小的圆覆盖掉中心
设置userCenter = true,
然后调用drawArc(RectF,startAngle,sweep,userCenter,Paint),
最后drawCircle(...) 就会呈现环状了
参考资料:
https://www.jianshu.com/p/c9a12370631d
2.把画笔设置成描边模式并设置线条宽度及userCenter = false,
mPiePaint.style = Paint.Style.STROKE
mPiePaint.textAlign = Paint.Align.LEFT
mPiePaint.strokeWidth = dp2px(21f)
然后调用drawArc(RectF,startAngle,sweep,userCenter,Paint)(本篇采用此方式)
动画思路来自:
https://blog.csdn.net/petterp/article/details/84928711
只需要明白,如果存在多个颜色的话,在绘制第二个以后颜色时,每次都要先绘制先前所有颜色,再绘制当前颜色,即可理解,这也就是动画的基本逻辑。
效果图:
自定义参数
<!-- PieChartView -->
<declare-styleable name="PieChartView">
<!-- outer radius -->
<attr name="pie_chart_outer_radius" format="dimension" />
<!-- ring width -->
<attr name="pie_chart_ring_width" format="dimension" />
<!-- line length of annotation -->
<attr name="pie_chart_line_length" format="dimension" />
<!-- text size of description -->
<attr name="pie_chart_text_size" format="dimension" />
<!-- blank top and bottom-->
<attr name="pie_chart_blank_top_bottom" format="dimension" />
<!-- blank of left and right -->
<attr name="pie_chart_blank_left_right" format="dimension" />
<!-- margin of left,top,right,bottom -->
<attr name="pie_chart_margin" format="dimension" />
</declare-styleable>
使用
<com.patrick.moti.PieChartView
android:id="@+id/pie_chart_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:pie_chart_blank_left_right="66dp"
app:pie_chart_blank_top_bottom="50dp"
app:pie_chart_line_length="12dp"
app:pie_chart_outer_radius="106dp"
app:pie_chart_margin="8dp"
app:pie_chart_ring_width="21dp"
app:pie_chart_text_size="12sp" />
val pieChartView = findViewById<PieChartView>(R.id.pie_chart_view)
val dataList = mutableListOf<PieChartView.PieData>()
dataList.add(
PieChartView.PieData(
"Abcdefghijklmn & opqrstuvwxyz1",
45.00,
"#FFFF00"
)
)
dataList.add(
PieChartView.PieData(
"Other1",
90.00,
"#FF0099"
)
)
dataList.add(
PieChartView.PieData(
"Abcdefghijklmn & opqrstuvwxyz2",
135.00,
"#FF9900"
)
)
dataList.add(
PieChartView.PieData(
"Other2",
180.00,
"#FF5678"
)
)
dataList.add(
PieChartView.PieData(
"abcdefghijklmn & opqrstuvwxyz3",
225.00,
"#FF2345"
)
)
dataList.add(
PieChartView.PieData(
"Shoping5",
270.00,
"#FF00FF"
)
)
dataList.add(
PieChartView.PieData(
"abcdefghijklmn & opqrstuvwxyz4",
315.00,
"#FF8828"
)
)
pieChartView.initData(dataList, 1260.00, "$")
实现
package com.example.view
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.*
import android.os.Build
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import android.util.AttributeSet
import android.util.Log
import android.view.View
import com.example.laboratory.R
import com.example.tools.AmountFormatUtil
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.sin
/**
* Pie Chart View
* @author patrick
* @date 6/28/21
*/
open class PieChartView(context: Context, attributes: AttributeSet?) : View(context, attributes) {
private var mHasInit = false
private var mPieDataList: List<IPieData>? = null
private var mTotalNumber: Double = 0.0
private var mCoinSymbol: String = "$"
private var mOuterRadius = 0f
private var mRingWidth = 0f
private var mLineLength = 0f
private var mTextSize = 0f
private var mBlankLeftAndRight = 0f
private var mBlankTopAndBottom = 0f
private var mMargin = 0f
//paint
private var mPiePaint = Paint()
private var mLinePaint = Paint()
private var mTextPaint = TextPaint()
//draw ring.
private var mValueAnimator = ValueAnimator()
private var mCurrentAngle = 0f
private var mCursor = 0
private val mCircleRectF = RectF()
private var mCX = 0f
private var mCY = 0f
private var mPieLeft = 0f
private var mPieTop = 0f
private var mPieRight = 0f
private var mPieBottom = 0f
//resource of draw.
private lateinit var mStartAngleArray: Array<Float>
private lateinit var mColorArray: Array<Int>
private lateinit var mTextRectArray: Array<TextCalculateParams?>
companion object {
const val MAX_ANGLE = 360f
const val COLOR_EMPTY = "#D7D8D8"
}
init {
val typeArray = context.obtainStyledAttributes(attributes, R.styleable.PieChartView)
mOuterRadius =
typeArray.getDimension(R.styleable.PieChartView_pie_chart_outer_radius, dp2px(106f))
mRingWidth =
typeArray.getDimension(R.styleable.PieChartView_pie_chart_ring_width, dp2px(21f))
mLineLength =
typeArray.getDimension(R.styleable.PieChartView_pie_chart_line_length, dp2px(12f))
mTextSize = typeArray.getDimension(R.styleable.PieChartView_pie_chart_text_size, dp2px(12f))
mMargin = typeArray.getDimension(
R.styleable.PieChartView_pie_chart_margin,
dp2px(8f)
)
mBlankLeftAndRight =
typeArray.getDimension(R.styleable.PieChartView_pie_chart_blank_left_right, dp2px(66f))
mBlankTopAndBottom =
typeArray.getDimension(
R.styleable.PieChartView_pie_chart_blank_top_bottom,
dp2px(50f)
)
typeArray.recycle()
//default circleX and circleY
mPieLeft = mBlankLeftAndRight + mMargin
mPieTop = mBlankTopAndBottom + mMargin
mCX = mPieLeft + mOuterRadius
mCY = mPieTop + mOuterRadius
mPieRight = mCX + mOuterRadius
mPieBottom = mCY + mOuterRadius
}
fun initData(
pieDataList: List<IPieData>,
total: Double,
typeface: Typeface? = null,
coinSymbol: String? = null
) {
mPieDataList = pieDataList
coinSymbol?.let {
mCoinSymbol = coinSymbol
}
val pieDataSize = pieDataList.size
initPaint(typeface)
initCollection(pieDataSize)
handleLogicThenInitCircleRectF(pieDataSize, total, pieDataList)
//reset.
mCursor = 0
mHasInit = true
//start draw with animation.
startDrawWithAnimation(pieDataSize)
}
private fun initPaint(typeface: Typeface?) {
mPiePaint.isAntiAlias = true
mPiePaint.style = Paint.Style.STROKE
mPiePaint.strokeWidth = mRingWidth
mLinePaint.style = Paint.Style.STROKE
mLinePaint.isAntiAlias = true
mLinePaint.strokeWidth = dp2px(1f)
mTextPaint.style = Paint.Style.FILL
mTextPaint.isAntiAlias = true
mTextPaint.textAlign = Paint.Align.LEFT
mTextPaint.textSize = mTextSize
typeface?.let {
mTextPaint.typeface = typeface
}
}
private fun initCollection(dataListSize: Int) {
mStartAngleArray = Array(
dataListSize + 1
) { 0f }
mStartAngleArray[0] = 0f
val defaultResSize = if (dataListSize > 0) {
dataListSize
} else {
1
}
mColorArray = Array(defaultResSize) { Color.parseColor(COLOR_EMPTY) }
mTextRectArray = Array(defaultResSize) { null }
}
private fun handleLogicThenInitCircleRectF(
pieDataSize: Int,
total: Double,
pieDataList: List<IPieData>
) {
//Collect data
mTotalNumber = 0.0
if ((pieDataSize == 1 && pieDataList[0] is PieData) || pieDataSize > 1) {
if (total <= 0.0) {
GDLog.w("pie_chart_view", "total value <= 0 is limited,please have a check.")
}
for (i in pieDataList.indices) {
val pieData = pieDataList[i] as PieData
mTotalNumber += pieData.valueItem
}
if (mTotalNumber <= 0.0) {
GDLog.e("pie_chart_view", "mTotalNumber value <= 0 is limited,please have a check.")
return
}
var mMaxBlankTopAndBottom = mBlankTopAndBottom
var maxPercentage = 0.0
var allAdjustPercentage = 0.0
var adjustCount = 0
var needFix = false
for (i in pieDataList.indices) {
val pieData = pieDataList[i] as PieData
val percentage = if (pieDataSize == 1) {
1.0
} else {
(pieData.valueItem / mTotalNumber)
}
maxPercentage = max(maxPercentage, percentage)
if (percentage > 0 && percentage < 0.01) {
needFix = true
adjustCount += 1
allAdjustPercentage += percentage
}
}
for (i in pieDataList.indices) {
val pieData = pieDataList[i] as PieData
val percentage = if (pieDataSize == 1) {
1.0
} else {
(pieData.valueItem / mTotalNumber)
}
val newPercent = when {
percentage > 0 && percentage < 0.01 -> {
0.01
}
needFix && percentage == maxPercentage -> {
needFix = false
maxPercentage + allAdjustPercentage - 0.01 * adjustCount
}
else -> {
percentage
}
}
val pieAngle = (newPercent * MAX_ANGLE).toFloat()
mStartAngleArray[i + 1] = mStartAngleArray[i] + pieAngle
mColorArray[i] = Color.parseColor(pieData.color)
val halfAngle: Float? = when {
newPercent > 0.9 -> {
-90f
}
newPercent < 0.05 -> {
//do not draw line and text,so return null as mark.
null
}
else -> {
-mStartAngleArray[i] - pieAngle / 2f
}
}
halfAngle?.let { textAngle ->
//params for line draw.
val toRadians = textAngle * Math.PI / 180
val lineEndX: Float =
((mOuterRadius + mLineLength) * cos(toRadians) + mCX).toFloat()
//params for text draw.
val tempLineEndY: Float =
((mOuterRadius + mLineLength) * sin(toRadians) + mCY).toFloat()
val toEdgeWidth = mCX - abs(lineEndX - mCX) - mMargin
val allowMaxWidth = if(tempLineEndY > mPieBottom || tempLineEndY < mPieTop){
//It's between quadrant 1 and 2 || quadrant 3 and 4
toEdgeWidth + (toEdgeWidth - toEdgeWidth * abs(cos(toRadians)))
}else{
toEdgeWidth.toDouble()
}
val categoryName = getTruncatedString(pieData.categoryName, allowMaxWidth)
val valueItem =
getTruncatedString(
AmountFormatUtil.formatWithoutDollarSign(pieData.valueItem),
allowMaxWidth,
AmountFormatUtil.AMOUNT_SYMBOLS_POSITIVE
)
val maxTextWidth = max(
mTextPaint.measureText(categoryName),
mTextPaint.measureText(valueItem)
).toInt()
val text = "${categoryName}\n${valueItem}"
val textLayout = StaticLayout.Builder.obtain(
text,
0,
text.length,
mTextPaint,
maxTextWidth
)
.setIncludePad(false)
.setAlignment(Layout.Alignment.ALIGN_CENTER)
.build()
mTextRectArray[i] =
TextCalculateParams(
staticLayout = textLayout,
radians = toRadians,
lineRectF = RectF(),
textX = 0f,
textY = 0f
)
//mMaxBlankTopAndBottom = max(mMaxBlankTopAndBottom, textLayout.height.toFloat()+mLineLength)
} ?: kotlin.run {
mTextRectArray[i] = null
}
}
mCY = mMaxBlankTopAndBottom + mOuterRadius + mMargin
//mCY updated then do prepare for drawing line and text content.
//mCX don't need to update,because text content will \n automatically if beyond the left-right edge.
for (i in mTextRectArray.indices) {
mTextRectArray[i]?.let {
val lineStartX: Float = (mOuterRadius * cos(it.radians) + mCX).toFloat()
val lineEndX: Float =
((mOuterRadius + mLineLength) * cos(it.radians) + mCX).toFloat()
val lineStartY: Float = (mOuterRadius * sin(it.radians) + mCY).toFloat()
val lineEndY: Float =
((mOuterRadius + mLineLength) * sin(it.radians) + mCY).toFloat()
it.lineRectF = RectF(lineStartX, lineStartY, lineEndX, lineEndY)
val textX =
lineEndX + (it.staticLayout.width / 2) * cos(it.radians) - it.staticLayout.width / 2
val textY =
((mOuterRadius + mLineLength + it.staticLayout.height / 2) * sin(it.radians) + mCY) - it.staticLayout.height / 2
it.textX = textX.toFloat()
it.textY = textY.toFloat()
}
}
} else {
if (pieDataSize == 1) {
val pieData = pieDataList[0] as PieEmptyData
mStartAngleArray[1] = MAX_ANGLE
mColorArray[0] = Color.parseColor(pieData.color)
}
//use mCX default,it is initialized
//use mCY default,it is initialized
}
initCircleRectF()
}
private fun getTruncatedString(
fullText: String,
maxWidth: Double,
amountSymbols: String? = ""
): String {
var truncatedString = ""
for (string in fullText.toCharArray()) {
val tempStr = "$truncatedString$string"
val width = mTextPaint.measureText("$amountSymbols$tempStr...")
if (width == maxWidth.toFloat()) {
truncatedString = "$amountSymbols$tempStr..."
break
}
if (width > maxWidth) {
truncatedString = "$amountSymbols$truncatedString..."
break
}
truncatedString = tempStr
}
val finalText = if (truncatedString.startsWith("$amountSymbols")) {
truncatedString
} else {
"$amountSymbols$truncatedString"
}
return finalText.replace("\n", "")
}
private fun initCircleRectF() {
val innerRadius = mOuterRadius - mRingWidth
mCircleRectF.left = mCX - innerRadius - mRingWidth / 2
mCircleRectF.top = mCY - innerRadius - mRingWidth / 2
mCircleRectF.right = mCX + innerRadius + mRingWidth / 2
mCircleRectF.bottom = mCY + innerRadius + mRingWidth / 2
}
private fun startDrawWithAnimation(dataSize: Int) {
mValueAnimator.setFloatValues(0f, MAX_ANGLE)
mValueAnimator.addUpdateListener {
mCurrentAngle = it.animatedValue as Float
if (mCurrentAngle <= 0) {
return@addUpdateListener
}
if (dataSize > 1) {
//algorithm: check and change the color of piePaint.
for (i in mCursor + 1 until mStartAngleArray.size) {
if (mCurrentAngle >= mStartAngleArray[i] && mCurrentAngle < MAX_ANGLE) {
mCursor = i
}
}
}
GDLog.d("pieChart_change", "$mCursor | $mCurrentAngle")
invalidate()
}
mValueAnimator.duration = 600L
mValueAnimator.startDelay = 50L
mValueAnimator.start()
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
val realWidth = if (widthMode == MeasureSpec.EXACTLY) {
(widthSize + mMargin * 2).toInt()
} else {
(mCX * 2).toInt()
}
val realHeight = if (heightMode == MeasureSpec.EXACTLY) {
(heightSize + mMargin * 2).toInt()
} else {
(mCY * 2).toInt()
}
setMeasuredDimension(realWidth, realHeight)
}
override fun onDraw(canvas: Canvas) {
if (!mHasInit) {
super.onDraw(canvas)
return
}
for (i in 0..mCursor) {
mPiePaint.color = mColorArray[i]
val startAngle = mStartAngleArray[i]
val sweep = mCurrentAngle - mStartAngleArray[i]
canvas.drawArc(
mCircleRectF,
-startAngle,
-sweep,
false,
mPiePaint
)
mTextRectArray[i]?.let { textParams ->
mLinePaint.color = mColorArray[i]
canvas.drawLine(
textParams.lineRectF.left,
textParams.lineRectF.top,
textParams.lineRectF.right,
textParams.lineRectF.bottom,
mLinePaint
)
//<test code>
// canvas.drawRect(
// textParams.textX,
// textParams.textY,
// textParams.textX + textParams.staticLayout.width,
// textParams.textY + textParams.staticLayout.height,
// mLinePaint
// )
//<test code/>
mTextPaint.color = mColorArray[i]
canvas.translate(textParams.textX, textParams.textY)
textParams.staticLayout.draw(canvas)
canvas.translate(-textParams.textX, -textParams.textY)
}
}
}
fun getOuterRadius(): Float {
return mOuterRadius
}
fun getRingWidth(): Float {
return mRingWidth
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
mHasInit = false
}
private fun dp2px(radius: Float): Float {
return (context.resources.displayMetrics.density * radius)
}
data class TextCalculateParams(
val staticLayout: StaticLayout,
val radians: Double,
var lineRectF: RectF,
var textX: Float,
var textY: Float
)
interface IPieData {
val color: String
}
class PieEmptyData(
override val color: String
) : IPieData
class PieData(
val categoryName: String,
val valueItem: Double,
override val color: String
) : IPieData
}