Android 自定义一个半圆环进度条

2,092 阅读7分钟

需求

UI同学出了一张图,其中一个ui是半圆环进度条,在2秒内从0%加载到100%。

image.png

类似这种,请不要介意它的难看,颜色可以调整,重要的是功能,功能,功能。

思路

提到进度条,首先想到的是progressBar,不过progressBar只有水平进度条和圆环进度条这2种,不满足需求。

在网上找了一圈,有实现方案的,但感觉有点复杂,修改成自己的有点麻烦,遂决定自己写一个圆环,顺便重新学习一下canvas的使用。

Canvas

canvas的常用方法如下:

drawRect

public void drawRect(@android.annotation.NonNull android.graphics.RectF rect, @android.annotation.NonNull android.graphics.Paint paint)

public void drawRect(@android.annotation.NonNull android.graphics.Rect r, @android.annotation.NonNull android.graphics.Paint paint)

public void drawRect(float left, float top, float right, float bottom, @android.annotation.NonNull android.graphics.Paint paint)

绘制一个矩形区域,前面的参数是矩形的坐标,后面的参数是画笔。

drawPath

public void drawPath(@android.annotation.NonNull android.graphics.Path path, @android.annotation.NonNull android.graphics.Paint paint) 

根据path路线绘制一条线,这个方法是灵活性最高的,但同时也是比较麻烦的,因为path是由一个个固定的关键点决定的。

drawLine

public void drawLine(float startX, float startY, float stopX, float stopY, @android.annotation.NonNull android.graphics.Paint paint) 

public void drawLines(@android.annotation.NonNull float[] pts, int offset, int count, @android.annotation.NonNull android.graphics.Paint paint) 

public void drawLines(@android.annotation.NonNull float[] pts, @android.annotation.NonNull android.graphics.Paint paint)

绘制一条线,灵活性同上

drawPoint

public void drawPoint(float x, float y, @android.annotation.NonNull android.graphics.Paint paint)

public void drawPoints(float[] pts, int offset, int count, @android.annotation.NonNull android.graphics.Paint paint) 

public void drawPoints(@android.annotation.NonNull float[] pts, @android.annotation.NonNull android.graphics.Paint paint) 

根据坐标绘制点,和上面的不同的是,点是孤立的,并非连接在一起的。

drawText

绘制文字,其中drawText有四种重载方法,和三种drawTextRun同名方法,2种drawTextOnPath。

drawOver

public void drawOval(@android.annotation.NonNull android.graphics.RectF oval, @android.annotation.NonNull android.graphics.Paint paint)

public void drawOval(float left, float top, float right, float bottom, @android.annotation.NonNull android.graphics.Paint paint) 

根据rect的大小绘制椭圆

drawCircle

绘制圆形

public void drawCircle(float cx, float cy, float radius, @android.annotation.NonNull android.graphics.Paint paint)

根据圆心位置和半径绘制圆形

drawArc

public void drawArc(@android.annotation.NonNull android.graphics.RectF oval, float startAngle, float sweepAngle, boolean useCenter, @android.annotation.NonNull android.graphics.Paint paint) 

public void drawArc(float left, float top, float right, float bottom, float startAngle, float sweepAngle, boolean useCenter, @android.annotation.NonNull android.graphics.Paint paint)

根据区域、圆心坐标、半径绘制弧度,startAngle是圆弧的起始角度,sweepAngle是扫描的弧度。

1、方案一

半圆环,可以当初是两个两个圆弧拼接成的封闭区域,半径较大的圆弧的背景是圆弧的颜色,较小的背景是白色,这样就可以实现一个圆环。

2、方案二

方案一不推荐,因为实现带背景的半圆环进度条,一共需要画三个圆弧。一个圆弧,背景为灰色,一个绿色圆弧,大小同灰色圆弧,最后一个白色背景的小圆弧,计算起来比较麻烦。而且相比较两个圆环拼接,不如直接将paint的strokWidth设置成大圆弧和小圆弧的差。这样只需要绘制两个圆弧就行了,而且两个圆弧的大小一样,只是画笔的颜色不一样就行了。

实现

第一步 先实现一个半圆环

//目前先写死
private var ringWidth = 55f

