Android实战——腾讯课堂加载动画效果实现

1 前言

大家在腾讯课堂上有没有注意到这个视频加载的动效?
在这里插入图片描述
实际上,里面是包含一张 .gif 图片的,有可能就是用 .gif 实现的。

不过,本文将使用 Android 自定义 View 的方式来实现这个动效。

2 正文

2.1 效果分析

效果里面包含:

  1. 一张腾讯课堂的博士帽 logo 图片;
  2. 只在博士帽有像素的区域才显示的两条水波纹;
  3. 让水波纹的高度动态变化。

这里面有难度的是第 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 方法简介

这里需要用到 PathrQuadTo 方法,做一下简单的说明:

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    //  canvas.drawBitmap(tencentClassBitmap, 0f, 0f, paint)
    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 把水波纹绘制在 waveBitmap 上而已,和屏幕没有任何关系。
        waveCanvas.drawPath(wavePath, paint)
        // 调用这行才会把 waveBitmap 绘制到屏幕上
        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,因为此处可以拿到 waveLength 的值。
        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()
            // 使用 offsetX 作为波形的起点。
            moveTo(offsetX, height / 2f)
            var waveStart = offsetX
            ...
        }
    }

    override fun onDetachedFromWindow() {
        super.onDetachedFromWindow()
        animator.cancel()
    }
    ...
}

运行效果如下:

效果明显是不对的,说好的,波形移动效果呢?连波形也看不到了。这是咋回事儿呢?

原因是在 onDraw 方法里的:waveCanvas.drawPath(wavePath, paint),在不断地向 waveBitmap 上绘制波形区域,最终导致波形被抹平了。

我们应该在向 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)
    // 这部分代码的作用是把水波纹绘制到 waveBitmap 上
    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

参考

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

willwaywang6

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值