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

自定义参数

 <!-- 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
}
我也是有底线的,感谢您的耐心。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,179评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,229评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,032评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,533评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,531评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,539评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,916评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,574评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,813评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,568评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,654评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,354评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,937评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,918评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,152评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,852评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,378评论 2 342

推荐阅读更多精彩内容