private val paint by lazy {
    Paint().apply {
        isAntiAlias = true
        color = Color.GREEN
        style = Paint.Style.STROKE
        strokeWidth = ringWidth
    }
}
//this is ring‘s background
private val greyPaint by lazy {
    Paint().apply {
        isAntiAlias = true
        color = Color.parseColor("#999999")
        style = Paint.Style.STROKE
        strokeWidth = ringWidth
    }
}
private var rectF: RectF = RectF()

用ringWidth表示圆环的宽度,paint是半圆环的颜色,greyPaint是背景色,rectF是绘制的区域。

然后在onMeasure中设置绘制区域的大小,在onDraw中绘制半圆环。

//在onMeasure中设置绘制区域
rectF.set(
    ringWidth,
    ringWidth,
    measuredWidth.toFloat() - ringWidth,
    (measuredHeight - ringWidth) * 2
)
//在onDraw中绘制半圆环进度条
//draw a ring background
canvas.drawArc(rectF, 180f, 180f, false, greyPaint)
//draw a ring
canvas.drawArc(rectF, 180f, 90f, false, paint)

这样子就能实现一个静止的半圆环进度条了。 那么,如何能让这个进度条动起来呢? drawArc方法中,sweepAngle是圆弧滑过的角度,只要在绘制的过程中不断更新这个值(假设时间是两秒,那么,在两秒的时间内,不断变化sweepAngle的值,然后更新ui)。

第二步 让半圆环进度条动起来

//圆弧的角度
private var sweepAngle = 0f
//动画时间
private var animatorDuration = 2000L

增加两个变量,然后添加动画

private val animator by lazy {
    ValueAnimator.ofFloat(0f, 180f).apply {
        addUpdateListener {
            sweepAngle = it.animatedValue as Float
            invalidate()
        }
        duration = animatorDuration
    }
}

在构造函数中运行动画animator.start() 这样,一个会动的半圆环进度条就实现了。

第三步 定制化

让圆环进度条丰富起来。

改变进度条的颜色

这个进度条有两个颜色的值,一个是进度条的背景色,一个是进度条颜色。想要改变两个的值,有两种方法,一个是直接为其添加属性,另一个是加入两个暴露外部的方法。

添加属性

进度条在xml下的代码如下:

<com.testdemo.CustomHalfCircleProgress
    android:id="@+id/progress"
    android:layout_width="346dp"
    android:layout_height="173dp"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent" />

此时,在attrs文件下添加属性(可能values文件夹下面没有这个文件,那么也可以写在其他文件里,如themes或者在values文件夹下面创建一个文件attrs.xml)

<declare-styleable name="CustomHalfCircleProgress">
    <attr name="bgColor" format="color" />
    <attr name="progressColor" format="color" />
</declare-styleable>

这样,回到xml布局,就可以为progress添加这两个属性了

<com.light_mountain.testdemo.CustomHalfCircleProgress
    android:id="@+id/progress"
    android:layout_width="346dp"
    android:layout_height="173dp"
    app:bgColor="#3FB6F7"
    app:progressColor="#006da8"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent" />

回到CustomHalfCircleProgress自定义View中,在构造函数constructor(context: Context, attrs: AttributeSet)中获取定义的属性

val typedArray = context.obtainStyledAttributes(attrs, R.styleable.CustomHalfCircleProgress)
bgColor = typedArray.getColor(R.styleable.CustomHalfCircleProgress_bgColor, Color.RED)
progressColor =
    typedArray.getColor(R.styleable.CustomHalfCircleProgress_progressColor, Color.BLUE)
typedArray.recycle()
paint.color = progressColor
bgPaint.color = bgColor

这样就可以实现一个以浅蓝色为背景,深蓝色为进度条颜色的半圆环进度条了。同理,其他的一些属性,如最大角度(最大值)、动画时间、进度条宽度等属性都能直接在xml文件中设置,方便快捷。

但它也有一个缺点。

因为是自定义属性,会占用属性名,而属性名是不能重复的,如果进度条使用了bgColor这个属性名,其他的方法就不能在使用这个属性名了。

添加方法

fun setBgColor(color: Int): CustomHalfCircleProgress {
    bgColor = color
    bgPaint.color = bgColor
    return this
}

fun setProgressColor(color: Int): CustomHalfCircleProgress {
    progressColor = color
    paint.color = progressColor
    return this
}

然后在onCreate中获取View,并设置颜色

