1 前言
大家在腾讯课堂上有没有注意到这个视频加载的动效? 实际上,里面是包含一张 .gif 图片的,有可能就是用 .gif 实现的。
不过,本文将使用 Android 自定义 View 的方式来实现这个动效。
2 正文
2.1 效果分析
效果里面包含:
- 一张腾讯课堂的博士帽 logo 图片;
- 只在博士帽有像素的区域才显示的两条水波纹;
- 让水波纹的高度动态变化。
这里面有难度的是第 2 点了:如何绘制水波纹?如何让水波纹只在博士帽有像素的区域显示?
水波纹可以使用二阶贝塞尔曲线来实现; 让水波纹只在博士帽有像素的区域显示,可以选择合适的混合模式(PorterDuff.Mode )来实现。
2.2 效果实现
2.2.1 绘制博士帽
创建继承于 View 的类,并重写 onMeasure 方法,onSizeChanged 方法和 onDraw 方法:
class TencentClassLoadingView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private lateinit var tencentClassBitmap: Bitmap
private val defaultSize = 150.dp
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
val measuredWidth = if (widthMode == MeasureSpec.EXACTLY) widthSize else defaultSize.toInt()
val measuredHeight = if (heightMode == MeasureSpec.EXACTLY) heightSize else defaultSize.toInt()
val measuredSize = min(measuredWidth, measuredHeight)
setMeasuredDimension(measuredSize, measuredSize)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
tencentClassBitmap = getImage(R.drawable.tencent_class, w)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawBitmap(tencentClassBitmap, 0f, 0f, paint)
}
private fun getImage(drawable: Int, requestSize: Int): Bitmap {
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeResource(resources, drawable, options)
options.inTargetDensity = requestSize
options.inDensity = options.outWidth
options.inJustDecodeBounds = false
return BitmapFactory.decodeResource(resources, drawable, options)
}
}
在主页面的布局中引用这个控件:
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<com.example.tencentclassloadingview.TencentClassLoadingView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
运行一下,效果如下:
对上面的代码进行一下说明: 在 Kotlin 中,自定义控件的构造方法采用默认参数的写法时,必须要加上 @JvmOverloads 注解,否则程序运行会崩溃。这是因为不加 @JvmOverloads 注解,只会有一个三个参数的构造方法,而不会生成两个参数的构造方法,导致布局加载时创建控件失败。
如上面代码所示的带默认参数的构造方法以及 @JvmOverloads 注解,不需要手写,因为 AS 的快捷键可以自动进行补全,方法是: 鼠标的光标放在红色波浪线处,按下 Alt + Enter,会弹出一个菜单: 选择第二项即可。
在 onMeasure 方法中,如果父类给的测量模式是 MeasureSpec.EXACTLY ,那么就采用父类给的建议宽度和高度;否则就采用默认的尺寸(一定要有一个默认的尺寸,这是因为当子 View 的宽高采用 wrap_content 时,不管父容器的模式是精确模式还是最大模式,子 View 的模式总是最大模式+父容器的剩余空间。)。另外,这个控件是一个正方形的控件,所以最终保存的尺寸是测量出的宽度和高度较小的那一个。
在 onSizeChanged 方法中,通过 getImage 方法来加载博士帽图片。getImage 方法的作用是可以获取到指定大小的 Bitmap 对象。
在 onDraw 方法中,绘制 Bitmap 对象,控件的尺寸就是 Bitmap 对象的尺寸,所以直接在左上角的原点绘制就可以了。
2.2.2 绘制水波纹
创建水波纹画布
因为在后面,需要把水波纹和博士帽分别作为混合模式的目标和源,所以我们不能使用 onDraw 方法里的 canvas 对象来绘制水波纹。
那怎么办呢?
我们可以创建一个空白的 Bitmap 对象,再使用这个 Bitmap 对象创建一个新的 Canvas 对象:
class TencentClassLoadingView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
...
private lateinit var waveBitmap: Bitmap
private lateinit var waveCanvas: Canvas
...
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
...
waveBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
waveCanvas = Canvas(waveBitmap)
}
...
}
rQuadTo 方法简介
这里需要用到 Path 的 rQuadTo 方法,做一下简单的说明:
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawColor(Color.parseColor("#44ff0000"))
wavePath.apply {
reset()
moveTo(0f, height / 2f)
rQuadTo(width / 4f, height / 4f, width / 2f, 0f)
rQuadTo(width / 4f, -height / 4f, width / 2f, 0f)
}
paint.style = Paint.Style.STROKE
paint.strokeWidth = 3.dp
canvas.drawPath(wavePath, paint)
}
运行效果如下: 可以看到,使用二阶贝塞尔曲线绘制出的波浪效果。 moveTo(0f, height / 2f) 是把 Path 的起点移动到控件的左边居中的位置。 第一个 rQuadTo(width / 4f, height / 4f, width / 2f, 0f) 是在前一个终点的基础上计算控制点坐标和当前的终点坐标:
- 控制点 x 坐标 = 前一个终点 x 坐标 + 控制点 x 位移 = 0f + width / 4f = width / 4f
- 控制点 y 坐标 = 前一个终点 y 坐标 + 控制点 y 位移 = height / 2f + height / 4f = height * 3 / 4f
- 当前终点 x 坐标 = 前一个终点 x 坐标 + 终点 x 位移 = 0f + width / 2f = width / 2f
- 当前终点 y 坐标 = 前一个终点 y 坐标 + 终点 y 位移 = height / 2f + 0f = height / 2f
需要特别说明的是,rQuardTo 方法的参数相对的都是前一个终点的坐标。rQuadTo 中的 r 是 relative,相对的意思。 第二个 rQuadTo(width / 4f, -height / 4f, width / 2f, 0f) 是在前一个终点(即(width / 2f,height / 2f)点)的基础上计算控制点坐标和当前的终点坐标:
- 控制点 x 坐标 = 前一个终点 x 坐标 + 控制点 x 位移 = width / 2f + width / 4f = width * 3 / 4f
- 控制点 y 坐标 = 前一个终点 y 坐标 + 控制点 y 位移 = height / 2f - height / 4f = height / 4f
- 当前终点 x 坐标 = 前一个终点 x 坐标 + 终点 x 位移 = width / 2f + width / 2f = width
- 当前终点 y 坐标 = 前一个终点 y 坐标 + 终点 y 位移 = height / 2f + 0f = height / 2f
我们可以把这些点以及连线都绘制上去,看看它们和二阶贝塞尔曲线的关系:
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawColor(Color.parseColor("#44ff0000"))
wavePath.apply {
reset()
moveTo(0f, height / 2f)
rQuadTo(width / 4f, height / 4f, width / 2f, 0f)
rQuadTo(width / 4f, -height / 4f, width / 2f, 0f)
}
paint.color = Color.BLACK
paint.style = Paint.Style.STROKE
paint.strokeWidth = 3.dp
canvas.drawPath(wavePath, paint)
val pts1 = floatArrayOf(
0f, height / 2f, width / 4f, height * 3 / 4f,
width / 4f, height * 3 / 4f, width / 2f, height / 2f,
width / 2f, height / 2f, width * 3 / 4f, height / 4f,
width * 3 / 4f, height / 4f, width * 1f, height / 2f,
)
paint.color = Color.BLUE
paint.strokeWidth = 2.dp
canvas.drawLines(pts1, paint)
paint.color = Color.RED
paint.strokeWidth = 5.dp
paint.style = Paint.Style.FILL
val pts2 = floatArrayOf(
0f, height / 2f,
width / 4f, height * 3 / 4f,
width / 2f, height / 2f,
width * 3 / 4f, height / 4f,
width * 1f, height / 2f,
)
canvas.drawPoints(pts2, paint)
}
效果如下:
我们可以知道,水波纹的波长是由 rQuadTo 方法的第三个参数控制的,振幅是由它的第二个参数控制的。
绘制充满控件宽度的水波纹
class TencentClassLoadingView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
...
private var waveLength = 0f
private var amplitude = 0f
private val WAVE_COLOR = Color.parseColor("#E600A2E8")
...
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
...
waveLength = w / 3f
amplitude = h / 20f
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawBitmap(tencentClassBitmap, 0f, 0f, paint)
updateWavePath()
paint.color = WAVE_COLOR
waveCanvas.drawPath(wavePath, paint)
canvas.drawBitmap(waveBitmap, 0f, 0f, paint)
}
private fun updateWavePath() {
wavePath.apply {
reset()
moveTo(0f, height / 2f)
var waveStart = 0f
while (waveStart <= width) {
rQuadTo(waveLength / 4f, amplitude, waveLength / 2f, 0f)
rQuadTo(waveLength / 4f, -amplitude, waveLength / 2f, 0f)
waveStart += waveLength
}
lineTo(width.toFloat(), height.toFloat())
lineTo(0f, height.toFloat())
close()
}
}
...
}
这里取波长为控件宽度的 1/3 ,振幅为控件高度的 1/20。
通过 updateWavePath() 方法,更新波纹路径:路径从控件的左边中点开始,一直绘制二阶贝塞尔曲线,直到控件的右边为止;连线到控件的右下角;再连线到控件的左下角;最后闭合。这其实就是水波纹整体的轮廓线了。
通过 waveCanvas.drawPath(wavePath, paint) ,把水波纹绘制在 waveBitmap 这个对象上。
通过 canvas.drawBitmap(waveBitmap, 0f, 0f, paint) ,把 waveBitmap 对象绘制在控件上。这一行是很关键的,没有的话,在屏幕上是无法看到水波纹区域的。
运行效果如下:
让水波纹在水平方向上动起来
通过改变波形的起始点,使用 ObjectAnimator ,来实现波形移动的效果:
class TencentClassLoadingView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
...
var offsetX: Float = 0f
set(value) {
field = value
invalidate()
}
private lateinit var animator: ObjectAnimator
...
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
...
animator = ObjectAnimator.ofFloat(this, "offsetX", 0f, waveLength).apply {
duration = 500L
interpolator = LinearInterpolator()
repeatCount = ValueAnimator.INFINITE
repeatMode = ValueAnimator.RESTART
}
animator.start()
}
...
private fun updateWavePath() {
wavePath.apply {
reset()
moveTo(offsetX, height / 2f)
var waveStart = offsetX
...
}
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
animator.cancel()
}
...
}
运行效果如下:
效果明显是不对的,说好的,波形移动效果呢?连波形也看不到了。这是咋回事儿呢?
原因是在 onDraw 方法里的:waveCanvas.drawPath(wavePath, paint) ,在不断地向 waveBitmap 上绘制波形区域,最终导致波形被抹平了。
我们应该在向 waveBitmap 上绘制之前,清空一下 waveBitmap 对象的图像,添加代码:
waveCanvas.drawColor(Color.BLACK, PorterDuff.Mode.CLEAR)
waveCanvas.drawPath(wavePath, paint)
运行效果如下:
水波纹移动后不能充满控件的原因是水波纹的起点是从控件左边中点开始的,而偏移量 offsetX 是从 0 到波长变化的,这样就导致波形区域会变小。
为了解决这个问题,我们让水波纹的起点在控件左边再减去一个波长的地方。代码如下:
private fun updateWavePath() {
wavePath.apply {
reset()
val initialStart = offsetX - waveLength
moveTo(initialStart, height / 2f)
var waveStart = initialStart
while (waveStart <= width) {
rQuadTo(waveLength / 4f, amplitude, waveLength / 2f, 0f)
rQuadTo(waveLength / 4f, -amplitude, waveLength / 2f, 0f)
waveStart += waveLength
}
lineTo(width.toFloat(), height.toFloat())
lineTo(0f, height.toFloat())
close()
}
}
运行效果如下:
可以看到,ok 了。
添加浅蓝的水波纹
浅蓝水波纹的振幅是控件高度的 1/25。 浅蓝水波纹是从右向左运动的,这只需要让起点偏移从大到小变化即可。
class TencentClassLoadingView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
...
private var amplitude = 0f
private var amplitudeLight = 0f
private val WAVE_COLOR = Color.parseColor("#E600A2E8")
private val WAVE_COLOR_LIGHT = Color.parseColor("#9900A2E8")
var offsetX: Float = 0f
set(value) {
field = value
invalidate()
}
var offsetXLight: Float = 0f
set(value) {
field = value
invalidate()
}
...
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
...
amplitudeLight = h / 25f
val animator = ObjectAnimator.ofFloat(this, "offsetX", 0f, waveLength).apply {
duration = 500L
interpolator = LinearInterpolator()
repeatCount = ValueAnimator.INFINITE
repeatMode = ValueAnimator.RESTART
}
val animatorLight = ObjectAnimator.ofFloat(this, "offsetXLight", waveLength, 0f).apply {
duration = 300L
interpolator = LinearInterpolator()
repeatCount = ValueAnimator.INFINITE
repeatMode = ValueAnimator.RESTART
}
animatorSet.playTogether(animator, animatorLight)
animatorSet.start()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawBitmap(tencentClassBitmap, 0f, 0f, paint)
waveCanvas.drawColor(Color.BLACK, PorterDuff.Mode.CLEAR)
paint.color = WAVE_COLOR_LIGHT
updateWavePath(offsetXLight, amplitudeLight)
waveCanvas.drawPath(wavePath, paint)
paint.color = WAVE_COLOR
updateWavePath(offsetX, amplitude)
waveCanvas.drawPath(wavePath, paint)
canvas.drawBitmap(waveBitmap, 0f, 0f, paint)
}
private fun updateWavePath(offsetX: Float, amplitude: Float) {
wavePath.apply {
reset()
val initialStart = offsetX - waveLength
moveTo(initialStart, height / 2f)
var waveStart = initialStart
while (waveStart <= width) {
rQuadTo(waveLength / 4f, amplitude, waveLength / 2f, 0f)
rQuadTo(waveLength / 4f, -amplitude, waveLength / 2f, 0f)
waveStart += waveLength
}
lineTo(width.toFloat(), height.toFloat())
lineTo(0f, height.toFloat())
close()
}
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
animatorSet.cancel()
}
...
}
效果如下:
让水波纹在竖直方向上动起来
class TencentClassLoadingView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
...
var waveHeight: Float = 0f
set(value) {
field = value
invalidate()
}
...
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
...
val animatorHeight = ObjectAnimator.ofFloat(this, "waveHeight", h.toFloat() + max(amplitude, amplitudeLight), -max(amplitude, amplitudeLight)).apply {
duration = 2000L
startDelay = 200L
interpolator = AccelerateInterpolator()
repeatCount = ValueAnimator.INFINITE
repeatMode = ValueAnimator.RESTART
}
animatorSet.playTogether(animator, animatorLight, animatorHeight)
animatorSet.start()
}
...
private fun updateWavePath(offsetX: Float, amplitude: Float) {
wavePath.apply {
reset()
val initialStart = offsetX - waveLength
moveTo(initialStart, waveHeight)
...
}
}
...
}
2.2.2 在博士帽有像素的区域才显示的两条水波纹
在使用混合模式之前,让我们把 onDraw 方法里的代码优化一下:
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawBitmap(tencentClassBitmap, 0f, 0f, paint)
waveCanvas.drawColor(Color.BLACK, PorterDuff.Mode.CLEAR)
paint.color = WAVE_COLOR_LIGHT
updateWavePath(offsetXLight, amplitudeLight)
waveCanvas.drawPath(wavePath, paint)
paint.color = WAVE_COLOR
updateWavePath(offsetX, amplitude)
waveCanvas.drawPath(wavePath, paint)
canvas.drawBitmap(waveBitmap, 0f, 0f, paint)
}
把水波纹绘制到 waveBitmap 上的代码在 onDraw 方法里面,它们的含义目前看来不是非常明显,我们把它们抽取到一个方法里面吧:
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawBitmap(tencentClassBitmap, 0f, 0f, paint)
drawWaveOnWaveBitmap()
canvas.drawBitmap(waveBitmap, 0f, 0f, paint)
}
private fun drawWaveOnWaveBitmap() {
waveCanvas.drawColor(Color.BLACK, PorterDuff.Mode.CLEAR)
paint.color = WAVE_COLOR_LIGHT
updateWavePath(offsetXLight, amplitudeLight)
waveCanvas.drawPath(wavePath, paint)
paint.color = WAVE_COLOR
updateWavePath(offsetX, amplitude)
waveCanvas.drawPath(wavePath, paint)
}
现在看着好多了。
应用混合模式:
class TencentClassLoadingView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
...
private val xfermode = PorterDuffXfermode(PorterDuff.Mode.DST_IN)
...
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawBitmap(tencentClassBitmap, 0f, 0f, paint)
drawWaveOnWaveBitmap()
val saveCount = canvas.saveLayer(0f, 0f, width.toFloat(), height.toFloat(), paint)
canvas.drawBitmap(waveBitmap, 0f, 0f, paint)
paint.xfermode = xfermode
canvas.drawBitmap(tencentClassBitmap, 0f, 0f, paint)
paint.xfermode = null
canvas.restoreToCount(saveCount)
}
...
}
这里选择了 PorterDuff.Mode.DST_IN 这个混合模式,把 waveBitmap 作为目标图像绘制在底部,把博士帽作为源图像绘制在顶部,在相交区域利用博士帽图像的透明度来改变水波纹图像的透明度,这样在博士帽完全透明的区域就不会显示水波纹图像了。
另外,上面这段代码,可以说是模板代码了,可以适当记忆一下。这里面需要替换的就是谁作为目标图像,谁作为源图像,以及采用何种混合模式。
关于混合模式的选取,笔者了解地比较少,希望同学们可以在开发时查资料明确不同混合模式的区别,选择使用。可以参考笔者的这个例子:
运行效果如下:
3 最后
通过本文,利用 Android 的自定义绘制知识:Path、空白 Bitmap 创建、贝塞尔曲线、混合模式,实现了腾讯课堂加载动画效果。
请大家一定要读一下参考中大佬的文章。与大佬的文章相比,本文可以说不值得一读。
完整代码见 github。
参考
|