Android动画系列-赛贝尔曲线(小船游动动画Path+Region)

2,221 阅读4分钟

1.本文涉及到的知识点

贝塞尔曲线
View绘制
Path的用法扩展(Region)

2.绘制不规则连续波浪(贝塞尔曲线)

绘制波浪这一点,在我看来,不管是使用半圆弧,半圆,弧形,都是ok的,主要在于你怎么使用,以及绘制的各个坐标点,要怎么和动画相结合。

(这里我使用的是二阶贝塞尔曲线)

首先是参数的定义以及初始化:

class WaveBoatView(context: Context, attrs: AttributeSet) : View(context, attrs) {


    //一个波浪长,相当于两个二阶贝塞尔曲线的长度
    //var mWaveLength: Float = 200f

    //一个界面里,有几个波浪(和mWavaLength二选一)
    var mWaveNumber = 5

    //起始坐标点
    var mStartX = 0.0f
    var mStartY = 0.0f

    //波浪的画笔
    private var mPaint: Paint? = Paint().apply {
    	//既然是波浪,必须蓝色安排上
        color = Color.BLUE
        style = Paint.Style.FILL_AND_STROKE
        strokeJoin = Paint.Join.ROUND
    }

    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        //这里是根据要显示的数量,获取到一个浪的长度(如果不是采用固定长度,而是根据页面适配)
        mWaveLength = width.toFloat() / mWaveNumber
        //这里选择二分之一的高度,作为起始位置
        mStartY = height.toFloat() / 2
    }
}

接下来,就是绘制一个连续的波浪。

在绘制波浪之前,大家可能需要考虑下,如何通过多个贝塞尔曲线,拼接成一个连续的波浪

首先,我们需要了解Path.rQuadTo()和Path.quadTo()的区别,通过了解,可能rQuadTo更加适合我们,因为省去了Path中多个连接的坐标点的一个记录,比较方便(手动狗头) 以下是我们的一个贝塞尔曲线的定位点和曲线的实现效果:

这里大家也发现了,贝塞尔最后一个坐标点,绘制到屏幕外面去了?

因为受到贝塞尔曲线的限制,我们没法说在最右端进行结束,只能选择绘制到屏幕外,所以我们在循环中,采用判断当前长度是否超过父layout长度,来判断是否需要再进行绘制(一下一上两个波)

override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        //每次初始化Path
        mPath!!.reset()
        val halfWaveLen = mWaveLength / 2 //半个波长,即一个贝塞尔曲线长度
        mPath!!.moveTo(0, mStartY) //波浪的开始位置
        //每一次for循环添加一个波浪的长度到path中,根据view的宽度来计算一共可以添加多少个波浪长度
        var i = 0f
        while (i <= widthtoFloat()){
            mPath!!.rQuadTo(
                halfWaveLen / 2.toFloat(),
                -200f,
                halfWaveLen.toFloat(),
                0f
            )
            mPath!!.rQuadTo(
                halfWaveLen / 2.toFloat(),
                200f,
                halfWaveLen.toFloat(),
                0f
            )
            i += mWaveLength
        }
        mPath!!.lineTo(width.toFloat(), height.toFloat())
        mPath!!.lineTo(0f, height.toFloat())

        mPath!!.close() //封闭path路径

        canvas!!.drawPath(mPath!!, mPaint!!)
    }

3.波浪动画

一个静态的波浪已经绘制好了,现在来思考,如何让这个波动起来:

波浪向右移动,本质上就是Path的起始坐标点,向右移动,那我们就通过动画,让Path向右移动就好了


//Path移动的坐标点
    var dx = 0f
    	//重写set方法,需要在每次设置新值的时候,刷新界面
        set(value) {
            field = value
            postInvalidate()
        }
    //  
    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
            super.onSizeChanged(w, h, oldw, oldh)
            .......
            //开始动画
			startAnim()
			
        }
        
    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        //..省略重复代码
        //mPath!!.moveTo(0, mStartY)修改
        mPath!!.moveTo(dx, mStartY) //波浪的开始位置
        //..省略重复代码
    }   
    //开始动画
    fun startAnim() {
        //根据一个动画不断得到0~mItemWaveLength的值dx,通过dx的增加不断去改变波浪开始的位置,			dx的变化范围刚好是一个波浪的长度,
        //所以可以形成一个完整的波浪动画,假如dx最大小于mItemWaveLength的话, 就会不会画出一个			完整的波浪形状
        var anim: ObjectAnimator =
            ObjectAnimator.ofFloat(this, "dx", 0f, mWaveLength.toFloat())
        anim.duration = 2000
		anim.repeatCount = ValueAnimator.INFINITE
        anim.start()
    }

这里我们看到波浪已经往后移动起来了,但是波浪的前半部分,也随着波浪的移动,没了,所以我们就需要,在屏幕之外多绘制一个波浪,也就是和我们的动画的移动长度一致,这样,才能实现一个看起来是连续的波浪。也就是说,我们的Path起始点,以及计算浪长度的起始点,都需要向左偏移一个浪的长度(mWaveLength)

override fun onDraw(canvas: Canvas?) {
        //..省略重复代码
        //mPath!!.moveTo(dx, mStartY) 
        mPath!!.moveTo(-mWaveLength + dx, mStartY) //波浪的开始位置
        //每一次for循环添加一个波浪的长度到path中
     	//根据view的宽度来计算一共可以添加多少个波浪长度
        // var i = 0f
        var i = -mWaveLength
		//..省略重复代码
    }

