参考文档:
先上效果图:
本篇文章讲解如何实现图中的第一个波浪球效果,相信学习完之后,第二个效果也能轻松实现。
知识点:
1.Canvas.clipPath
2.二阶贝塞尔曲线Canvas.quadTo
3.实现动态效果
一.图形分解
思路大概如下,后面伴随代码一步步进行实现。
二.绘制灰色圆形
控件的圆形需要根据控件宽高自适应,在控件中间绘制一个贴合控件的圆形。
控件的宽高可以在View的onSizeChanged回调中获取。根据控件宽高,计算圆形的半径。并初始化圆形路径circlePath。在onDraw方法中绘制圆形路径,这里圆形绘制的画笔circlePaint的类型为全填充。
kotlin
class WaveView : View {
private var width = 0
private var height = 0
private var radius = 0
private val circlePath: Path = Path()
private val circlePaint by lazy {
Paint().apply {
// 开启抗锯齿
isAntiAlias = true
// 设置颜色
color = Color.GRAY
// 设置填充类型
style = Paint.Style.FILL
}
}
constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : this(
context,
attrs,
defStyleAttr, 0
)
constructor(
context: Context,
attrs: AttributeSet?,
defStyleAttr: Int,
defStyleRes: Int
) : super(context, attrs, defStyleAttr, defStyleRes)
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
// 设置宽高
width = w
height = h
// 计算圆形半径
radius = if (w > h) {
h / 2
} else {
w / 2
}
// 添加圆形路径
circlePath.reset()
circlePath.addCircle(
(w / 2).toFloat(),
(h / 2).toFloat(),
radius.toFloat(),
Path.Direction.CCW
)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (width == 0 || height == 0) {
return
}
// 绘制圆形
canvas.drawPath(circlePath, circlePaint)
}
}
代码运行效果图如下:
View的雏形也算出来了😁
二.绘制波浪图案
波浪线可以有多种实现方式,一种可以使用绘制正弦曲线的方式绘制:计算波浪线起始点到结束点中间若干个符合正弦函数的点,用直线连接,模拟曲线效果;第二种就是使用二阶贝塞尔曲线。
我这里使用二阶贝塞尔曲线。
二阶贝塞尔曲线,只要确定三个点,就可以绘制一个与三个点行程的两条线相切的曲线,对于一组波浪线,我们可以通过两个贝塞尔曲线来实现。
通过下面,前三个点确定一个贝塞尔曲线,后面三个点确定一个贝塞尔曲线。
绘制二阶贝塞尔曲线路径的方式如下
arduino
// 以path的最后一个点为第一个点,以(x1,y1)为控制点,(x2,y2)为结束点绘制贝塞尔曲线
canvas.quadTo(float x1, float y1, float x2, float y2)
同时,这里提一个canvas非常好用的api:
Canvas.clipPath(Path path)
使用path对canvas画布进行剪切。这样做的效果就是只有path内部的范围图案才会显示出来,外面的会被裁减掉。
这个方法正好适合我们绘制波浪的场景啊。我们用圆形的path对画布进行剪切,后续绘制的图案也只有圆形范围内才会显示。我们用填充的方式绘制一个顶部为波浪的框形状,最后展现出来的效果便是,只有圆形区域可以显示波浪,外围会被切割掉。
scss
class WaveView : View {
...
private var wavePath: Path = Path()
private val wavePaint by lazy {
Paint().apply {
// 开启抗锯齿
isAntiAlias = true
// 设置颜色
color = Color.parseColor("#449FFF")
// 设置填充类型
style = Paint.Style.FILL_AND_STROKE
}
}
...
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
...
// 设置波浪路径
resetWavePath()
}
private fun resetWavePath() {
wavePath.reset()
wavePath.apply {
// 波浪高度设置为半径的1/2
val waveH = radius / 2
// 路径移动到点(0, height/2)
moveTo(0F, (height / 2).toFloat())
// 二阶贝塞尔曲线
quadTo(
(radius / 2).toFloat(), (height / 2 - waveH).toFloat(),
radius.toFloat(), (height / 2).toFloat()
)
// 二阶贝塞尔曲线
quadTo(
(radius / 2).toFloat() * 3, (height / 2 + waveH).toFloat(),
width.toFloat(), (height / 2).toFloat()
)
// 将线闭合成一个区域
lineTo(width.toFloat(), height.toFloat())
lineTo(0F, height.toFloat())
// 将结尾点和起始点连线闭合
close()
}
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (width == 0 || height == 0) {
return
}
// 绘制圆形
canvas.drawPath(circlePath, circlePaint)
// 将画布裁切,只有圆形circlePath内部的区域绘制的内容才会被显示
canvas.clipPath(circlePath)
// 绘制波浪
canvas.drawPath(wavePath, wavePaint)
}
}
效果出来了:
三.波浪动起来
1.两组波浪
如果让波浪动起来呢,我们的方案是绘制两组贝塞尔曲线,让贝塞尔曲线所在的图形重复从0平移到尾部。
我这里绘制的一组贝塞尔曲线(两条为一组)刚好等于圆形的直径。圆形在波浪图形的头部和尾部效果刚好是一样的。
修改波浪区域路径代码,绘制两组波浪:
scss
private fun resetWavePath() {
wavePath.reset()
wavePath.apply {
// 波浪高度设置为半径的1/2
val waveH = radius / 2
moveTo(0F, (height / 2).toFloat())
// 二阶贝塞尔曲线
quadTo(
(radius / 2).toFloat(), (height / 2 - waveH).toFloat(),
radius.toFloat(), (height / 2).toFloat()
)
// 二阶贝塞尔曲线
quadTo(
(radius / 2).toFloat() * 3, (height / 2 + waveH).toFloat(),
width.toFloat(), (height / 2).toFloat()
)
// 二阶贝塞尔曲线
quadTo(
width + (radius / 2).toFloat(), (height / 2 - waveH).toFloat(),
width + radius.toFloat(), (height / 2).toFloat()
)
// 二阶贝塞尔曲线
quadTo(
width + (radius / 2).toFloat() * 3, (height / 2 + waveH).toFloat(),
2 * width.toFloat(), (height / 2).toFloat()
)
// 将线闭合成一个区域
lineTo(width.toFloat() * 2, height.toFloat())
lineTo(0F, height.toFloat())
close()
}
}
对于波浪的移动,我这里使用对canvas画布左移,达到波浪线区域左移的效果。
kotlin
// 画布左移的距离
private var dx = 0F
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (width == 0 || height == 0) {
return
}
// 绘制圆形
canvas.drawPath(circlePath, circlePaint)
// 将画布裁切,只有圆形circlePath内部的区域绘制的内容才会被显示
canvas.clipPath(circlePath)
canvas.save()
// 左移画布
canvas.translate(-dx, 0F)
// 绘制波浪
canvas.drawPath(wavePath, wavePaint)
canvas.restore()
}
如上代码,对画布左移dx距离,然后绘制波浪图案。
对画布有变换操作前,最好先调用 canvas.save() 保存画布的状态,变换画布之后的绘制结束后,再调用canvas.restore()进行恢复画布操作。由于这里后续没有其他操作,不调用这两组方法也是可以的。
2.变换值控制
现在就剩下最后一步,按照一定时间一定速度改变dx的大小,调用View的invalidate方法进行重新绘制操作。View调用invalidate后,其onDraw方法将会被重新调用。
这里修改的方式有很多,比如使用Handler,不断发送消息去修改dx值;或者使用线程池起一个子线程不断循环修改dx值,或者使用ValueAnimator。不管使用哪种方法,都要注意在View销毁时,对这些工具对象的释放操作,以免造成内存泄漏。释放操作可以放在View的onDetachedFromWindow生命周期方法中进行。
kotlin
class WaveView : View {
...
private val valueAnimator by lazy {
ValueAnimator.ofFloat(0F, width.toFloat())
.setDuration(1500)
.apply {
interpolator = LinearInterpolator()
repeatMode = ValueAnimator.RESTART
repeatCount = ValueAnimator.INFINITE
}
}
...
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
...
valueAnimator.removeAllUpdateListeners()
valueAnimator.addUpdateListener {
if (!isAttachedToWindow) {
return@addUpdateListener
}
dx = it.animatedValue as Float
invalidate()
}
valueAnimator.start()
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
valueAnimator.cancel()
}
}
至此,编码完成。
下面展示一下有黄色和蓝色两个波浪的完整代码,全部代码不超过180行便实现了这个图形绘制。
再复杂的图形,只要可以分解成基础结构,绘制完成只是时间问题✌️。
scss
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Path
import android.util.AttributeSet
import android.util.Log
import android.view.View
import android.view.animation.LinearInterpolator
class WaveView : View {
private var width = 0
private var height = 0
private var radius = 0
private var dx = 0F
private var dx2 = 0F
private val circlePath: Path = Path()
private val circlePaint by lazy {
Paint().apply {
// 开启抗锯齿
isAntiAlias = true
// 设置颜色
color = Color.GRAY
// 设置填充类型
style = Paint.Style.FILL
}
}
private var wavePath: Path = Path()
private val wavePaint by lazy {
Paint().apply {
// 开启抗锯齿
isAntiAlias = true
// 设置颜色
color = Color.parseColor("#449FFF")
// 设置填充类型
style = Paint.Style.FILL_AND_STROKE
}
}
private val valueAnimator by lazy {
ValueAnimator.ofFloat(0F, width.toFloat())
.setDuration(1500)
.apply {
interpolator = LinearInterpolator()
repeatMode = ValueAnimator.RESTART
repeatCount = ValueAnimator.INFINITE
}
}
constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : this(
context,
attrs,
defStyleAttr, 0
)
constructor(
context: Context,
attrs: AttributeSet?,
defStyleAttr: Int,
defStyleRes: Int
) : super(context, attrs, defStyleAttr, defStyleRes)
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
// 设置宽高
width = w
height = h
// 计算圆形半径
radius = if (w > h) {
h / 2
} else {
w / 2
}
// 添加圆形路径
circlePath.reset()
circlePath.addCircle(
(w / 2).toFloat(),
(h / 2).toFloat(),
radius.toFloat(),
Path.Direction.CCW
)
// 设置波浪路径
resetWavePath()
valueAnimator.removeAllUpdateListeners()
valueAnimator.addUpdateListener {
if (!isAttachedToWindow) {
return@addUpdateListener
}
dx = it.animatedValue as Float
// 两个波浪的速度不同
dx2 = (dx * 2) % width
invalidate()
}
valueAnimator.start()
}
private fun resetWavePath() {
wavePath.reset()
wavePath.apply {
// 波浪高度设置为半径的1/2
val waveH = radius / 2
moveTo(0F, (height / 2).toFloat())
// 二阶贝塞尔曲线
quadTo(
(radius / 2).toFloat(), (height / 2 - waveH).toFloat(),
radius.toFloat(), (height / 2).toFloat()
)
// 二阶贝塞尔曲线
quadTo(
(radius / 2).toFloat() * 3, (height / 2 + waveH).toFloat(),
width.toFloat(), (height / 2).toFloat()
)
// 二阶贝塞尔曲线
quadTo(
width + (radius / 2).toFloat(), (height / 2 - waveH).toFloat(),
width + radius.toFloat(), (height / 2).toFloat()
)
// 二阶贝塞尔曲线
quadTo(
width + (radius / 2).toFloat() * 3, (height / 2 + waveH).toFloat(),
2 * width.toFloat(), (height / 2).toFloat()
)
// 将线闭合成一个区域
lineTo(width.toFloat() * 2, height.toFloat())
lineTo(0F, height.toFloat())
close()
}
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (width == 0 || height == 0) {
return
}
// 绘制圆形
canvas.drawPath(circlePath, circlePaint)
// 将画布裁切,只有圆形circlePath内部的区域绘制的内容才会被显示
canvas.clipPath(circlePath)
// 绘制第一条波浪操作
canvas.save()
// 左移画布
canvas.translate(-dx, 0F)
wavePaint.color = Color.YELLOW
// 绘制波浪
canvas.drawPath(wavePath, wavePaint)
canvas.restore()
// 绘制第二条波浪操作
canvas.save()
wavePaint.color = Color.parseColor("#449FFF")
canvas.translate(-dx2, 0F)
// 绘制波浪
canvas.drawPath(wavePath, wavePaint)
canvas.restore()
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
valueAnimator.cancel()
}
}