自定义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()
    }
}
相关推荐
千里马学框架2 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台2 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone2 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc2 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo2 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077002 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼2 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone2 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen2 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone2 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui