Android自定义一个环形百分比图



项目中有需求,要实现一个环形百分比图,就像这样


android 自定义View state android 自定义view 画闭合的圆弧_ci


这还不简单,利用Android Canvas的drawArc方法,绘制每一段圆弧就好了,于是三下五除二就撸出了第一版代码。


class PieView1 @JvmOverloads constructor(
        context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    companion object {
        const val DEFAULT_ANIMATION_DURATION = 500L
    }

    private var mArcWidth: Float = 0f
    private var mExtraWidth: Float = 0f
    private var mCircleRadius: Int = 0

    private lateinit var mArcPaint: Paint
    private lateinit var mArcRectF: RectF

    /**
     * 动画进度控制
     */
    private val mProgressAnimator: ObjectAnimator = ObjectAnimator.ofFloat(this, "progress", 0f, 1f)
            .apply {
                duration = DEFAULT_ANIMATION_DURATION
                this.interpolator = DecelerateInterpolator()
            }
    private var mProgress: Float = 1f

    private var mDataArray: FloatArray? = null
    private var mColorArray: IntArray = intArrayOf(R.color.j_yellow, R.color.green, R.color.f_link, R.color.wrong_red, R.color.brown_bright)

    init {
        init(context, attrs, defStyleAttr)
        initPaint()
    }

    private fun init(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) {
        val ta: TypedArray = context!!.obtainStyledAttributes(attrs, R.styleable.PieView1, defStyleAttr, 0)
        mArcWidth = ta.getDimensionPixelOffset(R.styleable.PieView1_pv_arcWidth, context.resources.getDimensionPixelOffset(R.dimen.dp_20)).toFloat()
        mExtraWidth = ta.getDimensionPixelOffset(R.styleable.PieView1_pv_extraWidth, context.resources.getDimensionPixelOffset(R.dimen.dp_30)).toFloat()
        mCircleRadius = ta.getDimensionPixelOffset(R.styleable.PieView1_pv_circleRadius, context.resources.getDimensionPixelOffset(R.dimen.margin_100))
        ta.recycle()

        if (isInEditMode) {
            mDataArray = floatArrayOf(0.1f, 0.2f, 0.3f, 0.4f)
        }
    }

    private fun initPaint() {
        mArcPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
            style = Paint.Style.STROKE
            strokeWidth = mArcWidth
            strokeCap = Paint.Cap.BUTT
        }

        //圆弧的外接矩形
        mArcRectF = RectF()
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        setMeasuredDimension(measureView(widthMeasureSpec), measureView(heightMeasureSpec))
    }

    private fun measureView(measureSpec: Int): Int {
        var result: Int
        val specMode = View.MeasureSpec.getMode(measureSpec)
        val specSize = View.MeasureSpec.getSize(measureSpec)
        if (specMode == View.MeasureSpec.EXACTLY) {
            result = specSize
        } else {
            result = mCircleRadius * 2
            if (specMode == View.MeasureSpec.AT_MOST) {
                result = Math.min(result, specSize)
            }
        }
        return result
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        mArcRectF.set((width shr 1) - mCircleRadius + mArcWidth / 2,
                (height shr 1) - mCircleRadius + mArcWidth / 2,
                (width shr 1) + mCircleRadius - mArcWidth / 2,
                (height shr 1) + mCircleRadius - mArcWidth / 2)

        mDataArray?.let {
            //ensure start to draw on the top
            canvas.rotate(-90f, (width shr 1).toFloat(), (height shr 1).toFloat())

            var acc = 0f
            for ((index, e) in it.withIndex()) {
                val angle = e * 359.9f * mProgress
                mArcPaint.color = ContextCompat.getColor(context, mColorArray[index % mColorArray.size])
                canvas.drawArc(mArcRectF, acc, angle, false, mArcPaint)
                acc += angle
            }

            //rotate back
            canvas.rotate(90f, (width shr 1).toFloat(), (height shr 1).toFloat())
        }
    }


    /**
     * 转化数据
     */
    private fun transformData(data: FloatArray): FloatArray {
        val sum = data.sum()
        val array = FloatArray(data.size)
        data.forEachIndexed { index, d ->
            array[index] = d / sum
        }
        return array
    }

    fun setData(dataArr: FloatArray, animate: Boolean) {
        mDataArray = transformData(dataArr)
        if (!animate) {
            invalidate()
            return
        }

        startAnimation()
    }

    fun startAnimation() {
        mProgressAnimator.let {
            if (it.isRunning) {
                it.cancel()
            }

            it.start()
        }
    }

    /**
     * 动画进度控制
     */
    @Keep
    @Suppress("unused")
    private fun setProgress(progress: Float) {
        this.mProgress = progress
        invalidate()
    }
}

