记一次自定义View: 扇形圆环


前言

本文是对 完全自定义View 的一次实践。实现了一个 扇形圆环. 包括渐变色,增长动画等.
好了话不多少, 我们先上图

动图


一、分析

  1. 两个画笔, 一个背景, 一个前景; 然后绘制环形即可;
  2. 属性动画, 不断增加彩色环的角度; 然后不断重绘;

涉及问题点:

  1. 自定义属性接收, 包括色彩集合
  2. onMeasure 适配View自适应的情况
  3. 色彩环 在 0° 位置 色彩分界线处理

二、上代码

1.自定义View代码

class FanRingView(context: Context, attrs: AttributeSet?)
    : View(context, attrs) {
    private val mPaint: Paint   // 圆环前景画笔
    private val mBgPaint: Paint // 圆环背景画笔

    private var mRectF: RectF? = null   // 圆环的矩形区域

    private var mViewCenterX = 0f // view宽的中心点(圆心X)
    private var mViewCenterY = 0f // view高的中心点(圆心Y)
    private var mCurrentRing = 0f // 彩色区域值(随数值变化)

    private var mSweepAngle = 270f      // 圆环角度
    private var mRingWidth = 18f    // 圆环宽度
    private var duration = 1500L     // 动画时长
    private var valueAnimator: ValueAnimator? = null    // 属性动画

    private lateinit var color: IntArray //渐变颜色
    private val defaultWidth: Int    // 自适应的默认宽高, 200dp

    companion object{
        const val MAX_VALUE = 100   // 圆环最大值
        const val TAG = "FanRingView"
    }

    init {
        val typedArray = context.obtainStyledAttributes(attrs, R.styleable.CircleProgressBar)
        mSweepAngle = typedArray.getFloat(R.styleable.CircleProgressBar_sweepAngle, 270f)
        val bgArcColor = typedArray.getColor(R.styleable.CircleProgressBar_bgArcColor, Color.LTGRAY)
        mRingWidth = typedArray.getDimension(R.styleable.CircleProgressBar_arcWidth, 18f)
        duration = typedArray.getInt(R.styleable.CircleProgressBar_animTime, 1500).toLong()

        // 着色器 渐变色彩集合
        val gradientArcColors = typedArray.getResourceId(R.styleable.CircleProgressBar_arcColors, 0)
        Log.i(TAG, "initAttrs: gradientArcColors--$gradientArcColors")
        if (gradientArcColors != 0) {
            try {
                val gradientColors = resources.getIntArray(gradientArcColors)
                Log.i(TAG, "initAttrs: gradientColors.length::"+gradientColors.size)
                when(gradientColors.size) {
                    0 -> {
                        //如果渐变色为数组为0,则尝试以单色读取色值
                        val colorSingle = resources.getColor(gradientArcColors,null)
                        color = IntArray(1)
                        color[0] = colorSingle
                    }
                    1 -> {
                        //如果渐变数组只有一种颜色,默认设为两种相同颜色
                        color = IntArray(1)
                        color[0] = gradientColors[0]
                    }
                    else -> {
                        color = gradientColors
                    }
                }
            } catch (e: Resources.NotFoundException) {
                // 或给默认值
                throw Resources.NotFoundException("the give resource not found.")
            }
        }
        typedArray.recycle()

		// 画笔的初始化可以滞后, 以减少 onCreate 的时耗. 这里博主懒了
        //背景画笔
        mBgPaint = Paint(Paint.ANTI_ALIAS_FLAG) // 抗锯齿
        mBgPaint.style = Paint.Style.STROKE     // 只绘制圆形的边
        mBgPaint.strokeWidth = mRingWidth       // 绘制宽度
        mBgPaint.color = bgArcColor             // 绘制颜色
        mBgPaint.strokeCap = Paint.Cap.ROUND    // 画笔笔刷类型, 末端圆角

        //圆环画笔
        mPaint = Paint(Paint.ANTI_ALIAS_FLAG)
        mPaint.style = Paint.Style.STROKE
        mPaint.strokeWidth = mRingWidth
        mPaint.strokeCap = Paint.Cap.ROUND

        defaultWidth = AndroidUtils.dp2px(context, 200f)
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        // 适配自适应宽高, wrap_content 的情况
        val widthSpecMode = MeasureSpec.getMode(widthMeasureSpec)
        val widthSpecSize = MeasureSpec.getSize(widthMeasureSpec)
        val heightSpecMode = MeasureSpec.getMode(heightMeasureSpec)
        val heithtSpecSize = MeasureSpec.getSize(heightMeasureSpec)

        when{
            widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST -> {
                // 宽高都自适应, 此时给固定宽高
                setMeasuredDimension(defaultWidth, defaultWidth)
            }
            widthSpecMode == MeasureSpec.AT_MOST -> {
                // 宽自适应, 高固定值; 那就按固定值设定宽高
                setMeasuredDimension(heithtSpecSize, heithtSpecSize)
            }
            heightSpecMode == MeasureSpec.AT_MOST -> {
                // 高自适应, 高固定值; 那就按固定值设定宽高
                setMeasuredDimension(widthSpecSize, widthSpecSize)
            }
            else -> super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        }
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)

        // 尺寸变更时, 需重新确定圆心位置, 及 矩形区域
        resetBlock(w, h)
    }

    private fun resetBlock(width: Int, height: Int){
        if (width > 0 && height > 0) {
            mViewCenterX = width / 2f
            mViewCenterY = height / 2f

            // 设置着色器
            mPaint.shader = SweepGradient(mViewCenterX, mViewCenterX, color, null)

            val radius = (width.coerceAtMost(height) - mRingWidth) / 2f
            mRectF = RectF(mViewCenterX - radius, mViewCenterY - radius, mViewCenterX + radius, mViewCenterY + radius)
        }
    }

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

        if(mRectF == null){
            // 确保, 圆心点 及 Rect初始化;
            resetBlock(measuredWidth, measuredHeight)
        }

        // canvas.drawArc 为逆时针绘制
        // 其中 startAngle: right=0; bottom=90; left=180; top=270;
        val start = 90f + (360f - mSweepAngle) / 2f

        //画背景圆环
        drawNormalRing(canvas, start)
        //画彩色圆环
        drawColorRing(canvas, start)
    }

    /**
     * 画背景圆环
     */
    private fun drawNormalRing(canvas: Canvas?, start: Float) {
        // 由于背景环 与 彩色环 存在重叠. 存在过度绘制. 我们尽可能减少过度绘制区域
        // start, mSweepAngle, mCurrentRing 都为 角度,度数 参数
        val startReal = start + mCurrentRing - 5f
        val ringReal = mSweepAngle - mCurrentRing + 5f
        canvas?.drawArc(mRectF!!, startReal, ringReal, false, mBgPaint)
    }

    /**
     * 画彩色圆环
     */
    private fun drawColorRing(canvas: Canvas?, start: Float) {
        if(mCurrentRing == 0f) return

        // 画布旋转;  防止 着色器 在0° 卡出颜色分界线;
        canvas?.rotate(start - 5f, mViewCenterX, mViewCenterY)
        canvas?.drawArc(mRectF!!, 5f, mCurrentRing, false, mPaint)
    }

    /**
     * 设置当前值
     */
    fun setValue(value: Int, textView: TextView) {
        valueAnimator?.cancel() // 首先取消还未完成的 属性动画
        val current = if (value > MAX_VALUE) MAX_VALUE else value
        startAnimator(current, textView)
    }

    private fun startAnimator(end: Int, textView: TextView) {
        valueAnimator = ValueAnimator.ofFloat(0f, end.toFloat()).also {
            it.duration = duration
            it.addUpdateListener { animation ->
                Log.i(TAG, "startAnimator: AnimatedValue()--${animation.animatedValue}")
                val i: Float = animation.animatedValue as Float
                textView.text = i.toInt().toString()

                // 计算度数
                mCurrentRing = mSweepAngle / 100f * i
                Log.i(TAG, "startAnimator: mSelectRing::$mCurrentRing")
                invalidate()

                // 动画完毕, 对象处理
                if(i >= end) valueAnimator = null
            }
            it.start()
        }
    }
    
    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        valueAnimator?.cancel()
        valueAnimator = null
    }
}

