Android(Kotlin) PieChartView(环形饼图带动画)

代码的世界虽逻辑繁华,却又大道至简。

画环形饼图常见的大概有两种画法:

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
只需要明白,如果存在多个颜色的话,在绘制第二个以后颜色时,每次都要先绘制先前所有颜色,再绘制当前颜色,即可理解,这也就是动画的基本逻辑。

效果图:

image.png

自定义参数

 
    
        
        
        
        
        
        
        
        
        
        
        
        
        
        
    

使用


val pieChartView = findViewById(R.id.pie_chart_view)
        val dataList = mutableListOf()
        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? = 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
    private lateinit var mColorArray: Array
    private lateinit var mTextRectArray: Array

    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,
        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
    ) {
        //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
                )
                //
//                canvas.drawRect(
//                    textParams.textX,
//                    textParams.textY,
//                    textParams.textX + textParams.staticLayout.width,
//                    textParams.textY + textParams.staticLayout.height,
//                    mLinePaint
//                )
                //
                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
}
我也是有底线的,感谢您的耐心。

你可能感兴趣的:(Android(Kotlin) PieChartView(环形饼图带动画))