还顺手给View增加了一个动画,但是一运行,UI就不满意了。你这里怎么有一条缝隙?

android 自定义View state android 自定义view 画闭合的圆弧_自定义View_02


我仔细看了一下,还真有!这什么情况?查了半天也没查出所以然来,只能归结为drawArc方法有问题了。既然缝隙问题没办法解决,就只能换一种实现方法了。这个时候就轮到强大的Path出场了。我就想到,干脆用Path把圆弧计算出来,然后只用Canvas.drawPath来进行绘制不就行了吗?而且Path完全由自己来控制,就不存在缝隙的情况了。

于是又辛辛苦苦撸出来第二套代码。

class RingView3 @JvmOverloads constructor(
        context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    companion object {
        const val DEFAULT_ANIMATION_DURATION = 500L
        const val NO_ITEM_SELECT = -1
        const val DEFAULT_EXTRA_WIDTH = 20f
    }

    /**
     * 动画进度控制
     */
    private val mProgressAnimator: ObjectAnimator = ObjectAnimator.ofFloat(this, "progress", 0f, 1f)
            .apply {
                duration = DEFAULT_ANIMATION_DURATION
                this.interpolator = DecelerateInterpolator()
            }
    private var mProgress: Float = 1f
    /**
     * 圆环宽度
     */
    private var mArcWidth: Float = 0f

    /**
     * 选中状态下额外的宽度
     */
    private var mExtraWidth: Float = DEFAULT_EXTRA_WIDTH

    private var mOuterRadius: Float = 0f
    private var mInnerRadius = 0f

    /**
     * 图形中心点
     */
    private var mCenterX = 0f
    private var mCenterY = 0f

    /**
     * 绘制圆弧的画笔和Path
     */
    private lateinit var mArcPath: Path
    private lateinit var mArcPaint: Paint

    /**
     * 外圆的外接矩形
     */
    private lateinit var mOuterRect: RectF

    /**
     * 内圆的外接矩形
     */
    private lateinit var mInnerRect: RectF

    /**
     * 选中状态下的外接矩形
     */
    private lateinit var mBigRectF: RectF
    private lateinit var mSmallRectF: RectF

    /**
     * 点击区域检测辅助region
     */
    private var mRegion: ArrayList<Region>?= null
    private lateinit var clip: Region

    /**
     * 被选中的item id
     */
    private var onSelectId: Int = NO_ITEM_SELECT

    /**
     * 被选中触发的回调事件
     */
    private var onSelectListener: ((selectId: Int, index: Int) -> Unit)? = null

    private var mDataArray: FloatArray? = null
    private var mColorArray: IntArray = intArrayOf(R.color.j_yellow, R.color.green, R.color.f_link, R.color.wrong_red, R.color.brown_bright)

    init {
        init(context, attrs, defStyleAttr)
        initPaint()
    }

    private fun init(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) {
        val ta: TypedArray = context!!.obtainStyledAttributes(attrs, R.styleable.RingView, defStyleAttr, 0)
        mArcWidth = ta.getDimensionPixelOffset(R.styleable.RingView_rv_arcWidth, context.resources.getDimensionPixelOffset(R.dimen.dp_20)).toFloat()
        mExtraWidth = ta.getDimensionPixelOffset(R.styleable.RingView_rv_extraWidth, DEFAULT_EXTRA_WIDTH.toInt()).toFloat()
        mOuterRadius = ta.getDimensionPixelOffset(R.styleable.RingView_rv_circleRadius, context.resources.getDimensionPixelOffset(R.dimen.margin_100)).toFloat()
        setAnimatorDuration(ta.getInteger(R.styleable.RingView_rv_duration, DEFAULT_ANIMATION_DURATION.toInt()).toLong())
        mInnerRadius = mOuterRadius - mArcWidth

        if (isInEditMode) {
            mDataArray = floatArrayOf(0.1f, 0.2f, 0.3f, 0.4f)
            onSelectId = 0
        }

        if (mInnerRadius <= 0) {
            throw IllegalArgumentException("circleRadius must set bigger than arcWidth!!!")
        }

        if (mInnerRadius < mExtraWidth) {
            throw IllegalArgumentException("circleRadius suggest set bigger than sum of arcWidth and extraWidth)!!!")
        }
        ta.recycle()
    }

    private fun initPaint() {
        mArcPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
            style = Paint.Style.FILL_AND_STROKE
        }

        mOuterRect = RectF()

        mInnerRect = RectF()

        mBigRectF = RectF()

        mSmallRectF = RectF()

        mArcPath = Path()

        clip = Region()
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        val size = ((mOuterRadius + mExtraWidth) * 2).toInt()
        setMeasuredDimension(measureView(widthMeasureSpec, size + paddingLeft + paddingRight),
                measureView(heightMeasureSpec, size + paddingTop + paddingBottom))
    }

    private fun measureView(measureSpec: Int, defaultSize: Int): Int {
        var result: Int
        val specMode = MeasureSpec.getMode(measureSpec)
        val specSize = MeasureSpec.getSize(measureSpec)
        if (specMode == MeasureSpec.EXACTLY) {
            result = specSize
        } else {
            result = defaultSize
            if (specMode == MeasureSpec.AT_MOST) {
                result = Math.min(result, specSize)
            }
        }
        return result
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        mOuterRadius = (Math.min(w - paddingLeft - paddingRight, h - paddingTop - paddingBottom) shr 1).toFloat() - mExtraWidth
        mCenterX = (w shr 1).toFloat()
        mCenterY = (h shr 1).toFloat()
        clip.set(-w, -h, w, h)

        setRectAndRadii()
    }

    private fun setRectAndRadii() {
        mInnerRadius = mOuterRadius - mArcWidth

        mOuterRect.set(-mOuterRadius, -mOuterRadius, mOuterRadius, mOuterRadius)

        mInnerRect.set(-mInnerRadius, -mInnerRadius, mInnerRadius, mInnerRadius)

        mBigRectF.set(-mOuterRadius - mExtraWidth,
                -mOuterRadius - mExtraWidth,
                mOuterRadius + mExtraWidth,
                mOuterRadius + mExtraWidth)

        mSmallRectF.set(-mInnerRadius + mExtraWidth,
                -mInnerRadius + mExtraWidth,
                mInnerRadius - mExtraWidth,
                mInnerRadius - mExtraWidth)
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)

        canvas.translate(mCenterX, mCenterY)
        mDataArray?.let {
            var accumulateDegree = -90f
            for ((index, e) in it.withIndex()) {
                //将数据从百分比转化为度数(不是弧度!)
                val sweepAngle = e * 359.9f * mProgress
                mArcPaint.color = ContextCompat.getColor(context, mColorArray[index % mColorArray.size])

                resetArcPath(accumulateDegree, sweepAngle, index == onSelectId)
                mRegion?.get(index)?.setPath(mArcPath, clip)
                canvas.drawPath(mArcPath, mArcPaint)

                accumulateDegree += sweepAngle
            }
        }

        canvas.translate(-mCenterX, -mCenterY)
    }

    /**
     * @param degree 圆弧的度数
     * @param selected 是否被选中
     */
    private fun resetArcPath(startAngle: Float, degree: Float, selected: Boolean) {
        //保证从顶部开始绘制,因为Path的0度默认是x轴,所以设置开始角度为-90度,从上面开始绘制
        mArcPath.reset()
//        val startAngle = -90f
        //如果是选中的item 则展示选中状态
        if (selected) {
            /**
             * Path.arcTo 妙用
             * Append the specified arc to the path as a new contour. If the start of
             * the path is different from the path's current last point, then an
             * automatic lineTo() is added to connect the current contour to the
             * start of the arc. However, if the path is empty, then we call moveTo()
             * with the first point of the arc.
             *
             */
            mArcPath.arcTo(mBigRectF, startAngle, degree, false)
            mArcPath.arcTo(mSmallRectF, startAngle + degree, -degree, false)
        } else {
            mArcPath.arcTo(mOuterRect, startAngle, degree, false)
            mArcPath.arcTo(mInnerRect, startAngle + degree, -degree, false)
        }
        mArcPath.close()
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        if (MotionEvent.ACTION_DOWN == event.action) {
            val x = event.x
            val y = event.y
            run breaking@{
                mRegion?.forEachIndexed { index, region ->
                    if (region.contains((x - mCenterX).toInt(), (y - mCenterY).toInt())) {
                        onSelectId = if (onSelectId == index) {
                            NO_ITEM_SELECT
                        } else {
                            index
                        }

                        invalidate()
                        onSelectListener?.invoke(onSelectId, index)
                        return@breaking
                    }
                }
            }
        }

        return super.onTouchEvent(event)
    }

    /**
     * 设置颜色数组
     */
    @Suppress("unused")
    fun setColorArray(colors: IntArray?) {
        colors?.let {
            this.mColorArray = it
        }
    }

    fun setOnSelectListener(listener: (selectId: Int, index: Int) -> Unit) {
        this.onSelectListener = listener
    }

    fun setArcWidth(arcWidth: Float) {
        this.mArcWidth = arcWidth
        setRectAndRadii()
        postInvalidate()
    }

    fun setExtraWidth(extraWidth: Float) {
        this.mExtraWidth = extraWidth
        setRectAndRadii()
        postInvalidate()
    }

    fun setAnimatorDuration(duration: Long) {
        mProgressAnimator.duration = duration
    }

    @Suppress("unused")
    fun setAnimatorInterpolator(interpolator: TimeInterpolator) {
        mProgressAnimator.interpolator = interpolator
    }

    fun setData(list: List<Float>?, animate: Boolean = true) {
        setData(list?.toFloatArray(), animate)
    }

    /**
     * 动画进度控制
     */
    @Keep
    @Suppress("unused")
    private fun setProgress(progress: Float) {
        this.mProgress = progress
        invalidate()
    }

    /**
     * @param dataArr 数据源
     * @param animate 是否展示动画
     */
    fun setData(dataArr: FloatArray?, animate: Boolean = true) {
        dataArr?.let {
            onSelectId = NO_ITEM_SELECT
            mDataArray = transformData(dataArr)

            if (!animate) {
                invalidate()
                return
            }

            startAnimation()
        }
    }

    fun startAnimation() {
        onSelectId = NO_ITEM_SELECT

        mProgressAnimator.let {
            if (it.isRunning) {
                it.cancel()
            }

            it.start()
        }
    }

    /**
     * 转化数据,并且初始化辅助Region数组
     */
    private fun transformData(data: FloatArray): FloatArray {
        val sum = data.sum()
        val array = FloatArray(data.size)
        data.forEachIndexed { index, d ->
            array[index] = d / sum
            mRegion?.add(Region())
        }
        return array
    }


    override fun onSaveInstanceState(): Parcelable? {
        val data = Bundle()
        data.putParcelable("superData", super.onSaveInstanceState())
        data.putFloatArray("data_array", mDataArray)
        return data
    }

    override fun onRestoreInstanceState(state: Parcelable) {
        val data = state as Bundle
        val superData = data.getParcelable<Parcelable>("superData")
        super.onRestoreInstanceState(superData)

        mDataArray = data.getFloatArray("data_array")
        initPaint()
        startAnimation()
    }
}

