Android 自定义 View 进阶篇 - 贝塞尔曲线

368 阅读4分钟

贝塞尔曲线的本质是通过数学计算的公式来绘制平滑的曲线,分为一阶,二阶,三阶及多阶。但是这里不讲数学公式和验证,那些伟大的数学家已经证明过了,所以这里就只讲讲 Android 开发中的运用吧!

对于 Android 开发,实现贝塞尔曲线还是比较方便的,有对应的 API 供你调用。由于一阶贝塞尔曲线就是一条直线,实际没啥多大用处,因此,下面主要讲解二阶和三阶。

二阶贝塞尔曲线

在 Android 开发中,使用 quadTo 来实现二阶贝塞尔

        path.reset()
        path.moveTo(startX, startY)
        path.quadTo(currentX, currentY, endX, endY)
        canvas.drawPath(path, curvePaint)

startX 和 startY,endX 和 endY 为两个固定点,currentX 和 currentY 就是控制点,通过改变控制点的位置来改变二阶贝塞尔曲线的形状。

贝1.gif

a 点和 b 点就是固定点,c 点是控制点,我们可以改变 c 点的位置来改变曲线的形状。

    override fun onTouchEvent(event: MotionEvent): Boolean {
        when (event.action) {
            MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
                currentX = event.x
                currentY = event.y
                postInvalidate()
            }
        }
        return true
    }

三阶贝塞尔曲线

在 Android 开发中,使用 cubicTo 来实现三阶贝塞尔

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        path.reset()
        path.moveTo(startX, startY)
        path.cubicTo(fixedX1, fixedY1, fixedX2, fixedY2, endX, endY)
        canvas.drawPath(path, curvePaint)
        //绘制辅助线
        drawHelpLine(canvas)
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        when (event.action) {
            MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
                //divideLine 区分触摸点是左边还是右边
                if (event.x < divideLine) {
                    fixedX1 = event.x
                    fixedY1 = event.y
                } else {
                    fixedX2 = event.x
                    fixedY2 = event.y
                }
                postInvalidate()
            }
        }
        return true
    }

其中,startX 和 startY,endX 和 endY 为两个固定点,fixedX1 和 fixedY1,fixedX2 和 fixedY2 分别为两个控制点,通过改变控制点的位置来改变三阶贝塞尔曲线的形状。

贝2.gif

a 点和 b 点就是固定点,c 点和 d 点是控制点,我们可以改变 c 点或 d 点的位置来改变曲线的形状。

实战

关于贝塞尔曲线,最典型的应用就是波浪球了,那咱们也来整一个,先上图

贝3.gif

首先裁剪一下画布,变为圆形

        val circlePath = Path()
        circlePath.addCircle(width / 2f, height / 2f, width / 2f, Path.Direction.CW)
        canvas.clipPath(circlePath)

Path.Direction.CW:沿顺时针方向绘制,Path.Direction.CCW:沿逆时针方向绘制

以 View 为中心,画圆

        canvas.drawCircle(width / 2f, height / 2f, width / 2f, circularPaint)

利用二阶贝塞尔,绘制波浪,起点为屏幕外,circleLen 为曲线 1/4 周期长度

    private val startPoint = Point(-4 * circleLen, 0)

根据进度改变起点坐标的 y 值,控制点为曲线的顶部和底部,循环绘制,然后构建曲线之下的封闭区域,填充

        //根据进度改变起点坐标的 y 值
        startPoint.y = ((1 - (progress / 100.0)) * height).toInt()
        //移动到起点
        wavePath.moveTo(startPoint.x.toFloat(), startPoint.y.toFloat())
        var j = 1
        //循环绘制曲线
        for (i in 1..8) {
            val controlX = (startPoint.x + circleLen * j).toFloat()
            //波顶和波底
            val controlY =
                if (i % 2 == 0) (startPoint.y + waveHeight).toFloat() else (startPoint.y - waveHeight).toFloat()
            //二阶贝塞尔
            wavePath.quadTo(
                controlX,
                controlY,
                (startPoint.x + circleLen * 2 * i).toFloat(),
                startPoint.y.toFloat()
            )
            j += 2
        }
        //绘制封闭的区域
        wavePath.lineTo(width.toFloat(), height.toFloat())
        wavePath.lineTo(startPoint.x.toFloat(), height.toFloat())
        wavePath.lineTo(startPoint.x.toFloat(), startPoint.y.toFloat())
        wavePath.close()
        canvas.drawPath(wavePath, wavePaint)
        wavePath.reset()
        //走完一周回到原点
        startPoint.x =
            if (startPoint.x + translateX >= 0) -circleLen * 4 else startPoint.x + translateX
            

