收到美工的需求,想要在主页背景中加入水的元素。

水其实不算一个很好表达的事物,因为静态的水比较难呈现,所以我们打算使用波纹来表示流动的水。

本着不自己造轮子(实际上是自己对 Canvas 的理解还不够透彻)以及敏捷开发的原则,上『GitHub』薅一个吧。

『GitHub』上这类型的开源控件并不少,一般都叫 WaveView,名字很贴切,对比了几个,还是决定选一个比较简单的 —— 由国内开发者 @唯夜(Github ID: @onlynight)开发的 WaveView 控件。

先来看看他提供的 Demo 效果:

Demo 效果

首先在 Project 的「build.gradle」中添加远程仓库:

allprojects {
    repositories {
        ...
        maven { url 'https://jitpack.io' }
    }
}

然后在 Module 的「build.gradle」中添加依赖:

dependencies {
    ...
    implementation 'com.github.onlynight:WaveView:(insert latest version)'
}

这样就将 WaveView 引入到项目中了,接下来在布局文件中添加此控件:

<com.github.onlynight.waveview.WaveView
    android:id="@+id/wave_view"
    android:layout_width="match_parent"
    android:layout_height="240dp"
    app:isCircle="false"
    app:period="1"
    app:waveHeightPercent="0.9"
    app:waveRange="15dp"
    app:waveSpeed="4"
    app:wave1Color="@color/waveBackColor"
    app:wave2Color="@color/waveFrontColor"
    app:layout_constraintBottom_toBottomOf="parent" />

说一下其专有属性:

<declare-styleable name="WaveView">

    <!-- define wave speed, example value 10 -->
    <!-- 定义波速,类型为 Float -->
    <attr name="waveSpeed" format="float" />

    <!-- define wave range, example value 15dp -->
    <!-- 定义波动范围,类型为 Dimension -->
    <attr name="waveRange" format="dimension|reference" />

    <!-- define wave 1 color -->
    <!-- 定义背景波纹颜色,类型是 Color -->
    <attr name="wave1Color" format="color|reference" />

    <!-- define wave 2 color -->
    <!-- 定义前景波纹颜色,类型是 Color -->
    <attr name="wave2Color" format="color|reference" />

    <!-- define wave height percent, the value is between 0 to 1 -->
    <!-- 定义波高百分比,类型是 Float,取值范围为 0~1 -->
    <attr name="waveHeightPercent" format="float" />

    <!-- define paint stroke width, if you want optimizing view, you should change the stroke width more -->
    <!-- 定义绘画笔划宽度,如果要优化视图,则应更改画笔宽度,类型为 Dimension -->
    <attr name="waveStrokeWidth" format="dimension|reference" />

    <!-- if the view is circle -->
    <!-- 视图是否为圆形 -->
    <attr name="isCircle" format="boolean" />

    <!-- the sine wave period, value range 0 to all -->
    <!-- 正弦波周期,类型是 Float,取值范围为 0~∞ -->
    <attr name="period" format="float" />

</declare-styleable>

可以看到我上方的代码只选取了其中一部分属性进行设置。

最后我们还需在 Activity 中启动动画:

WaveView waveView = (WaveView) findViewById(R.id.wave_view);
// When you want start wave you should call this method.
waveView.start();
// When you want stop wave you should call this method.
waveView.stop();

尽管很多时候我们并没有必要停止波纹动画但它还是提供了相应的方法。

最后来看看我做的效果图:

效果图

不难看出控件的绘制并不这么平滑,我猜想应该使用的是矩形绘制,于是截了个图放大一看,果然如此。

矩形组合

其实数学几何和素描几何中也经常使用此方法进行绘制。

比如微积分中,使用容易计算的矩形面积来代替不规则图形的面积,而当矩形的宽足够小并趋近于零的时候,矩形代替的面积也就越趋近于不规则图形的面积了。

微积分中的应用

在立体几何素描中我们都不会使用圆规作圆,而是通过切圆法,即先画出一个矩形,再通过取半径中点切圆的方法逐渐把圆切出来,当切的次数越多,该多边形也就越接近于圆。

切圆法的应用

可以从 WaveView 的源码中看一下其实现的原理。

我们视觉上看到的是水波纹,实际上只是一个正弦波和余弦波向左位移,然后将三角函数的周期加长,在一个 View 中不显示整个三角函数的波形,这样从视觉上来说就实现了水波纹效果。

根据上面的分析,我们知道需要计算一个正弦波和一个余弦波,并且根据时间的推移将正弦波或者余弦波向左或者向右平移,最后每次计算完波形图的时候绘制下来就完成了。

来看下 WaveView 中的关键代码:

private void drawWave(Canvas canvas, int width, int height) {
    setPaint();
    double lineX = 0;
    double lineY1 = 0;
    double lineY2 = 0;
    for (int i = 0; i < width; i += mStrokeWidth) {
        lineX = i;
        if (mIsRunning) {
            lineY1 = mWaveRange * Math.sin((mAngle + i) * Math.PI / 180 / mPeriod) + height * (1 - mWaveHeightPercent);
            lineY2 = mWaveRange * Math.cos((mAngle + i) * Math.PI / 180 / mPeriod) + height * (1 - mWaveHeightPercent);
        } else {
            lineY1 = 0;
            lineY2 = 0;
        }
        canvas.drawLine((int) lineX, (int) lineY1, (int) lineX + 1, height, mWavePaint1);
        canvas.drawLine((int) lineX, (int) lineY2, (int) lineX + 1, height, mWavePaint2);
    }
}

可以看到,这里没有选择 Path 进行绘制,因为 @唯夜 认为 Path 绘制无法满足需求,所以通过画竖线,计算每个点起始的位置,然后从这个点画一条线到 View 的底部,然后循环多次直到 View 的边界处结束绘制,这样就看到正弦波了。这时候在每次绘制过程中给三角函数添加一个偏移量,每次计算的时候波形就会偏移,就完成了波纹。

这也就解释上方的矩形填充理论了,可以通过修改 waveStrokeWidth 属性来使其更平滑,我使用时发现大概使用 1dp 左右比较合适,同时也发现一个问题,当修改后虽然曲线更加平滑,但会出现少许卡顿的现象,这个平衡还是需要开发者自己来找。

顺便再说一个作者开发时遇到的一个坑,从上方的 Demo 图知道其可设置为圆形;常规的思路是画完以后再将其切成一个圆形,作者尝试了各种方法证明这种思路有问题。

最后发现需要先限定 Canvas 的绘制区域,然后再将图形绘制到 View 上去,这样才能实现效果。

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    if (mIsRunning) {
        int height = getHeight();
        int width = getWidth();
        // 注意这里的执行顺序
        clipContainer(canvas, width, height);
        drawWave(canvas, width, height);
    }
}

private void clipContainer(Canvas canvas, int width, int height) {
    if (mIsCircle) {
        mContainerPath.reset();
        canvas.clipPath(mContainerPath);
        mContainerPath.addCircle(width / 2, height / 2, width / 2, Path.Direction.CCW);
        canvas.clipPath(mContainerPath, Region.Op.REPLACE);
    }
}

其实看完源码之后发现实现该功能也不是特别复杂,但前提还是得搞懂自定义 View 的基本原理。

该控件的项目主页可 👉戳这里👈 跳转。