val progress = findViewById<CustomHalfCircleProgress>(R.id.progress)
progress
    .setProgressColor(Color.parseColor("#006DA8"))
    .setBgColor(Color.parseColor("#3FB6F7"))

这样就可以随时更改进度条和背景的颜色。

补全其他的方法,如设置进度条的宽度,设置动画时间,启动动画和停止动画

fun setDuration(time: Long): CustomHalfCircleProgress {
    this.animatorDuration = time
    animator.duration = animatorDuration
    return this
}

fun setRingWidth(value:Float) :CustomHalfCircleProgress{
    this.ringWidth = value
    paint.strokeWidth = ringWidth
    bgPaint.strokeWidth = ringWidth
    return this
}

fun start():CustomHalfCircleProgress {
    animator.start()
    return this
}

fun stop():CustomHalfCircleProgress{
    animator.cancel()
    return this
}

给进度条设置宽度为22f,动画时间为5秒,效果如下图(2秒的时候截的图)

image.png

额外需求

是不是很完美了,基本上可以做到进度条客制化了,剩下的大多都是细节问题,如

1、显示进度条的百分比

2、进度条结束了需要通知外部

3、自定义进度条的起始百分比和结束百分比

4、进度条渐变色

百分比

这两个也容易,增加一个变量percent表示当前的进度,再加入一个paint作为百分比的画笔,

private val textPaint = Paint().apply {
    isAntiAlias = true
    color = Color.parseColor("#006da8")
    textSize = 24f
    textAlign = Paint.Align.CENTER
}
override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    ...省略代码...
    //draw percent
    canvas.drawText("${percent}%", percentX, percentY, textPaint)
}

percentX和percentY即在onMeasure中设置,我这边把文字的位置设置在水平居中,2/3高度的位置。

注:在canvas的画布中,x轴是向右为正,y轴是向下为正。

注2:刚开始绘制文字的时候,canvas的最后一个参数是直接使用进度条的paint,结果发现绘制出来的文字堆挤在一起,还以为是画布没有刷新,文字‘1%’和‘2%’重叠在一起导致的,于是使用canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR)更新画布,结果导致视图的背景变黑,但百分比还是拥挤在一块。

image.png 百分比堆挤在一起

image.png 正常的百分比显示

进度通知

这个容易,接口回调

interface Listener {
    fun success()
    fun fail()
}

定义一个接口,两个方法,success表示进度条正常结束回调,fail表示进度条动画被cancel。

private val animator by lazy {
    ValueAnimator.ofFloat(0f, 180f).apply {
        ...省略代码...
        addListener(object : AnimatorListener {
            override fun onAnimationStart(p0: Animator?) {
                isCancel = false
            }

            override fun onAnimationEnd(p0: Animator?) {
                if (isCancel) listener?.fail() else listener?.success()
            }

            override fun onAnimationCancel(p0: Animator?) {
                isCancel = true
            }

            override fun onAnimationRepeat(p0: Animator?) {
            }
        })
    }
}

在progress控件绑定后,添加接口回调的方法

progress.setListener(object : CustomHalfCircleProgress.Listener{
        override fun fail() {
            //动画执行失败,进度条半道中止
        }

        override fun success() {
            //进度条百分百,成功执行完毕
        }
    })

自定义起始百分比和结束百分比

这个更加容易了

//起始百分比
private var startAngle = 0f
//结束百分比
private var endAngle = 180f
...省略代码...
/**
 * 这里要注意的是,startPercent<=endPercent,且数值在0-100的范围内,这里就不多做判断了
 */
fun setStartPercent(percent: Int): CustomHalfCircleProgress {
    startAngle = percent * 180f / 100f
    animator.setFloatValues(startAngle, endAngle)
    return this
}

/**
 * 这里需要注意的一点是,动画结束时,调用的仍然是listener?.success,即便此时未到100%,如果有需求的话,可以在onAnimationEnd里多做一层判断
 */
fun setEndPercent(percent: Int) :CustomHalfCircleProgress {
    endAngle = percent * 180f / 100f
    animator.setFloatValues(startAngle, endAngle)
    return this
}

进度条渐变色

这个更加简单了,paint+渐变色直接可以搜得到答案 # Android绘图之LinearGradient线性渐变 就不提供相关代码了。

完整代码

以下是自定义的半圆环进度条的完整代码

class CustomHalfCircleProgress : View {

    private var percent: Int = 0
    private var percentX: Float = 0f
    private var percentY: Float = 0f

