Android 正弦和贝塞尔曲线简单应用

本文主要就介绍如何使用正余弦或者贝塞尔曲线实现一个类似水波纹加载的动效。如果有看前面的文章,可以知道现在使用贝塞尔曲线相对比较容易,因为Android的Path中就有贝塞尔曲线的API,可以直接使用。但是,正余弦在Path中确实没有什么API供开发者直接使用,那么只能另寻其它方式了。

先上图,俗话说一图顶千言。

正弦曲线

在Path的API中有lineTo(x,y)方法,使用Math的sin()方法,将x与y的关系建立一个正弦映射,然后将多个点使用lineTo()方法连接起来,这样所有的点的关系都是正弦映射,然后再使用Canvas的drawPath()方法,显示出的效果就是一条平滑的正弦曲线了。

在代码实现之前,先熟悉一下正弦函数公式。

正弦曲线可表示为y=Asin(ωx+φ)+k,定义为函数y=Asin(ωx+φ)+k在直角坐标系上的图象,其中sin为正弦符号,x是直角坐标系x轴上的数值,y是在同一直角坐标系上函数对应的y值,k、ω和φ是常数(k、ω、φ∈R且ω≠0)

  • A——振幅,当物体作轨迹符合正弦曲线的直线往复运动时,其值为行程的1/2。
  • ωx+φ——相位,反映变量y所处的状态。
  • φ——初相,x=0时的相位;反映在坐标系上则为图像的左右移动。
  • k——偏距,反映在坐标系上则为图像的上移或下移。
  • ω——角速度, 控制正弦周期(单位弧度内震动的次数)。

如下是一个简单的正弦曲线示例。

正弦函数的振幅A是20,ω是2π/50,这里为什么使用了2π/50呢,因为正弦曲线的周期是2π,这样50个单位长度就恰好是一个周期,偏距k和初相φ都是0。

接下来设计一下上面图片动效的正弦函数,首先一个屏幕内刚好是一个正弦曲线周期,垂直方向上面位于屏幕的中间位置,还可以随着时间平移,代码如下:

public float getMyValue(float x, float offset) {
	return (float) Math.sin(2 * Math.PI * x / width - offset) * 40 + height / 2;
}

上面方法width为屏幕的宽度,height为屏幕的高度,offset是随着时间左右移动的距离。

x与y的映射方法已经设计好了,接下来找一些取样点,取样点越密曲线看上去越是柔顺平滑,但是这样对性能损耗也越大,所以在本示例中选取了128个取样点,然后计算每一个取样点的x的值并放入一个数组中。

private static final int SAMPLE_SIZE = 128;
private float[] samplingX;//采样点

// 计算采样点并存入数组
samplingX = new float[SAMPLE_SIZE];
float gap = width / (float) SAMPLE_SIZE;
for (int i = 0; i < SAMPLE_SIZE; i++) {
	samplingX[i] = i * gap;
}

使用Path的lineTo()方法将所有取样点连接起来。这里暂时不要使用canvas.drawLine()方法,虽然这种方式也能达到相同的视觉效果,但是非常耗性能,建议使用lineTo()方法将所有采样点连接起来后,然后使用canvas.drawPath()方法一次性绘制显示。

for (int i = 0; i < SAMPLE_SIZE; i++) {
	float offset = (System.currentTimeMillis() - startTime) / 300f;
	// 计算y点值
	float y = getMyValue(samplingX[i], offset);
	if (y <= height) {
		path.lineTo(samplingX[i], y);
	}
}

在动态交互图上面,可以看到有两个交相波动的曲线,其实这种方式也很容易实现,只需要将正弦函数y=Asin(ωx+φ)+k中在相同x值时φ值不同就可以了。

public MyPathView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

	paint = new Paint();
	paint.setAntiAlias(true);
	paint.setStyle(Paint.Style.FILL);
	paint.setColor(0x880000AA);

	path = new Path();
	path01 = new Path();
}

public float getMyValue(float x, float offset) {
	return (float) Math.sin(2 * Math.PI * x / width - offset) * 40 + height / 2;
}