看,这个动画开始连续起来了

随机波浪呢?

随即波浪来了,随机波浪的原理其实也很简单,本来我们的波浪贝塞尔高度定的是200,现在我们只需要把200改为随机数就行了(当然,你直接采用Random来生成随机数,直接放在贝塞尔函数里,肯定是不行的,不信自己试下)

我们需要有一个数据结构,来保存每一组浪(一上一下),在一个浪过去的时候,把这一组浪的数据减去最后一个组浪,同时在头部加上新的一组浪

???这个数据结构,不就是说的链表吗?没错,我们用的就是链表,LinkedList。

上面提到一点,在一个浪过去的时候,对应到动画,也就是一次动画结束的时候,因为我们采用的是Repeat模式的动画,所以,我们只需要在repeat监听中,更新链表即可

	//保存随机浪
    var heightList =  LinkedList<Float>()
    
    override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
       	//..省略重复代码
        //现在是第几个浪
		var waveIndex =0
        while (i <= width.toFloat()) {
            mPath!!.rQuadTo(
                halfWaveLen / 2.toFloat(),
                //使用随机的波浪高度
                -heightList.get(waveIndex),
                halfWaveLen.toFloat(),
                0f
            )
            mPath!!.rQuadTo(
                halfWaveLen / 2.toFloat(),
                //使用随机的波浪高度
                heightList.get(waveIndex),
                halfWaveLen.toFloat(),
                0f
            )
            i += mWaveLength
        }
        //..省略重复代码

    }
    
    fun startAnim() {
        //根据一个动画不断得到0~mItemWaveLength的值dx,通过dx的增加不断去改变波浪开始的位置,dx的变化范围刚好是一个波浪的长度,
        //所以可以形成一个完整的波浪动画,假如dx最大小于mItemWaveLength的话, 就会不会画出一个完整的波浪形状
        var anim: ObjectAnimator =
            ObjectAnimator.ofFloat(BezierView@ this, "dx", 0f, mItemWaveLength.toFloat())
        anim.duration = 1000
        //这里为什么不采用repeat? 可自行采用repeat,会导致动画闪屏
        //需要放在hanlder里,否则start无效
        anim.doOnEnd {
            handler.post {
                heightList.addFirst(Random.nextInt(500).toFloat())
                heightList.removeAt(mWaveNumber + 6)
                anim.start()
            }
        }
        anim.start()
    }
    

这样,一个随机高度的波浪就好了

4.绘制小船(Path、Region的使用)

Region 是干什么用的?

Region其实是用来获取区域的,这个区域的话,是和指定Path相交的区域 假设我要获取中间的浪多高?

我可以通过中间区域与Path相交,然后获取相交区域的height,就行了(只要把Region的左右区域设置的小,四舍五入,就相当于线与图像的相交,通过top,就可以获取高度)

 override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        
		//..省略重复代码
        //这里我默认小船居中
        var x = width.toFloat() / 2
        var region = Region()
        
        var clip = Region((x - 0.1).toInt(), 0, x.toInt(), height)
        
		//获取小船的高度区域
        var rect = region.getBounds()
        
        canvas.drawBitmap(
            boat,
            //将小船的底部,贴着浪
            rect.right.toFloat() - boat.width / 2,
            rect.top.toFloat() - boat.height / 4 * 3,
            mPaint
        )
    }

这样之后,小船就可以随着浪,上下摆动了

4.小船随波浪翻动

小船摆动怎么实现呢? 这里可能有人问了,是不是用PathMeasure直接获取Matrix?

因为实在没办法获取贝塞尔曲线中点的长度,只能再次使用Region,获取小船的旋转角度,如果不会三角函数的可以跳过,小船就不能转了(dog ) 这里我通过两个Region,来获取两个点,来获取角度,来实现小船的旋转 两个红色的区域,就是两个高度点,可以获取三角函数值,再算出角度

override fun onDraw(canvas: Canvas?) {
        //...省略重复代码

        //这里我默认小船居中
        //获取两个Path上的点
        var x = width.toFloat() / 2
        var region = Region()
        var region2 = Region()
        var clip = Region((x - 0.1).toInt(), 0, x.toInt(), height)
        var clip2 = Region((x - 10).toInt(), 0, (x - 9).toInt(), height)
        region.setPath(mPath!!, clip)
        region2.setPath(mPath!!, clip2)

        var rect = region.getBounds()
        var rect2 = region2.getBounds()
		//计算角度值
        val fl =
            -atan2(-rect.top.toFloat() + rect2.top.toFloat(), 9.5f) * 180 / Math.PI.toFloat()

        canvas.save()
		//进行旋转操作
        canvas.rotate(
            fl, rect.right.toFloat(),
            rect.top.toFloat()
        )
        canvas.drawBitmap(
            boat,
            rect.right.toFloat() - boat.width / 2,
            rect.top.toFloat() - boat.height / 4 * 3,
            mPaint
        )
        canvas.restore()

    }

这样之后,我们的动画效果就可以实现了

5.扩展

通过这个项目,希望大家不仅可以学会贝塞尔曲线,也可以搭配上不同的动画效果,实现出更好的动画

git项目地址github.com/ZhuHuaming/…