1. 了解Bezier曲线
如何表示一条曲线,能够精确地控制曲线的路径,一直以来是一个很困难的问题。Bezier曲线就是利用数学公式,能够精确描述一条我们想要的曲线。主要是由起始点、终止点和控制点三个部分组成,其中控制点是控制曲线的关键。
接下来看一下具体的实现:
-
一阶Bezier曲线
一阶Bezier曲线,是两个点的连线,是一条直线。
-
二阶Bezier曲线
其中的p0和p2分别是起始点和终止点,p1即是控制点。在三个点形成的两条线段上,选取各自的起始位置然后向各自的终点位置移动,并且将两个点连接成一条辅助线,在这条辅助线上同样有一个点从起始位置移动到重点位置,这个点与p0点的连线就是一条二阶Bezier曲线。
-
三阶Bezier曲线
可以看出与二阶Bezier曲线类似,只不过是控制点变成了2个,形成的三条线段构成了两条辅助线,在这两条辅助线上又构造了一条辅助线,并且其运动的点与p0的连线构成一条三阶Bezier曲线。
在Android中,提供了二阶和三阶的实现api,对于其他多阶Bezier曲线通过二阶和三阶的拼接也能达到同样地效果。
2. Bezier曲线Demo
接下来通过两个Demo来简单认识一下Bezier曲线的实现。
2.1 二阶Bezier曲线
首先创建SecondBezierActivity及其布局,然后创建一个自定义的SecondBezierView:
public class SecondBezierActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_second_bezier);
}
}
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.blue.animationart.SecondBezierActivity">
<com.blue.animationart.view.SecondBezierView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</android.support.constraint.ConstraintLayout>
接下来,我们着重看一下SecondBezierView.java这个自定义view的实现。
先创建一系列坐标点:
private float mStartPointX;
private float mStartPointY;
private float mEndPointX;
private float mEndPointY;
private float mFlagPointX;
private float mFlagPointY;
在onSizeChanged方法里对这些坐标点进行赋值:
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
// 起始点横坐标是屏幕宽度的四分之一处
mStartPointX = w / 4;
// 起始点纵坐标是屏幕高度的一半再减200
mStartPointY = h / 2 - 200;
mEndPointX = w * 3 / 4;
mEndPointY = h / 2 - 200;
mFlagPointX = w / 2;
mFlagPointY = h - 400;
}
在onDraw方法里对曲线进行绘制,在绘制之前需要创建一个Path路径描述类的实例和对应的Paint画笔实例:
private Path mPath;
// 曲线的画笔
private Paint mPaintBezier;
// 控制点的画笔
private Paint mPaintFlag;
... ...
public SecondBezierView(Context context, AttributeSet attrs) {
super(context, attrs);
// 消除锯齿
mPaintBezier = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaintBezier.setStrokeWidth(8);
mPaintBezier.setStyle(Paint.Style.STROKE);
mPaintFlag = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaintFlag.setStrokeWidth(3);
mPaintFlag.setStyle(Paint.Style.STROKE);
}
... ...
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
... ...
mPath = new Path();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 让Path恢复,养成良好的习惯
mPath.reset();
// 将Path移动到初始位置点
mPath.moveTo(mStartPointX, mStartPointY);
// quadTo即二阶Bezier曲线的Android API方法,前两个参数是控制点坐标,后两个参数是终止点坐标
mPath.quadTo(mFlagPointX, mFlagPointY, mEndPointX, mEndPointY);
// 下面是绘制一些辅助的点和线段
canvas.drawPoint(mStartPointX, mStartPointY, mPaintFlag);
canvas.drawPoint(mEndPointX, mEndPointY, mPaintFlag);
canvas.drawPoint(mFlagPointX, mFlagPointY, mPaintFlag);
canvas.drawLine(mStartPointX, mStartPointY, mFlagPointX, mFlagPointY, mPaintFlag);
canvas.drawLine(mEndPointX, mEndPointY, mFlagPointX, mFlagPointY, mPaintFlag);
// 绘制曲线
canvas.drawPath(mPath, mPaintBezier);
}
接下来对其进行拓展,在手机屏幕上触摸会改变控制点的坐标,以达到动态改变二阶Bezier曲线的目的。实现起来很简单,只需要实现onTouchEvent,获取MotionEvent.ACTION_MOVE,把当前的坐标点赋值给控制点,最后刷新即可:
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()) {
case MotionEvent.ACTION_MOVE:
mFlagPointX = event.getX();
mFlagPointY = event.getY();
invalidate();
break;
}
return true;
}
2.2 三阶Bezier曲线
与二阶Bezier曲线的原理类似,还是先创建所承载的activity和自定义的view:
public class ThridBezierActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_thrid_bezier);
}
}
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.blue.animationart.ThridBezierActivity">
<com.blue.animationart.view.ThridBezierView
android:layout_width="match_parent"
android:layout_height="match_parent" />
</android.support.constraint.ConstraintLayout>
接下来也是着重看一下自定义的view实现。
定义变量,初始化画笔实例:
private float mStartPointX;
private float mStartPointY;
private float mEndPointX;
private float mEndPointY;
private float mFlagPointOneX;
private float mFlagPointOneY;
private float mFlagPointTwoX;
private float mFlagPointTwoY;
private Path mPath;
private Paint mPaintBezier;
private Paint mPaintFlag;
public ThridBezierView(Context context, AttributeSet attrs) {
super(context, attrs);
mPaintBezier = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaintBezier.setStrokeWidth(8);
mPaintBezier.setStyle(Paint.Style.STROKE);
mPaintFlag = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaintFlag.setStrokeWidth(3);
mPaintFlag.setStyle(Paint.Style.STROKE);
}
在onSizeChanged方法中对坐标点进行赋值:
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mStartPointX = w / 4 - 100;
mStartPointY = h / 2 - 200;
mEndPointX = w * 3 / 4 + 100;
mEndPointY = h / 2 - 200;
mFlagPointOneX = w / 2 - 200;
mFlagPointOneY = h / 2 + 200;
mFlagPointTwoX = w / 2 + 200;
mFlagPointTwoY = h / 2 + 100;
mPath = new Path();
}
在onDraw方法对曲线和坐标点等进行绘制:
mPath.reset();
mPath.moveTo(mStartPointX, mStartPointY);
// 三阶Bezier曲线的API是cubicTo,同样地前四个参数对应两个控制点的坐标,后两个参数对应终止点坐标
mPath.cubicTo(mFlagPointOneX, mFlagPointOneY, mFlagPointTwoX, mFlagPointTwoY, mEndPointX, mEndPointY);
canvas.drawPoint(mStartPointX, mStartPointY, mPaintFlag);
canvas.drawPoint(mEndPointX, mEndPointY, mPaintFlag);
canvas.drawPoint(mFlagPointOneX, mFlagPointOneY, mPaintFlag);
canvas.drawLine(mStartPointX, mStartPointY, mFlagPointOneX, mFlagPointOneY, mPaintFlag);
canvas.drawLine(mEndPointX, mEndPointY, mFlagPointTwoX, mFlagPointTwoY, mPaintFlag);
canvas.drawLine(mFlagPointOneX, mFlagPointOneY, mFlagPointTwoX, mFlagPointTwoY, mPaintFlag);
canvas.drawPath(mPath, mPaintBezier);
- 总结
- Android只提供二阶和三阶的实现,多阶可以通过拼接方式来实现;
- 在自定义view中,先赋值坐标点,然后初始化Paint对象设置样式,最后在onDraw方法中进行绘制;
- 二阶对应方法quadTo方法,三阶对应cubicTo方法。
3. Bezier曲线实践
3.1 路径变换
之前的文章提到过,VectorDrawable在L版本以下是不支持路径变换动画的,我们可以通过Bezier曲线来实现L版本以下的路径变换。
分析
能够看出,是利用三阶Bezier曲线所实现,通过属性动画控制两个控制点的坐标向下运动,从而带动曲线跟着运动,进而实现了路径的变换动画。实现
创建对应的activity和自定义的view,与前面的demo的方式相同,在此不多做介绍。
下面来实现这个自定义view。
首先还是创建坐标点和Paint画笔的变量:
private float mStartPointX;
private float mStartPointY;
private float mEndPointX;
private float mEndPointY;
private float mFlagPointOneX;
private float mFlagPointOneY;
private float mFlagPointTwoX;
private float mFlagPointTwoY;
private Path mPath;
private Paint mPaintBezier;
private Paint mPaintFlag;
然后设置画笔的样式:
public PathMorphingView(Context context, AttributeSet attrs) {
super(context, attrs);
mPaintBezier = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaintBezier.setStrokeWidth(8);
mPaintBezier.setStyle(Paint.Style.STROKE);
mPaintFlag = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaintFlag.setStrokeWidth(3);
mPaintFlag.setStyle(Paint.Style.STROKE);
}
接着在onSizeChanged给坐标点赋值,此处我们给控制点坐标初始赋值为起始点和终止点坐标,让曲线初始状态是直线:
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mStartPointX = w / 4;
mStartPointY = h / 2 - 200;
mEndPointX = w * 3 / 4;
mEndPointY = h / 2 - 200;
mFlagPointOneX = mStartPointX;
mFlagPointOneY = mStartPointY;
mFlagPointTwoX = mEndPointX;
mFlagPointTwoY = mEndPointY;
mPath = new Path();
}
在onDraw方法中对曲线进行绘制:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
mPath.reset();
mPath.moveTo(mStartPointX, mStartPointY);
mPath.cubicTo(mFlagPointOneX, mFlagPointOneY, mFlagPointTwoX, mFlagPointTwoY, mEndPointX, mEndPointY);
canvas.drawPoint(mStartPointX, mStartPointY, mPaintFlag);
canvas.drawPoint(mEndPointX, mEndPointY, mPaintFlag);
canvas.drawPoint(mFlagPointOneX, mFlagPointOneY, mPaintFlag);
canvas.drawLine(mStartPointX, mStartPointY, mFlagPointOneX, mFlagPointOneY, mPaintFlag);
canvas.drawLine(mEndPointX, mEndPointY, mFlagPointTwoX, mFlagPointTwoY, mPaintFlag);
canvas.drawLine(mFlagPointOneX, mFlagPointOneY, mFlagPointTwoX, mFlagPointTwoY, mPaintFlag);
canvas.drawPath(mPath, mPaintBezier);
}
接下来就是重要的属性动画的实现:
private ValueAnimator mValueAnimator;
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
... ...
// 属性动画数值增长的范围
mValueAnimator = ValueAnimator.ofFloat(mStartPointY, h);
// 弹性效果的插值器
mValueAnimator.setInterpolator(new BounceInterpolator());
mValueAnimator.setDuration(1000);
// 监听属性动画所改变的值,并且将值设置给两个控制点
mValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
mFlagPointOneY = (float) valueAnimator.getAnimatedValue();
mFlagPointTwoY = (float) valueAnimator.getAnimatedValue();
invalidate();
}
});
// 设置点击事件
setOnClickListener(this);
}
在点击事件中,播放动画:
@Override
public void onClick(View view) {
mValueAnimator.start();
}
- 总结
- Bezier曲线的路径变化动画可以解决VectorDrawable不能向下兼容的问题;
- Bezier曲线的路径变化的实现原理就是通过属性动画来动态改变坐标点的值,进而带动改变曲线的路径。
3.2 波浪运动
波浪动画在手机清理软件和加载动画上运用很多,运用Bezier曲线的实现效果如下图:
- 分析
要实现动态地波浪效果,首先要知道怎么绘制一个静态的波浪曲线。
(desmos是一个绘制数学公式的工具。)
能够看到,两个二阶Bezier曲线就能够组成一个完整的波形曲线。将这个组装而成的波形通过循环波长个数进行绘制,就能达到一连串的波形曲线。而曲线的起始点、终止点和控制点的横坐标由属性动画操作向右移动,达到曲线移动的效果。但是要注意的是,需要在屏幕之外添加一个完整的波形,这样在整个波形移动的时候不会发生中断。
- 实现
下面来实现这个自定义view。
首先初始化相应的变量,并且确定需要绘制的波长个数:
private Path mPath;
private Paint mPaintBezier;
private int mWaveCount;
private int mWaveLength;
private int mScreenHeight, mScreenWidth;
// 波形绘制的纵坐标
private int mCenterY;
public WaveBezierView(Context context, AttributeSet attrs) {
super(context, attrs);
mPaintBezier = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaintBezier.setColor(Color.BLUE);
mPaintBezier.setStrokeWidth(8);
mPaintBezier.setStyle(Paint.Style.FILL_AND_STROKE);
// 定义波长的长度
mWaveLength = 800;
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mPath = new Path();
setOnClickListener(this);
mScreenHeight = h;
mScreenWidth = w;
mCenterY = h / 2;
// 屏幕的宽度除以波长长度是屏幕所容纳的个数,1.5是屏幕之外的波长个数
mWaveCount = (int) Math.round(mScreenWidth / mWaveLength + 1.5);
}
接下来是绘制波形:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
mPath.reset();
// 从屏幕外开始,所以需要初始位置在屏幕之外一个波长的距离
mPath.moveTo(-mWaveLength, mCenterY);
// 将之前计算而得的波长个数进行循环绘制
for (int i = 0; i < mWaveCount; i++) {
// 控制点对应着波峰和波谷,然后依次向后一个波长位置继续绘制
mPath.quadTo(-mWaveLength * 3 / 4 + i * mWaveLength, mCenterY + 60, -mWaveLength / 2 + i * mWaveLength, mCenterY);
mPath.quadTo(-mWaveLength / 4 + i * mWaveLength, mCenterY - 60, i * mWaveLength, mCenterY);
}
// 图形与屏幕下边缘封闭,然后对封闭图形填充颜色。
mPath.lineTo(mScreenWidth, mScreenHeight);
mPath.lineTo(0, mScreenHeight);
mPath.close();
canvas.drawPath(mPath, mPaintBezier);
}
接下来对坐标点的横坐标进行偏移:
private ValueAnimator mValueAnimator;
// 偏移量
private int moffset;
... ...
@Override
public void onClick(View view) {
// 设置范围
mValueAnimator = ValueAnimator.ofInt(0, mWaveLength);
mValueAnimator.setDuration(1000);
mValueAnimator.setRepeatCount(ValueAnimator.INFINITE);
// 线性插值器
mValueAnimator.setInterpolator(new LinearInterpolator());
mValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
// 给偏移量进行赋值
moffset = (int) valueAnimator.getAnimatedValue();
invalidate();
}
});
mValueAnimator.start();
}
将偏移量moffset作用到整个波形曲线上,只需要修改quadTo方法即可:
@Override
protected void onDraw(Canvas canvas) {
... ...
mPath.moveTo(-mWaveLength + moffset, mCenterY);
for (int i = 0; i < mWaveCount; i++) {
mPath.quadTo(-mWaveLength * 3 / 4 + i * mWaveLength + moffset, mCenterY + 60, -mWaveLength / 2 + i * mWaveLength + moffset, mCenterY);
mPath.quadTo(-mWaveLength / 4 + i * mWaveLength + moffset, mCenterY - 60, i * mWaveLength + moffset, mCenterY);
}
... ...
}
- 总结
- 运用Bezier曲线实现路径变化动画是不需要考虑兼容性问题的。
- 横坐标偏移的动画要考虑到屏幕之外也需要绘制。
- 绘制路径变化动画的一般流程:画出静态的整个图像,通过属性动画改变坐标点的坐标。
3.3 模拟物体运动轨迹
模拟添加物品到购物车的运动轨迹动画。
- 分析
此动画分为两个部分,一个是Bezier曲线的绘制,一个是曲线上的点移动。在Android API中没有提供获取Bezier曲线上点的坐标值,需要通过一系列计算公式,计算出其坐标值:
通过使用估值器TypeEvaluator来计算动画运动的值,根据这个值来让点移动,从而达到Bezier曲线所规划的运动轨迹。
- 实现
首先实现计算Bezier曲线坐标点的工具类:
public class BezierUtil {
/**
* B(t) = (1 - t)^2 * P0 + 2t * (1 - t) * P1 + t^2 * P2, t ∈ [0,1]
*
* @param t 曲线长度比例
* @param p0 起始点
* @param p1 控制点
* @param p2 终止点
* @return t对应的点
*/
public static PointF CalculateBezierPointForQuadratic(float t, PointF p0, PointF p1, PointF p2) {
PointF point = new PointF();
float temp = 1 - t;
point.x = temp * temp * p0.x + 2 * t * temp * p1.x + t * t * p2.x;
point.y = temp * temp * p0.y + 2 * t * temp * p1.y + t * t * p2.y;
return point;
}
/**
* B(t) = P0 * (1-t)^3 + 3 * P1 * t * (1-t)^2 + 3 * P2 * t^2 * (1-t) + P3 * t^3, t ∈ [0,1]
*
* @param t 曲线长度比例
* @param p0 起始点
* @param p1 控制点1
* @param p2 控制点2
* @param p3 终止点
* @return t对应的点
*/
public static PointF CalculateBezierPointForCubic(float t, PointF p0, PointF p1, PointF p2, PointF p3) {
PointF point = new PointF();
float temp = 1 - t;
point.x = p0.x * temp * temp * temp + 3 * p1.x * t * temp * temp + 3 * p2.x * t * t * temp + p3.x * t * t * t;
point.y = p0.y * temp * temp * temp + 3 * p1.y * t * temp * temp + 3 * p2.y * t * t * temp + p3.y * t * t * t;
return point;
}
}
初始化相应变量,绘制Bezier曲线,绘制曲线上的移动点:
private int mStartPointX, mStartPointY, mEndPointX, mEndPointY;
private int mFlagPointX, mFlagPointY;
private int mMovePaintX, mMovePaintY;
private Path mPath;
private Paint mPaintPath;
private Paint mPaintCircle;
public PathBezierView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
mPath = new Path();
mPaintPath = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaintPath.setStyle(Paint.Style.STROKE);
mPaintPath.setStrokeWidth(8);
mPaintCircle = new Paint(Paint.ANTI_ALIAS_FLAG);
mStartPointX = 100;
mStartPointY = 100;
mEndPointX = 600;
mEndPointY = 600;
mFlagPointX = 500;
mFlagPointY = 0;
mMovePaintX = mStartPointX;
mMovePaintY = mStartPointY;
setOnClickListener(this);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawCircle(mStartPointX, mStartPointY, 20, mPaintCircle);
canvas.drawCircle(mEndPointX, mEndPointY, 20, mPaintCircle);
canvas.drawCircle(mMovePaintX, mMovePaintY, 20, mPaintCircle);
mPath.reset();
mPath.moveTo(mStartPointX, mStartPointY);
mPath.quadTo(mFlagPointX, mFlagPointY, mEndPointX, mEndPointY);
canvas.drawPath(mPath, mPaintPath);
}
创建估值器:
public class BezierEvaluator implements TypeEvaluator<PointF> {
private PointF mFlagPoint;
public BezierEvaluator(PointF flagPoint) {
// 传入控制点的坐标
mFlagPoint = flagPoint;
}
@Override
public PointF evaluate(float v, PointF pointF, PointF t1) {
// 参数v代表动画运行的比例,其正好对应CalculateBezierPointForQuadratic曲线长度比例的参数
// return的值就是当前运动的坐标点对象
return BezierUtil.CalculateBezierPointForQuadratic(v, pointF, mFlagPoint, t1);
}
}
创建属性动画:
@Override
public void onClick(View view) {
BezierEvaluator evaluator = new BezierEvaluator(new PointF(mFlagPointX, mFlagPointY));
ValueAnimator animator = ValueAnimator.ofObject(evaluator, new PointF(mStartPointX, mStartPointY), new PointF(mEndPointX, mEndPointY));
animator.setDuration(600);
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator valueAnimator) {
// 获取我们定义的BezierEvaluator 所计算的值
PointF pointF = (PointF) valueAnimator.getAnimatedValue();
mMovePaintX = (int) pointF.x;
mMovePaintY = (int) pointF.y;
invalidate();
}
});
// 加速减速插值器
animator.setInterpolator(new AccelerateDecelerateInterpolator());
animator.start();
}
- 总结
模拟运动轨迹动画的一般步骤:构建Bezier曲线,自定义估值器,在属性动画上获取曲线上运动的每个点坐标,将这些坐标设置给运动的点。