2.布局及Activity的代码

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools">
    
    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".test.customview.FanRingActivity">
        <com.example.kotlinmvpframe.test.customview.custom.FanRingView
            android:id="@+id/frv_ring"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:arcWidth="10dp"
            app:arcColors="@array/gradient_arc_color"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent" />
        <TextView
            android:id="@+id/tv_value"
            style="@style/tv_base_16_dark"
            android:layout_marginBottom="40dp"
            android:padding="15dp"
            android:background="?selectableItemBackground"
            app:layout_constraintStart_toStartOf="@id/frv_ring"
            app:layout_constraintEnd_toEndOf="@id/frv_ring"
            app:layout_constraintBottom_toBottomOf="@id/frv_ring" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

Activity:

// 设置值
binding.frvRing.setValue(100, binding.tvValue)

// 点击TextView时, 随机设置值
binding.tvValue.setOnClickListener {
    val score: Int = (1..100).random()
    binding.frvRing.setValue(score, binding.tvValue)
}

3.其他代码

自定义属性: values/attr.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!--********************基本圆形进度条***************-->
    <!-- 圆弧度数 -->
    <attr name="sweepAngle" format="float" />
    <!-- 设置动画时间 -->
    <attr name="animTime" format="integer" />

    <!-- 背景圆弧颜色 -->
    <attr name="bgArcColor" format="color|reference" />
    <!-- 圆弧宽度 -->
    <attr name="arcWidth" format="dimension" />
    <!-- 圆弧颜色, -->
    <attr name="arcColors" format="color|reference" />

    <declare-styleable name="CircleProgressBar">
        <attr name="sweepAngle" />
        <attr name="animTime" />
        <!-- 圆弧宽度 -->
        <attr name="arcWidth" />
        <attr name="arcColors" />
        <!-- 背景圆弧颜色 -->
        <attr name="bgArcColor" />
    </declare-styleable>