@Override
protected void onDraw(Canvas canvas) {
	super.onDraw(canvas);
	Log.d(TAG, "onDraw: "+canvas);
	path.reset();
	path01.reset();
	if (samplingX == null) {
		width = getWidth();
		height = getHeight();
		samplingX = new float[SAMPLE_SIZE];

		float gap = width / (float) SAMPLE_SIZE;
		for (int i = 0; i < SAMPLE_SIZE; i++) {
			samplingX[i] = i * gap;
		}
	}
	// 正弦曲线
	for (int i = 0; i < SAMPLE_SIZE; i++) {
		float offset = (System.currentTimeMillis() - startTime) / 300f;
		float y = getMyValue(samplingX[i], offset);
		if (y <= height) {
			path.lineTo(samplingX[i], y);
		}

		float offset01 = (System.currentTimeMillis() - startTime) / 500f;
		float y01 = getMyValue(samplingX[i], offset01);
		if (y01 <= height) {
			path01.lineTo(samplingX[i], y01);
		}
	}
	path.lineTo(width, height);
	path.lineTo(0, height);
	path.close();
	canvas.drawPath(path, paint);

	path01.lineTo(width, height);
	path01.lineTo(0, height);
	path01.close();
	canvas.drawPath(path01, paint);
	postInvalidate();
}

贝塞尔曲线

前面已经使用过Path的二阶贝塞尔曲线quadTo()方法了,我们知道二阶贝塞尔曲线只能实现上面正弦曲线半个生命周期,那么另外半个周期怎么实现,其实也容易,再使用贝塞尔曲线实现一个,这样将两条贝塞尔曲线连接在一起就形成了一个类似的正弦曲线,由于贝塞尔曲线本身就是条平滑的曲线了,所以不必再像上面正弦曲线那样采集一些取样点。

如下图所示,A到B是一个以P01为控制点的贝塞尔曲线,B到C是以P02为控制点的曲线。

在本示例中使用另外一个二阶贝塞尔曲线方法:

public void rQuadTo(float dx1, float dy1, float dx2, float dy2)

该方法是以最近的点为相对位置取值的,并且取值dx1、dy1、dx2、dy2都是差值,并不是某一个位置点的坐标。

比如在上图中,A(xa,ya)为P01(x01,y01)的相对位置,P01(x01,y01)为B(xb,yb)的相对位置,那么dx1、dy1、dx2、dy2计算如下:

dx1=x01-xa;
dy1=y01-ya;
dx2=xb-x01;
dy2=yb-y01;

假设波纹向右滚动,那么可以让波纹在x轴左侧方向,超出屏幕外也计算在内,这样移动时就看上去是连续的,一旦移动整个屏幕距离,立刻将曲线重置为初始点。

public class MyPathView extends View {
    private static final String TAG = "MyPathView";
    private Paint paint;
    private Path path;

    private int width;
    private int height;
    private float offsetX = 0; // 左右移动距离
    private float offsetY = 0; // 上下移动距离
    private int waveLength = 1080; //这里写死的
    private int waveControl = 60; // 控制点距离x轴的距离

    public MyPathView(Context context) {
        this(context, null);
    }

    public MyPathView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public MyPathView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        paint = new Paint();
        paint.setAntiAlias(true);
        paint.setStyle(Paint.Style.FILL);
        paint.setStrokeWidth(10);
        paint.setColor(Color.RED);
        path = new Path();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        Log.d(TAG, "onDraw: ");
        path.reset();
        width = getWidth();
        height = getHeight();
        path.moveTo(-waveLength + offsetX, height / 2 - offsetY);
        for (int i = -waveLength; i < width + waveLength; i += waveLength) {
            path.rQuadTo(waveLength / 4, -waveControl, waveLength / 2, 0);
            path.rQuadTo(waveLength / 4, waveControl, waveLength / 2, 0);
        }
        path.lineTo(width, height);
        path.lineTo(0, height);
        path.close();
        canvas.drawPath(path, paint);
        handler.sendEmptyMessageDelayed(0, 16);
    }

    private Handler handler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            offsetX += 12;
            offsetY += 1;
            if (offsetX >= waveLength) {
                offsetX = 0;
            }
            invalidate();
        }
    };

}

小结

本文介绍的内容不多,主要就是如何绘制出一个平滑的波形图。为了绘制完整的波形图,可以使用正弦函数,也可以使用贝塞尔函数,一定要记住两者在使用上面的差异性。

考虑到性能问题,到目前为止介绍的所有绘制方式都是在主线程操作的,接下来介绍一下如何在子线程中绘制图像,其实就是SurfaceView中绘制图像,有关更多SurfaceView在下一篇博文再继续介绍。

评论

您确定要删除吗?删除之后不可恢复