由于UI中间又加了许多需求,所以第二套代码实现的功能比第一套多了一些,比如点击选中某块圆弧要有强调突出效果,像下面这样。

android 自定义View state android 自定义view 画闭合的圆弧_百分比图_03


然后我就巧妙的利用了Region,将用于绘制的Path转化成了一个个Region记录下来,然后通过判断点击点坐标落在了哪个Region,就可以判断出点击了那个圆弧,也算是巧妙的解决了不规则View的点击事件问题。(当然这里也可以用圆的一些知识来计算点击点的位于哪个圆弧,但是计算还是相对复杂一些。而且我这里已经有现成的Path了,可以很方便的得到圆弧的Region,因此这里选着使用Region来解决点击事件问题)。

并且注意我这一段代码

if (isInEditMode) {
         mDataArray = floatArrayOf(0.1f, 0.2f, 0.3f, 0.4f)
        onSelectId = 0
    }

这里是一个自定义View的小技巧,有时候我们自定义View,因为没有数据,很难在xml文件中看到View的实际表现效果,我们就可以利用这样的方式,设置一些初始数据,方便在xml中看到实际效果,并且if里面的代码块在运行时就会被忽略,不会影响到实际使用。

最后在给大家讲解一个Path.arcTo方法的妙用。

private fun resetArcPath(startAngle: Float, degree: Float, selected: Boolean) {
        //保证从顶部开始绘制,因为Path的0度默认是x轴,所以设置开始角度为-90度,从上面开始绘制
        mArcPath.reset()
        //如果是选中的item 则展示选中状态
        if (selected) {
            /**
             * Path.arcTo 妙用
             * Append the specified arc to the path as a new contour. If the start of
             * the path is different from the path's current last point, then an
             * automatic lineTo() is added to connect the current contour to the
             * start of the arc. However, if the path is empty, then we call moveTo()
             * with the first point of the arc.
             *
             */
            mArcPath.arcTo(mBigRectF, startAngle, degree, false)
            mArcPath.arcTo(mSmallRectF, startAngle + degree, -degree, false)
        } else {
            mArcPath.arcTo(mOuterRect, startAngle, degree, false)
            mArcPath.arcTo(mInnerRect, startAngle + degree, -degree, false)
        }
        mArcPath.close()
    }

