自定义View绘制波浪球效果

参考文档:

Android自定义View(10) 《贝塞尔曲线简介》

先上效果图:

本篇文章讲解如何实现图中的第一个波浪球效果,相信学习完之后,第二个效果也能轻松实现。

知识点:

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()
    }
}
相关推荐
我又来搬代码了1 小时前
【Android】使用productFlavors构建多个变体
android
德育处主任3 小时前
Mac和安卓手机互传文件(ADB)
android·macos
芦半山3 小时前
Android“引用们”的底层原理
android·java
迃-幵3 小时前
力扣:225 用队列实现栈
android·javascript·leetcode
大风起兮云飞扬丶4 小时前
Android——从相机/相册获取图片
android
Rverdoser4 小时前
Android Studio 多工程公用module引用
android·ide·android studio
aaajj4 小时前
[Android]从FLAG_SECURE禁止截屏看surface
android
@OuYang4 小时前
android10 蓝牙(二)配对源码解析
android
Liknana4 小时前
Android 网易游戏面经
android·面试
Winston Wood4 小时前
一文了解Android的Doze模式
android