</resources>

颜色集合文件: values/array.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <integer-array name="gradient_arc_color">
        <item>0xFFFF0000</item>
        <item>0xFFFFAA00</item>
        <item>0xFFFFFF00</item>
    </integer-array>
</resources>

三、部分讲解

1.Paint 设置:

mBgPaint.strokeCap = Paint.Cap.ROUND	// 画笔笔刷类型, 末端圆角

上面这句是让圆环 末端圆角的关键代码. 不然末端为直线样式, 并不好看;

// 设置着色器
mPaint.shader = SweepGradient(mViewCenterX, mViewCenterX, color, null)

上面这句是设置 着色器. 颜色渐变的关键代码;

2. 零度位置, 色彩分界线处理

由于我们的圆环末端为圆角. 而着色器在 0°位置会产生一个分界线.

假如我们的代码类似这样:

canvas?.drawArc(mRectF!!, -90f, 350f, false, mPaint)

效果如下图所示:
在这里插入图片描述
即便我们旋转画布. 那么这个分界线也会在左下角产生.
因此: 我们将旋转画布, 并预留出5°的位置, 来避开这个分界线!

 /**
 * 画彩色圆环
 */
private fun drawColorRing(canvas: Canvas?, start: Float) {
    if(mCurrentRing == 0f) return

    // 画布旋转;  防止 着色器 在0° 卡出颜色分界线;
    canvas?.rotate(start - 5f, mViewCenterX, mViewCenterY)
    canvas?.drawArc(mRectF!!, 5f, mCurrentRing, false, mPaint)
}

3.View自适应

defaultWidth 该属性作为 默认的宽高值(目前为200dp)
onMeasure 回调中, 当宽高都为 wrap_content 时, 启用该属性;

4.属性动画:

需要注意的点:

  • 当View销毁时, 关闭属性动画, 以免内存泄漏
  • 再次设定数值时, 首先关掉未执行完毕的先前动画
  • 动画执行完毕时, 对象置为 null. 以免内存泄漏

5.减少过度绘制

private fun drawNormalRing(canvas: Canvas?, start: Float) {
    // 由于背景环 与 彩色环 存在重叠. 存在过度绘制. 我们尽可能减少过度绘制区域
    val startReal = start + mCurrentRing - 5f
    val ringReal = mSweepAngle - mCurrentRing + 5f
    canvas?.drawArc(mRectF!!, startReal, ringReal, false, mBgPaint)
}

色彩环和背景环 会有重叠部分. 重叠部分背景的绘制 并无必要. 因此我们计算色彩环角度, 绘制背景环时 适当减去 色彩环的部分

总结

没有总结

上一篇: WorkManager笔记: 三、加急工作
下一篇: 记一次自定义View:滑动标尺

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值