    private var ringWidth = 55f

    //圆弧的角度
    private var sweepAngle = 0f
    private var startAngle = 0f
    private var endAngle = 180f

    //动画时间
    private var animatorDuration = 2000L

    private var bgColor = Color.parseColor("#999999")
    private var progressColor = Color.GREEN

    private var listener: Listener? = null
    private var isCancel = false
    private val paint =
        Paint().apply {
            isAntiAlias = true
            color = Color.parseColor("#006da8")
            style = Paint.Style.STROKE
            strokeWidth = ringWidth
        }

    //this is ring‘s background
    private val bgPaint =
        Paint().apply {
            isAntiAlias = true
            color = Color.parseColor("#999999")
            style = Paint.Style.STROKE
            strokeWidth = ringWidth
        }

    private val textPaint = Paint().apply {
        isAntiAlias = true
        color = Color.parseColor("#006da8")
        textSize = 24f
        textAlign = Paint.Align.CENTER
    }

    private val animator by lazy {
        ValueAnimator.ofFloat(startAngle, endAngle).apply {
            addUpdateListener {
                sweepAngle = it.animatedValue as Float
                percent = (sweepAngle * 100 / 180f).toInt()
                postInvalidate()
            }
            duration = animatorDuration
            addListener(object : AnimatorListener {
                override fun onAnimationStart(p0: Animator?) {
                    isCancel = false
                }

                override fun onAnimationEnd(p0: Animator?) {
                    if (isCancel) listener?.fail() else listener?.success()
                }

                override fun onAnimationCancel(p0: Animator?) {
                    isCancel = true
                }

                override fun onAnimationRepeat(p0: Animator?) {
                }
            })
        }
    }

    private var rectF: RectF = RectF()//区域

    constructor(context: Context) : super(context)
    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
        initView(context, attrs)
    }

    constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super(
        context,
        attrs,
        defStyleAttr
    )

    private fun initView(context: Context, attrs: AttributeSet) {

    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        //draw a ring background
        canvas.drawArc(rectF, 180f, 180f, false, bgPaint)
        //draw a ring
        canvas.drawArc(rectF, 180f, sweepAngle, false, paint)
        //draw percent
        canvas.drawText("${percent}%", percentX, percentY, textPaint)

    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        rectF.set(
            ringWidth,
            ringWidth,
            measuredWidth.toFloat() - ringWidth,
            (measuredHeight - ringWidth) * 2
        )
        percentX = measuredWidth / 2.0f
        percentY = measuredHeight / 3.0f
    }

    fun setBgColor(color: Int): CustomHalfCircleProgress {
        bgColor = color
        bgPaint.color = bgColor
        return this
    }

    fun setProgressColor(color: Int): CustomHalfCircleProgress {
        progressColor = color
        paint.color = progressColor
        return this
    }

    fun setDuration(time: Long): CustomHalfCircleProgress {
        this.animatorDuration = time
        animator.duration = animatorDuration
        return this
    }

    fun setRingWidth(value: Float): CustomHalfCircleProgress {
        this.ringWidth = value
        paint.strokeWidth = ringWidth
        bgPaint.strokeWidth = ringWidth
        return this
    }

    fun start(): CustomHalfCircleProgress {
        animator.start()
        return this
    }

    fun stop(): CustomHalfCircleProgress {
        animator.cancel()
        return this
    }

    fun setListener(listener: Listener): CustomHalfCircleProgress {
        this.listener = listener
        return this
    }

    /**
     * 这里要注意的是,startPercent<=endPercent,且数值在0-100的范围内
     */
    fun setStartPercent(percent: Int): CustomHalfCircleProgress {
        startAngle = percent * 180f / 100f
        animator.setFloatValues(startAngle, endAngle)
        return this
    }

    /**
     * 这里需要注意的一点是,动画结束时,调用的仍然是listener?.success,即便此时未到100%,如果有需求的话,可以在onAnimationEnd里多做一层判断
     */
    fun setEndPercent(percent: Int) :CustomHalfCircleProgress {
        endAngle = percent * 180f / 100f
        animator.setFloatValues(startAngle, endAngle)
        return this
    }

    interface Listener {
        fun success()
        fun fail()
    }
}

以上就是本人实现一个简单的进度条的方案,能用,但用处不大,不过对付一般简单容易的需求很好满足。