这里是设置每隔 100ms,进度加一

        progress = if (progress >= 100) 0 else progress + 1
        postInvalidateDelayed(100)

全部代码如下

class ProgressBallView : View {

    //曲线 1/4 周期的长度
    private val circleLen = DensityUtils.dp2px(context, 53)

    //曲线高度
    private val waveHeight = DensityUtils.dp2px(context, 27)

    //默认的长宽值
    private val defaultSize = DensityUtils.dp2px(context, 300)

    //进度
    private var progress = 0

    //平移的长度
    private val translateX = circleLen / 4

    //圆形 Paint
    private val circularPaint = Paint()

    //波浪 Paint
    private val wavePaint = Paint()

    //波浪的路径
    private val wavePath = Path()

    //曲线的起始坐标
    private val startPoint = Point(-4 * circleLen, 0)

    constructor(context: Context) : super(context)

    constructor(context: Context, attributeSet: AttributeSet) : super(context, attributeSet) {
        initPaint()
    }

    private fun initPaint() {
        with(circularPaint) {
            isAntiAlias = true
            color = Color.GRAY
        }

        with(wavePaint) {
            isAntiAlias = true
            color = Color.RED
        }
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        var viewWidth = measureView(widthMeasureSpec)
        var viewHeight = measureView(heightMeasureSpec)
        //取最小的,作为长宽
        viewWidth = min(viewWidth, viewHeight)
        viewHeight = viewWidth
        setMeasuredDimension(viewWidth, viewHeight)
    }

    private fun measureView(measureSpec: Int): Int {
        val mode = MeasureSpec.getMode(measureSpec)
        return if (mode == MeasureSpec.AT_MOST || mode == MeasureSpec.EXACTLY) {
            MeasureSpec.getSize(measureSpec)
        } else {
            defaultSize
        }
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        //裁剪画布为圆形
        cutCanvas(canvas)
        //绘制圆形
        drawRound(canvas)
        //绘制波浪
        drawWave(canvas)
        //自动增长进度
        autoGrow()
    }

    //进度从 0-100,自动增长
    private fun autoGrow() {
        progress = if (progress >= 100) 0 else progress + 1
        postInvalidateDelayed(100)
    }

    //裁剪画布为圆形
    private fun cutCanvas(canvas: Canvas) {
        val circlePath = Path()
        circlePath.addCircle(width / 2f, height / 2f, width / 2f, Path.Direction.CW)
        canvas.clipPath(circlePath)
    }

    //绘制圆形
    private fun drawRound(canvas: Canvas) {
        canvas.drawCircle(width / 2f, height / 2f, width / 2f, circularPaint)
    }

    //绘制波浪
    private fun drawWave(canvas: Canvas) {
        //根据进度改变起点坐标的 y 值
        startPoint.y = ((1 - (progress / 100.0)) * height).toInt()
        //移动到起点
        wavePath.moveTo(startPoint.x.toFloat(), startPoint.y.toFloat())
        var j = 1
        //循环绘制曲线
        for (i in 1..8) {
            val controlX = (startPoint.x + circleLen * j).toFloat()
            //波顶和波底
            val controlY =
                if (i % 2 == 0) (startPoint.y + waveHeight).toFloat() else (startPoint.y - waveHeight).toFloat()
            //二阶贝塞尔
            wavePath.quadTo(
                controlX,
                controlY,
                (startPoint.x + circleLen * 2 * i).toFloat(),
                startPoint.y.toFloat()
            )
            j += 2
        }
        //绘制封闭的区域
        wavePath.lineTo(width.toFloat(), height.toFloat())
        wavePath.lineTo(startPoint.x.toFloat(), height.toFloat())
        wavePath.lineTo(startPoint.x.toFloat(), startPoint.y.toFloat())
        wavePath.close()
        canvas.drawPath(wavePath, wavePaint)
        wavePath.reset()
        //走完一周回到原点
        startPoint.x =
            if (startPoint.x + translateX >= 0) -circleLen * 4 else startPoint.x + translateX
    }

}