以己推人,我还是先放个图吧:

实现背景如下:是的还是我们那个大家都喜爱的UI设计师,需要显示员工出勤率的百分比数据,设计的是带圆环,带阴影,并且带动画逻辑。内部显示对应的百分比数据。

为什么自己要自己写?网上的一些轮子要么就过渡封装,要么就不符合需求,本着圆环的绘制与自定义也并不复杂,这里就记录一下自定义View圆环从零到实现的流程。专属定制一个自定义圆环,面向UI设计师开发!

看到这效果,我仿佛看到了大家的表情。

大家应该或多或少的使用过一些圆形进度,我当然知道这对各位高工来说都是小KESE了,小小圆环还不是手到擒来。但是呢我希望对一些不是那么了解自定义View的同学有一些启发,当然高工们也可以跟着一起复习一下的啦。当然了也是为了后期的一些文章作为进阶,毕竟自定义View的绘制是比较基础的东西了。

话不多说,Let's go

一、自定义属性

作为一个自定义View,我们需要配置一些属性,那么必不可少的就需要一些自定义属性,比如我们的圆环View,从效果图上看的话,我们需要定义如下的元素:一个内环,一个外环,外环阴影,一个百分比的文本,一个提示文本。

从而我们就需要指定一下内环的宽度、背景颜色、外环的宽度、颜色、阴影的大小、阴影的颜色、百分比文字的大小颜色、提示文本的大小颜色,由于我们还需要做动画展示圆环,所以我们还需要配置是否开启动画。

总的来说一些自定义的属性定义位置如下:

具体的实现如下:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!-- 圆形进度条 -->
    <declare-styleable name="MyCircleProgressView">
        <attr name="mCirWidth" format="dimension" />
        <attr name="mCirColor" format="color" />
        <attr name="mBgCirWidth" format="dimension" />
        <attr name="mBgCirColor" format="color" />
        <attr name="animTime" format="integer" />
        <attr name="value" format="integer" />
        <attr name="maxvalue" format="integer" />
        <attr name="startAngle" format="float" />
        <attr name="sweepAngle" format="float" />
        <attr name="valueSize" format="dimension" />
        <attr name="valueColor" format="color" />
        <attr name="unit" format="string" />
        <attr name="hint" format="string" />
        <attr name="hintSize" format="dimension" />
        <attr name="hintColor" format="color" />
        <attr name="gradient" format="dimension" />
        <attr name="isGradient" format="boolean" />
        <attr name="shadowSize" format="float" />
        <attr name="shadowColor" format="color" />
        <attr name="shadowShow" format="boolean" />
        <attr name="digit" format="integer" />
        <attr name="isanim" format="boolean" />
    </declare-styleable>
</resources>

当我们在配置文件中定义了自定义属性,那么我们就可以在类中通过 StyledAttributes 来拿到我们定义的属性值。