本来绘制一段圆弧需要起至少4个数据,绘制内圆起点坐标,然后绘制一条线段到外圆的第二个点坐标,然后绘制一段圆弧,需要角度数据,然后再绘制一条线段到内圆,需要计算第三个点的坐标,然后在反向绘制一段圆弧。
就像下面代码这样

...
        mArcPath.moveTo(calculateX(mCenterX, mInnerRadius, startDegree),
                calculateY(mCenterY, mInnerRadius, startDegree))
        mArcPath.lineTo(calculateX(mCenterX, mOuterRadius, startDegree),
                    calculateY(mCenterY, mOuterRadius, startDegree))
        mArcPath.arcTo(mOuterRect, startDegree, degree, false)
        mArcPath.lineTo(calculateX(mCenterX, mInnerRadius, endDegree),
                    calculateY(mCenterY, mInnerRadius, endDegree))
        mArcPath.arcTo(mInnerRect, endDegree, -degree, false)
    ...
    private fun calculateX(x: Float, r: Float, degree: Float): Float {
        return (x + r * Math.cos(Math.toRadians(degree.toDouble()))).toFloat()
    }

但是看我们实现代码并没有把Path先是moveTo绘制起点坐标,然后又是绘制线段又是绘制圆弧的,而是简简单单的绘制了两条圆弧,省去了巨量的计算。但是最终效果是一模一样的。这是为什么?其实可以从Path.arcTo方法的解释中找到答案。

Path.arcTo方法解释中说到,如果Path是空的,就会先调用moveTo方法把绘制起点移动到圆弧的起点,于是我们在绘制第一个圆弧(外弧)的时候就不要调用moveTo方法了。如果Path不为空,则调用lineTo方法,于是我们在绘制第二个圆弧(内弧)的时候,也不需要我们再调用lineTo方法了,最后调用Path.close帮我们闭合Path,于是整个圆弧就绘制出来了。相比与下面的代码,我们省去了大量的计算!