贝塞尔曲线于1962由法国工程师皮埃尔·贝塞尔(Pierre Bézier)所广泛发表,他运用贝塞尔曲线来为汽车的主体进行设计。现在贝塞尔曲线在计算机图形学领域也是一个相当重要的参数曲线,很多画图工具软件都包含贝塞尔曲线的工具对象。Android开发过程中也可以通过它实现很多有趣的特效动画,这里通过简单的代码编写来深入学习贝塞尔曲线的生成。
给定点两个点,一阶贝塞尔曲线只是一条两点之间的直线。这条线由下式给出:
接下来使用编程来实现画出这条线,需要在自定义的View控件中包含开始和结束两个点,使用属性动画来做t的生成对象。
public class BezierView extends View {
private Paint mPaint;
// 开始位置
private Point mStart;
// 结束位置
private Point mEnd;
// t是固定值的时候当前点的位置
private Point mCurrent;
private ValueAnimator mValueAnimator;
// 当前t的值
private float mProgress;
public BezierView(Context context) {
this(context, null);
}
public BezierView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public BezierView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
mPaint = new Paint();
mPaint.setAntiAlias(true);
mPaint.setDither(true);
mPaint.setStrokeWidth(10);
mStart = new Point();
mStart.x = 100;
mStart.y = 100;
mEnd = new Point();
mEnd.x = 600;
mEnd.y = 600;
mCurrent = new Point();
// t从0到1变化耗时3秒
mValueAnimator = ValueAnimator.ofFloat(0, 1f);
mValueAnimator.setDuration(3000);
mValueAnimator.addUpdateListener(animation -> {
mProgress = animation.getAnimatedFraction();
// 每次t发生变化就刷新界面
invalidate();
});
mValueAnimator.setRepeatMode(ValueAnimator.RESTART);
mValueAnimator.setRepeatCount(ValueAnimator.INFINITE);
post(() -> {
mValueAnimator.start();
});
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 根据当前t的值,获取当前生成的点
mCurrent.x = (int) (mStart.x + mProgress * (mEnd.x - mStart.x));
mCurrent.y = (int) (mStart.y + mProgress * (mEnd.y - mStart.y));
mPaint.setColor(Color.RED);
canvas.drawLine(mStart.x, mStart.y, mCurrent.x, mCurrent.y, mPaint);
mPaint.setColor(Color.BLUE);
canvas.drawCircle(mCurrent.x, mCurrent.y, 10, mPaint);
mPaint.setColor(Color.GREEN);
canvas.drawLine(mCurrent.x + 8, mCurrent.y + 8, mEnd.x, mEnd.y, mPaint);
}
}
上面红色的线就是一阶贝塞尔曲线生成的直线,这个效果还是比较简单的,涉及到的点相对比较少。
二阶贝塞尔曲线的路径由给定点P0、P1、P2的函数B(t)追踪:
如果直接使用这个公式去计算结果然后把所有的点连接起来,也是可以生成贝塞尔曲线的,不过这种明显不够直观,这里还是使用前面生成直线的方式生成一条贝塞尔曲线。
public class Bezier2View extends View {
private Paint mPaint;
// 开始点,控制点和结束点
private Point mStart;
private Point mControl;
private Point mEnd;
// 开始点和控制点之间随t变化生成的点
private Point mCurrent1;
// 控制点和结束点之间随t变化的点
private Point mCurrent2;
private Path mPath;
// 记录上一个t的贝塞尔曲线位置
private Point mLastPoint;
private Point mTmpPoint;
// t的动画生成对象
private ValueAnimator mValueAnimator;
// 记录t的值
private float mProgress;
public Bezier2View(Context context) {
this(context, null);
}
public Bezier2View(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public Bezier2View(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
init();
}
private void init() {
mPaint = new Paint();
mPaint.setAntiAlias(true);
mPaint.setDither(true);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(10);
mStart = new Point();
mStart.x = 100;
mStart.y = 600;
mLastPoint = new Point();
mLastPoint.x = mStart.x;
mLastPoint.y = mStart.y;
mControl = new Point();
mControl.x = 350;
mControl.y = 250;
mEnd = new Point();
mEnd.x = 600;
mEnd.y = 600;
mCurrent1 = new Point();
mCurrent2 = new Point();
mTmpPoint = new Point();
mPath = new Path();
mValueAnimator = ValueAnimator.ofFloat(0, 1f);
mValueAnimator.setDuration(3000);
mValueAnimator.addUpdateListener(animation -> {
mProgress = animation.getAnimatedFraction();
// 如果刚开始或者刚结束删除上次画的贝塞尔曲线
if (mProgress <= 0.0001f || mProgress >= 0.9999f) {
mPath.reset();
mLastPoint.x = mStart.x;
mLastPoint.y = mStart.y;
}
invalidate();
});
mValueAnimator.setRepeatMode(ValueAnimator.RESTART);
mValueAnimator.setRepeatCount(ValueAnimator.INFINITE);
post(() -> {
mValueAnimator.start();
});
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 获取开始点和控制点之间的随t变化的点位置
mCurrent1.x = (int) (mStart.x + mProgress * (mControl.x - mStart.x));
mCurrent1.y = (int) (mStart.y + mProgress * (mControl.y - mStart.y));
mPaint.setColor(Color.GRAY);
// 绘制从开始点到mCurrent1点的直线
canvas.drawLine(mStart.x, mStart.y, mCurrent1.x, mCurrent1.y, mPaint);
mPaint.setColor(Color.BLUE);
canvas.drawCircle(mCurrent1.x, mCurrent1.y, 5, mPaint);
mPaint.setColor(Color.GRAY);
canvas.drawLine(mCurrent1.x + 4, mCurrent1.y - 4, mControl.x, mControl.y, mPaint);
// 后去控制点和结束点之间的随t变化的点位置
mCurrent2.x = (int) (mControl.x + mProgress * (mEnd.x - mControl.x));
mCurrent2.y = (int) (mControl.y + mProgress * (mEnd.y - mControl.y));
// 绘制从控制点到结束点的直线
mPaint.setColor(Color.GRAY);
canvas.drawLine(mControl.x, mControl.y, mCurrent2.x, mCurrent2.y, mPaint);
mPaint.setColor(Color.BLUE);
canvas.drawCircle(mCurrent2.x, mCurrent2.y, 5, mPaint);
mPaint.setColor(Color.GRAY);
canvas.drawLine(mCurrent2.x + 8, mCurrent2.y + 8, mEnd.x, mEnd.y, mPaint);
mPaint.setColor(Color.CYAN);
canvas.drawLine(mCurrent1.x + 8, mCurrent1.y + 8, mCurrent2.x - 8, mCurrent2.y - 8, mPaint);
// 计算开始点和控制点一阶位置和控制点与结束点的一阶位置
// 计算这两个点之间的一阶点位置
mTmpPoint.x = (int) (mCurrent1.x + mProgress * (mCurrent2.x - mCurrent1.x));
mTmpPoint.y = (int) (mCurrent1.y + mProgress * (mCurrent2.y - mCurrent1.y));
// 将这个点和上一次记录的点用直线连接起来
mPath.moveTo(mLastPoint.x, mLastPoint.y);
mPath.lineTo(mTmpPoint.x, mTmpPoint.y);
mLastPoint.x = mTmpPoint.x;
mLastPoint.y = mTmpPoint.y;
mPaint.setColor(Color.MAGENTA);
// 绘制这条贝塞尔曲线
canvas.drawPath(mPath, mPaint);
}
}
使用二阶贝塞尔曲线的时候需要的计算量明显比一阶要多了很多,需要的使用多个一阶的组合生成二阶曲线,到三阶曲线绘制操作已经十分的复杂。
幸运的是Android的Path提供了贝塞尔曲线的二阶和三阶函数,使用它们就可以很轻松的绘制贝塞尔曲线,只需要提供开始点、控制点和结束点。
public class CustomBezier2View extends View {
private Point mStart;
private Point mControl;
private Point mEnd;
private Paint mPaint;
private Path mPath;
public CustomBezier2View(Context context) {
this(context, null);
}
public CustomBezier2View(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public CustomBezier2View(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mPaint = new Paint();
mPaint.setAntiAlias(true);
mPaint.setDither(true);
mPaint.setColor(Color.RED);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(10);
mStart = new Point();
mStart.x = 100;
mStart.y = 600;
mControl = new Point();
mControl.x = 350;
mControl.y = 250;
mEnd = new Point();
mEnd.x = 600;
mEnd.y = 600;
mPath = new Path();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
mPaint.setColor(Color.BLUE);
canvas.drawCircle(mStart.x, mStart.y, 5, mPaint);
canvas.drawCircle(mControl.x, mControl.y, 5, mPaint);
canvas.drawCircle(mEnd.x, mEnd.y, 5, mPaint);
mPaint.setColor(Color.RED);
mPath.moveTo(mStart.x, mStart.y);
mPath.quadTo(mControl.x, mControl.y, mEnd.x, mEnd.y);
canvas.drawPath(mPath, mPaint);
}
}
P0、P1、P2、P3四个点在平面或在三维空间中定义了三阶贝塞尔曲线,开始和结束的点会被经过,中间的两点只是做为控制点使用,曲线不会经过它们。
三阶的实现这里就直接使用Android提供的方法来实现,定义开始点,结束点和两个控制点就可以了。
public class CustomBezier3View extends View {
private Point mStart;
private Point mControl1;
private Point mControl2;
private Point mEnd;
private Paint mPaint;
private Path mPath;
public CustomBezier3View(Context context) {
this(context, null);
}
public CustomBezier3View(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public CustomBezier3View(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mPaint = new Paint();
mPaint.setAntiAlias(true);
mPaint.setDither(true);
mPaint.setColor(Color.RED);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(10);
mStart = new Point();
mStart.x = 100;
mStart.y = 600;
mControl1 = new Point();
mControl1.x = 350;
mControl1.y = 250;
mControl2 = new Point();
mControl2.x = 550;
mControl2.y = 230;
mEnd = new Point();
mEnd.x = 800;
mEnd.y = 600;
mPath = new Path();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
mPaint.setColor(Color.BLUE);
canvas.drawCircle(mStart.x, mStart.y, 5, mPaint);
canvas.drawCircle(mControl1.x, mControl1.y, 5, mPaint);
canvas.drawCircle(mControl2.x, mControl2.y, 5, mPaint);
canvas.drawCircle(mEnd.x, mEnd.y, 5, mPaint);
mPaint.setColor(Color.RED);
mPath.moveTo(mStart.x, mStart.y);
mPath.cubicTo(mControl1.x, mControl1.y, mControl2.x, mControl2.y, mEnd.x, mEnd.y);
canvas.drawPath(mPath, mPaint);
}
}
水波纹下过最重要的是先绘制出一条正弦曲线,再使用多条正弦曲线填充满屏幕,再在屏幕的左侧添加和屏幕宽度一样的正弦曲线,最后再不断的修改曲线的相位值就可以实现无限循环的正弦波效果。
public class WaveView extends View {
private int mVisibleWaveCount = 2;
private int mHeight = CommonUtils.dp2px(300);
private int mOffset = 0;
private int mWaveLength;
private int mWaveHeight;
private Paint mPaint;
private Path mPath;
private ValueAnimator mValueAnimator;
public WaveView(Context context) {
this(context, null);
}
public WaveView(Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public WaveView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mPaint = new Paint();
mPaint.setDither(true);
mPaint.setAntiAlias(true);
mPaint.setStrokeWidth(10);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setColor(Color.CYAN);
mPath = new Path();
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mWaveLength = w / mVisibleWaveCount;
mWaveHeight = CommonUtils.dp2px(20);
mValueAnimator = ValueAnimator.ofInt(-w, 0);
mValueAnimator.setDuration(1500);
mValueAnimator.setRepeatMode(ValueAnimator.RESTART);
mValueAnimator.setRepeatCount(ValueAnimator.INFINITE);
mValueAnimator.setInterpolator(new LinearInterpolator());
mValueAnimator.addUpdateListener( animation -> {
// offset从负的屏幕宽度到0,这时左边的正弦波播放完毕,需要从头开始
mOffset = (int) animation.getAnimatedValue();
invalidate();
});
mValueAnimator.start();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int drawWaveCount = 2 * mVisibleWaveCount;
mPath.reset();
// 绘制多个波
for (int i = 0; i < drawWaveCount; i++) {
drawWave(mOffset + i * mWaveLength, mHeight);
}
canvas.drawPath(mPath, mPaint);
}
// 绘制一个包含波谷和波峰的正弦波,底线会合并起来
// x,y是开始为之的坐标
private void drawWave(int x, int y) {
int halfLength = mWaveLength / 2;
int controlHeight = mWaveHeight + CommonUtils.dp2px(10);
mPath.moveTo(x, y);
mPath.rQuadTo(halfLength / 2, -controlHeight, halfLength, 0);
mPath.rQuadTo(halfLength / 2, controlHeight, halfLength, 0);
mPath.rLineTo(0, mHeight);
mPath.rLineTo(-mWaveLength, 0);
mPath.close();
}
}