private fun initAttrs(attrs: AttributeSet?, context: Context?) {
        val typedArray = context!!.obtainStyledAttributes(attrs, R.styleable.MyCircleProgressView)
        isAnim = typedArray.getBoolean(R.styleable.MyCircleProgressView_isanim, true)
        mDigit = typedArray.getInt(R.styleable.MyCircleProgressView_digit, 2)
        mBgCirColor = typedArray.getColor(R.styleable.MyCircleProgressView_mBgCirColor, Color.GRAY)
        mBgCirWidth = typedArray.getDimension(R.styleable.MyCircleProgressView_mBgCirWidth, 15f)
        mCirColor = typedArray.getColor(R.styleable.MyCircleProgressView_mCirColor, Color.YELLOW)
        mCirWidth = typedArray.getDimension(R.styleable.MyCircleProgressView_mCirWidth, 15f)
        mAnimTime = typedArray.getInt(R.styleable.MyCircleProgressView_animTime, 1000)
        mValue = typedArray.getString(R.styleable.MyCircleProgressView_value)
        mMaxValue = typedArray.getFloat(R.styleable.MyCircleProgressView_maxvalue, 100f)
        mStartAngle = typedArray.getFloat(R.styleable.MyCircleProgressView_startAngle, 270f)
        mSweepAngle = typedArray.getFloat(R.styleable.MyCircleProgressView_sweepAngle, 360f)
        mValueSize = typedArray.getDimension(R.styleable.MyCircleProgressView_valueSize, 15f)
        mValueColor = typedArray.getColor(R.styleable.MyCircleProgressView_valueColor, Color.BLACK)
        mHint = typedArray.getString(R.styleable.MyCircleProgressView_hint)
        mHintSize = typedArray.getDimension(R.styleable.MyCircleProgressView_hintSize, 15f)
        mHintColor = typedArray.getColor(R.styleable.MyCircleProgressView_hintColor, Color.GRAY)
        mUnit = typedArray.getString(R.styleable.MyCircleProgressView_unit)
        mShadowColor = typedArray.getColor(R.styleable.MyCircleProgressView_shadowColor, Color.BLACK)
        mShadowIsShow = typedArray.getBoolean(R.styleable.MyCircleProgressView_shadowShow, false)
        mShadowSize = typedArray.getFloat(R.styleable.MyCircleProgressView_shadowSize, 8f)
        isGradient = typedArray.getBoolean(R.styleable.MyCircleProgressView_isGradient, false)
        mGradientColor = typedArray.getResourceId(R.styleable.MyCircleProgressView_gradient, 0)
        if (mGradientColor != 0) {
            mGradientColors = resources.getIntArray(mGradientColor!!)
        typedArray.recycle()

每一种自定义View的属性自定义方式都是类似的。

拿到我们配置的一些自定义属性,赋值给对应的成员变量之后,我们就可以初始化一些画笔与矩阵资源。

二、画笔和矩阵资源的初始化

不同的资源绘制我们需要不同的画笔,所以我们都声明出来

   //是否开启抗锯齿(默认开启)
    private var antiAlias: Boolean = true
    //声明背景圆画笔
    private lateinit var mBgCirPaint: Paint //画笔
    private var mBgCirColor: Int? = null //颜色
    private var mBgCirWidth: Float = 15f //圆环背景宽度
    //声明进度圆的画笔
    private lateinit var mCirPaint: Paint //画笔
    private var mCirColor: Int? = null //颜色
    private var mCirWidth: Float = 15f //主圆的宽度
    //绘制进度数值
    private lateinit var mValuePaint: TextPaint
    private var mValueSize: Float? = null
    private var mValueColor: Int? = null
    //绘制提示文本
    private var mHint: CharSequence? = null
    private lateinit var mHintPaint: TextPaint
    private var mHintSize: Float? = null
    private var mHintColor: Int? = null

然后我们就可以通过我们的自定义属性给这些画笔做初始化和赋值操作。自定义属性的赋值操作上面我们已经赋值过了,这里就看如何初始化画笔资源。

* 初始化画笔 private fun initPaint() { //圆画笔(主圆的画笔设置) mCirPaint = Paint() mCirPaint.isAntiAlias = antiAlias //是否开启抗锯齿 mCirPaint.style = Paint.Style.STROKE //画笔样式 mCirPaint.strokeWidth = mCirWidth //画笔宽度 mCirPaint.strokeCap = Paint.Cap.ROUND //笔刷样式(圆角的效果) mCirPaint.color = mCirColor!!//画笔颜色 //背景圆画笔(一般和主圆一样大或者小于主圆的宽度) mBgCirPaint = Paint() mBgCirPaint.isAntiAlias = antiAlias mBgCirPaint.style = Paint.Style.STROKE mBgCirPaint.strokeWidth = mBgCirWidth mBgCirPaint.strokeCap = Paint.Cap.ROUND mBgCirPaint.color = mBgCirColor!! //初始化主题文字的字体画笔 mValuePaint = TextPaint() mValuePaint.isAntiAlias = antiAlias //是否抗锯齿 mValuePaint.textSize = mValueSize!! //字体大小 mValuePaint.color = mValueColor!! //字体颜色 mValuePaint.textAlign = Paint.Align.CENTER //从中间向两边绘制,不需要再次计算文字 mValuePaint.typeface = TypefaceUtil.getSFSemobold(context) //字体加粗 //初始化提示文本的字体画笔 mHintPaint = TextPaint() mHintPaint.isAntiAlias = antiAlias mHintPaint.textSize = mHintSize!! mHintPaint.color = mHintColor!! mHintPaint.textAlign = Paint.Align.CENTER mHintPaint.typeface = TypefaceUtil.getSFRegular(context) //自定义字体

由于是自定义View的第一篇,这里我尽量把每一个的自定义属性都注释清楚,方便大家查看,后面别的自定义View就没这么详细了。

那么我们在初始化了资源之后,我们准备绘制,怎么绘制呢?绘制在哪呢?大小都还不知道呢!

是的,如果我们需要画圆的话,我们至少需要圆的中心点,半径,矩阵大小之类的方向位置信息吧。

    //圆心位置
    private lateinit var centerPosition: Point
    private var raduis: Float? = null
    //声明边界矩形
    private var mRectF: RectF? = null
    //颜色渐变色
    private var isGradient: Boolean? = null
    private var mGradientColors: IntArray? = intArrayOf(Color.RED, Color.GRAY, Color.BLUE)
    private var mGradientColor: Int? = null
    private var mSweepGradient: SweepGradient? = null
     * 设置圆形和矩阵的大小,设置圆心位置
    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        //圆心位置
        centerPosition.x = w / 2
        centerPosition.y = h / 2
        val maxCirWidth = Math.max(mCirWidth, mBgCirWidth)
        val minWidth = Math.min(
            w - paddingLeft - paddingRight - 2 * maxCirWidth,
            h - paddingBottom - paddingTop - 2 * maxCirWidth
        raduis = minWidth / 2
        //矩形坐标
        mRectF!!.left = centerPosition.x - raduis!! - maxCirWidth / 2
        mRectF!!.top = centerPosition.y - raduis!! - maxCirWidth / 2
        mRectF!!.right = centerPosition.x + raduis!! + maxCirWidth / 2
        mRectF!!.bottom = centerPosition.y + raduis!! + maxCirWidth / 2
        if (isGradient!!) {
            setupGradientCircle() //设置圆环画笔颜色渐变
     * 使用渐变色画圆
    private fun setupGradientCircle() {
        mSweepGradient =
            SweepGradient(
                centerPosition.x.toFloat(),
                centerPosition.y.toFloat(),
                mGradientColors!!,
        mCirPaint.shader = mSweepGradient

我们再 onSizeChanged 即显示出来的时候,我们对半径,中心点,位置矩阵做一些赋值,然后我们顺便设置并支持了渐变的圆形进度支持。

如果支持渐变的话,我们对主圆的画笔颜色设置为渐变的方式。

所有的资源和信息都已经初始化和赋值之后,我们就可以开始绘制了。

三、文本与圆环的绘制

由于是自定义View了,绘制肯定是走 onDraw 方法了。

* 核心方法-绘制文本与圆环 override fun onDraw(canvas: Canvas?) { super.onDraw(canvas) drawText(canvas) drawCircle(canvas)

我们先看文本的绘制,一个是百分比,一个是提示的文本。我们使用 canvas.drawText 来绘制文本。方法如下:

* 绘制中心的文本 private fun drawText(canvas: Canvas?) { canvas!!.drawText( mValue + mUnit, centerPosition.x.toFloat(), centerPosition.y.toFloat(), mValuePaint if (mHint != null || mHint != "") { canvas.drawText( mHint.toString(), centerPosition.x.toFloat(), centerPosition.y - mHintPaint.ascent() + 15, //设置Y轴间距 mHintPaint

我们赋值的Value比如为60,那么中间的绘制的文本就是60% 。我们绘制的Hilt提示文本就在我们的百分比文本下面,我们对中心点向下偏移15即可定位绘制。

那么绘制圆形怎么绘制,大家应该知道 drawCircle 和 drawArc 一个是圆形,一个是圆环,我们这里使用的是 drawArc 。

我们设置一个起始角度,和一个绘制角度,然后使用绘制背景的一个圆和进度主圆,代码如下:

    //绘制的起始角度和滑过角度(默认从顶部开始绘制,绘制总进度360度)
    private var mStartAngle: Float = 270f
    private var mSweepAngle: Float = 360f
     * 画圆(主要的圆)
    private fun drawCircle(canvas: Canvas?) {
        canvas?.save()
        if (mShadowIsShow) {
            mCirPaint.setShadowLayer(mShadowSize!!, 0f, 0f, mShadowColor!!) //设置阴影
        //画背景圆(画一整个圆环)
        canvas?.drawArc(mRectF!!, mStartAngle, mSweepAngle, false, mBgCirPaint)
        //画圆(计算出进度吗,按进度来绘制)
        val percent = value.toFloat() / maxValue
        canvas?.drawArc(mRectF!!, mStartAngle, mSweepAngle * percent, false, mCirPaint)
        canvas?.restore()

如此我们就可以画出静态的2个圆环了,并且我们顺便把阴影也绘制出来,使用的是 setShadowLayer 方法,当然阴影的使用我们还有另一种方法 BlurMaskFilter 也可以设置阴影的效果,大家如果有兴趣可以翻看我前面的文章,实现圆角阴影的ViewGroup

其实到这里就已经可以展示出我们文章开头的那种静态展示效果了。

虽然静态能实现了,但是和我们设计师的需求还是差一点效果,怎么让进度条动起来呢?

四、动起来

思路:定义一个属性动画,定义出开始的百分比进度和计算出总共需要的百分比进度,然后我们通过属性动画从开始进度到总共进度做指定时间的动画。

    //属性动画
    private var mAnimator: ValueAnimator? = null
    //动画进度
    private var mAnimPercent: Float = 0f
     * 执行属性动画
    private fun startAnim(start: Float, end: Float, animTime: Int) {
        mAnimator = ValueAnimator.ofFloat(start, end)
        mAnimator?.duration = animTime.toLong()
        mAnimator?.addUpdateListener {
            //得到当前的动画进度并赋值
            mAnimPercent = it.animatedValue as Float
            //根据当前的动画得到当前的值
            mValue = if (isAnim) {
                roundByScale((mAnimPercent * mMaxValue).toDouble(), mDigit)
            } else {
                roundByScale(mValue!!.toDouble(), mDigit)
            //不停的重绘当前值-表现出动画的效果
            postInvalidate()
        mAnimator?.start()
    //计算百分比的小数点后面的位数显示
    fun roundByScale(v: Double, scale: Int): String {
        if (scale < 0) {
            throw IllegalArgumentException("参数错误,必须设置大于0的数字")
            if (scale == 0) {
                return DecimalFormat("0").format(v)
            var formatStr = "0."
            for (i in 0 until scale) {
                formatStr += "0"
            return DecimalFormat(formatStr).format(v);
     * 画圆(主要的圆)
    private fun drawCircle(canvas: Canvas?) {
        canvas?.save()
        if (mShadowIsShow) {
            mCirPaint.setShadowLayer(mShadowSize!!, 0f, 0f, mShadowColor!!) //设置阴影
        //画背景圆
        canvas?.drawArc(mRectF!!, mStartAngle, mSweepAngle, false, mBgCirPaint)
        canvas?.drawArc(mRectF!!, mStartAngle, mSweepAngle * mAnimPercent, false, mCirPaint)
        canvas?.restore()

总体代码也是比较简单,这样就可以通过动画动态的计算出当前百分比的Value值和当前动画进度值。然后我们修改drawCircle的方法以当前的进度为准来绘制动画,这样就可以做出动画的效果。

动画执行的效果如下:

本篇为自定义View的基础绘制起始,后期会有一个小系列的自定义View合集,我会在此基础上加入交互的逻辑,下次就没有这么细了哦 - -|

通过这一篇圆形进度的自定义绘制,我们其实可以进行扩展,思路打开。不仅仅是可以绘制圆形,圆环,还能绘制横向,纵向的进度条,还能绘制带进度的文本。只要是Canvas支持的能draw的东西,我们都能以这种方式做成进度的方式。

虽然是比较简单的自定义View了,我还是上传到了Maven,有需要的直接依赖即可

implementation "com.gitee.newki123456:circle_progress_view:1.0.0"

当然如果你想查看源码或者做一些定制化的修改,点击传送门查看源码。

同时,你也可以关注我的Kotlin项目合集,传送门。项目会持续更新。

惯例,我如有讲解不到位或错漏的地方,希望同学们可以指出交流。

如果感觉本文对你有一点点的启发,还望你能点赞支持一下,你的支持是我最大的动力。

Ok,这一期就此完结。

本文正在参加「金石计划 . 瓜分6万现金大奖」