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/…