自定义动画(仿Win10加载动画)
一、源代码
二、背景
先看看Win10的加载动画(找了很久才找到):
每次打开电脑都会有这个加载动画,看上挺cool的,就想着自己能否实现它。
要实现这个动画?
首先想能否通过自定义SurfaceView控件(界面刷新是通过子线程来完成)来实现。这需要知道某一刻时间,那些小圆点在什么位置。小圆点都在做圆周运动,可以看出除了左上角,可以通过势能和动能的相互转化来计算速度。但速度是变化的,如何计算某一个时刻的位置?网上一查,晕,都是微积分,算了吧。
后来,还是使用动画吧,最后的效果:
动起来:
![这里写图片描述](http://www.2cto.com/uploadfile/Collfiles/20161008/20161008091104137.gif)
要玩动画,自然就得理解动画的核心部分。如果理解了,就坐电梯直达“动画分析”。
三、动画的两个核心
一般复杂动画需要自定义TimeInterpolator和TypeEvaluator,前者控制运动的速度,后者控制运动的轨迹。
TypeEvaluator不仅控制运动轨迹,只要有权限且能体现效果的setter属性,都可以控制,如旋转、缩放、颜色等;TimeInterpolator能干的事情,它也能干,只是为了方便,把它抽出来了。
这是他俩的关系(详见参考5):
3.1 TimeInterpolator
TimeInterpolator,名为时间插值器(或时间校正器),用于校正动画播放的时间。默认是加速减速插值器AccelerateDecelerateInterpolator。
正常情况下,动画在执行时间duration内,从起点到终点,中间是匀速运动,每一时刻都对应着固定的位置。为了统一,把时间[0, duration]转换成时间百分比[0, 1],如duration=100ms时,在50ms,应该对应着时间比0.5,且位置在正中间。如下图红色线:
说明:
横轴是实际运行的时间百分比轴(X轴),纵轴是校正后的时间百分比轴(Y轴); 图中紫色线是经过校正后的时间百分比,在x=0.5时,正常情况是y=0.35,但校正后y不到0.3,也就是说这个时刻所在的位置还不到总路线的1/3。说白了,导数就是速度,紫色线的导数在逐渐变大,表示速度也在逐渐变快,这就是一个加速过程。 X轴是实际运行的时间轴,与Y轴,只有一对一、或一对多的关系,不能出现多对一的情况。一对多的关系就是来回运动的具体体现。
再看TimeInterpolator,是个接口,只有一个方法getInterpolation(float input),方法中的参数对应着X轴,返回值对应着Y轴。
1
2
3
|
<code> public interface TimeInterpolator { float getInterpolation( float input); }</code> |
3.2 TypeEvaluator
TypeEvaluator就是一个估值器。拿代码说来,明白一点。
1
2
3
|
<code> public interface TypeEvaluator<t> { public T evaluate( float fraction, T startValue, T endValue); }</t></code> |
也是一个接口,有一个方法
evaluate(float fraction, T startValue, T endValue),参数说明:
fraction:时间插值器的校正值 startValue:开始值 endValue:结束值 T:可以是float类的单值,也可以是坐标、颜色等
fraction:时间插值器的校正值 startValue:开始值 endValue:结束值 T:可以是float类的单值,也可以是坐标、颜色等
一般与AnimatorUpdateListener的onAnimationUpdate()方法结合。
3.3 贝塞尔曲线
以下用到了很多二阶贝塞尔曲线,具体计算公式如下(详见参考2):
B(t)=(1?t)2P0+2t(1?t)P1+t2P2,t∈[0,1]
原理:由 P0 至 P1 的连续点 Q0,描述一条线段。
由 P1 至 P2 的连续点 Q1,描述一条线段。
由 Q0 至 Q1 的连续点 B(t),描述一条二次贝塞尔曲线。
经验:P1-P0为曲线在P0处的切线
另外加两条经验:
为了更自然,P1-P2一般情况下也是曲线在P2处的切线,这样就能算出P1的具体位置 曲线上每个点的坐标x和y,x和y分别套用此公式
为了更自然,P1-P2一般情况下也是曲线在P2处的切线,这样就能算出P1的具体位置 曲线上每个点的坐标x和y,x和y分别套用此公式
3.4 效果比较
无图无真相。。。图来了
在起点、终点、运行时间都一样的情况下,三种效果比较:
普通动画——默认插值器是加速减速插值器AccelerateDecelerateInterpolator 自定义插值器动画——速度效果是先匀速,再做贝塞尔曲线运动(先反向减速后,再正向加速)。如下图红线(其他画图软件都没不好办,这时还是PS的钢笔工具好用)
自定义估值器动画——插值器是默认的(水平方向与普通动画是一致的),做正弦曲线运动
普通动画——默认插值器是加速减速插值器AccelerateDecelerateInterpolator 自定义插值器动画——速度效果是先匀速,再做贝塞尔曲线运动(先反向减速后,再正向加速)。如下图红线(其他画图软件都没不好办,这时还是PS的钢笔工具好用)
自定义估值器动画——插值器是默认的(水平方向与普通动画是一致的),做正弦曲线运动
三种动画的代码:
1
2
3
4
5
6
7
8
9
10
11
|
<code><code> private static final long ANIM_DURATION = 5000 ; /** * 普通动画 * 差值器默认为AccelerateDecelerateInterpolator */ private void normalAnim() { ObjectAnimator oa = ObjectAnimator.ofFloat(v_normal, "translationX" , 0 , 300 ); oa.setDuration(ANIM_DURATION); oa.setRepeatCount(ValueAnimator.INFINITE); oa.start(); }</code></code> |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
<code><code> /** * 自定义插值器的动画 */ private void interpolatorAnim() { ObjectAnimator oa = ObjectAnimator.ofFloat(v_interpolator, "translationX" , 0 , 300 ); oa.setInterpolator( new TimeInterpolator() { /** * 获取插值器的值 * @param input 原生时间比值[0, 1] * @return 校正后的值 */ @Override public float getInterpolation( float input) { // 前半段时间为直线(匀速运动),后半段贝塞尔曲线(先反向) if (input < 0.5 ) { return input; } // 把贝塞尔曲线范围[0.5, 1]转换成[0, 1]范围 input = (input - 0 .5f) * ( 1 - 0 ) / ( 1 - 0 .5f); float tmp = 1 - input; return tmp * tmp * 0 .5f + 2 * input * tmp * 0 + input * input * 1 ; } }); oa.setDuration(ANIM_DURATION); oa.setRepeatCount(ValueAnimator.INFINITE); oa.start(); }</code></code> |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
<code><code> /** * 自定义估值器的动画 */ private void evaluatorAnim() { ValueAnimator va = ValueAnimator.ofObject( new TypeEvaluator<pointf>() { /** * 估算结果 * * @param fraction 由插值器提供的值,∈[0, 1] * @param startValue 开始值 * @param endValue 结束值 * @return */ @Override public PointF evaluate( float fraction, PointF startValue, PointF endValue) { PointF p = new PointF(); float distance = endValue.x - startValue.x; p.x = fraction * distance; float halfDistance = distance / 2 ; float sinX = ( float ) Math.sin(fraction * Math.PI / 0.5 ); p.y = -halfDistance * sinX; return p; } }, new PointF( 0 , 0 ), new PointF( 300 , 0 ) ); va.addUpdateListener( new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { PointF pointF = (PointF) animation.getAnimatedValue(); v_evaluator.setTranslationX(pointF.x); v_evaluator.setTranslationY(pointF.y); } }); va.setDuration(ANIM_DURATION); va.setRepeatCount(ValueAnimator.INFINITE); va.start(); } </pointf></code></code> |
再看看原生动画:
4.1 组成与始末
毫无疑问,是由5个圆点组成。
从哪开始的呢?一般都是从无到有,即从最底部一个一个弹出开始的。经过查看Win10的动画,每次显示时确实从此处开始的。
结束,从开始的前一刻,也就是其它点都消失了,最后一个点到达底部的时刻。
4.2 动画剖析
从上面的动画核心部分可知,要分析动画,就要分析其速度变化与运行时间。
先从第一个圆点开始分析。底部起始点为0°,顺时针为正,分析每个区间段的速度与时间:
0° ~ 160°:速度变慢,时间0.5s 160° ~ 180°:匀速,时间2s 180° ~ 360°:速度变快,时间1s 360° ~ 520°:速度变慢,时间0.5s 520° ~ 540°:匀速,时间2s 540° ~ 720°:速度变快,时间1s
其中:步骤4-6重复1-3。
第二个以后的圆点,一开始以为在使用延时执行就可以,但发现有问题:动画需无限次重复执行,延时只在第一次执行时延时。
所以,用时间来模拟延时执行的效果:首先隐藏在起始点,速度为0,然后延时时间(偏移时间offsetMs)到达后就显示,开始运动了。
最后第一个圆点等待最后一个圆点到达底部的功能也是一样:到达后隐藏在终点,速度不变且为0,一直等待最后一个圆点到达终点。
这样,才算一个完整的运动轨迹(这里范围为实际运行时间):
步骤 | 时间段 | 时间差 | 运动范围 | 速度变化 | 备注 |
---|---|---|---|---|---|
1 |
0 ~ offsetMs |
offsetMs |
0° ~ 0° |
0 |
隐藏 |
2 |
offsetMs ~ offsetMs + 0.5 |
0.5 |
0° ~ 160° |
减速 |
显示 |
3 |
offsetMs + 0.5 ~ offsetMs + 2.5 |
2 |
160° ~ 180° |
匀速 |
|
4 |
offsetMs + 2.5 ~ offsetMs + 3.5 |
1 |
180° ~ 360° |
加速 |
|
5 |
offsetMs + 3.5 ~ offsetMs + 4.0 |
0.5 |
360° ~ 520° |
减速 |
|
6 |
offsetMs + 4.0 ~ offsetMs + 6.0 |
2 |
520° ~ 540° |
匀速 |
|
7 |
offsetMs + 6.0 ~ offsetMs + 7.0 |
1 |
540° ~ 720° |
加速 |
|
8 |
offsetMs + 7.0 ~ 8.0 |
1-offsetMs |
720° ~ 720° |
0 |
隐藏 |
其中,步骤5-7运动与步骤2-3重复,也就是实际看到运动效果的几个步骤。
之后又经过了如下优化:
经过多次卡表发现,一次运行的总时间为7s,但比例还是按上面的来 测试中发现会出现挤压重叠现象,仔细查看原生动画,发现在匀速开始和结束的位置,并不都是160°和180°,五个圆点到达匀速的角度在逐渐变小,离开的角度也在逐渐变小。因此需要给每个圆点一个偏移角度 为了进一步模仿动画在左上角慢慢靠近的追逐效果,上面所述的到达偏移角度要比离开的偏移角度大
这样,就有了下面轨迹参数的确定(这些参数是我测试出来比较理想的,可以自己去设置更精确的参数):
/**
* 创建动画
*
* @param view 需执行的控件
* @param index 该控件执行的顺序
* @return 该控件的动画
*/
private Animator createViewAnim(final View view, final int index) {
long duration = 7000; // 一个周期(2圈)一共运行7000ms,固定值
int comeStepAngle = 22; // 到达的间隔角度
int goStepAngle = 16; // 离开的间隔角度
// 最小执行单位时间
final float minRunUnit = duration / 16;
// 最小执行单位时间所占总时间的比例
double minRunPer = minRunUnit / duration;
// 在差值器中实际值(Y坐标值),共8组
final double[] trueRunInOne = new double[]{
0,
0,
160 / 720d - index * comeStepAngle / 720d,
180 / 720d - index * goStepAngle / 720d,
360 / 720d,
520 / 720d - index * comeStepAngle / 720d,
540 / 720d - index * goStepAngle / 720d,
1
};
// 动画开始的时间比偏移量。剩下的时间均摊到每个圆点上(本应该是length-1,但length效果更好)
final float offset = (float) (index * (16 - 14) * minRunPer / mDotViews.length);
// 在差值器中理论值(X坐标值),与realRunInOne对应
final double[] rawRunInOne = new double[]{
0,
offset + 0,
offset + 1 * minRunPer,
offset + 5 * minRunPer,
offset + 7 * minRunPer,
offset + 8 * minRunPer,
offset + 12 * minRunPer,
offset + 14 * minRunPer
};
}
整个动画放在了一个自定义RelativeLayout里,圆点是通过代码动态添加的,圆点背景使用的是shape:
// 2、 添加新控件
for (int i = 0; i < mDotViews.length; i++) {
mDotViews[i] = new View(getContext());
View view = mDotViews[i];
LayoutParams lp = new LayoutParams(dotD, dotD);
// 添加规则:底部 + 水平居中
lp.addRule(ALIGN_PARENT_BOTTOM);
lp.addRule(CENTER_HORIZONTAL);
// 调整位置
if (mHeight > mWidth) {
lp.bottomMargin = (mHeight - mWidth)/2;
}
// 设置旋转中心点
view.setPivotX(dotR);
view.setPivotY(-(halfSize - dotD));
// 背景
view.setBackgroundResource(R.drawable.shape_dot);
// 修改点的背景颜色
GradientDrawable gradientDrawable = (GradientDrawable) view.getBackground();
gradientDrawable.setColor(dotColor);
view.setVisibility(INVISIBLE);
addView(view, lp);
}
五个圆点的时间百分比轨迹图如下(这时PS画图也不好使了,还是要Android自己来,demo里有源代码):
五、核心代码
private Animator createViewAnim(final View view, final int index) {
...
// 各贝塞尔曲线控制点的Y坐标
final float p1_2 = calculateLineY(rawRunInOne[2], trueRunInOne[2], rawRunInOne[3], trueRunInOne[3], rawRunInOne[1]);
final float p1_4 = calculateLineY(rawRunInOne[2], trueRunInOne[2], rawRunInOne[3], trueRunInOne[3], rawRunInOne[4]);
final float p1_5 = calculateLineY(rawRunInOne[5], trueRunInOne[5], rawRunInOne[6], trueRunInOne[6], rawRunInOne[4]);
final float p1_7 = calculateLineY(rawRunInOne[5], trueRunInOne[5], rawRunInOne[6], trueRunInOne[6], rawRunInOne[7]);
// A 创建属性动画:绕着中心点旋转2圈
ObjectAnimator objAnim = ObjectAnimator.ofFloat(view, "rotation", 0, 720);
// B 设置一个周期执行的时间
objAnim.setDuration(duration);
// C 设置重复执行的次数:无限次重复执行下去
objAnim.setRepeatCount(ValueAnimator.INFINITE);
// D 设置差值器
objAnim.setInterpolator(new TimeInterpolator() {
@Override
public float getInterpolation(float input) {
if (input < rawRunInOne[1]) {
// 1 等待开始
return 0;
} else if (input < rawRunInOne[2]) {
if (view.getVisibility() != VISIBLE) {
view.setVisibility(VISIBLE);
}
// 2 底部 → 左上角:贝赛尔曲线1
// 先转换成[0, 1]范围
input = calculateNewPercent(rawRunInOne[1], rawRunInOne[2], 0, 1, input);
return calculateBezierQuadratic(trueRunInOne[1], p1_2, trueRunInOne[2], input);
} else if (input < rawRunInOne[3]) {
// 3 左上角 → 顶部:直线
return calculateLineY(rawRunInOne[2], trueRunInOne[2], rawRunInOne[3], trueRunInOne[3], input);
} else if (input < rawRunInOne[4]) {
// 4 顶部 → 底部:贝赛尔曲线2
input = calculateNewPercent(rawRunInOne[3], rawRunInOne[4], 0, 1, input);
return calculateBezierQuadratic(trueRunInOne[3], p1_4, trueRunInOne[4], input);
} else if (input < rawRunInOne[5]) {
// 5 底部 → 左上角:贝赛尔曲线3
input = calculateNewPercent(rawRunInOne[4], rawRunInOne[5], 0, 1, input);
return calculateBezierQuadratic(trueRunInOne[4], p1_5, trueRunInOne[5], input);
} else if (input < rawRunInOne[6]) {
// 6 左上角 → 顶部:直线
return calculateLineY(rawRunInOne[5], trueRunInOne[5], rawRunInOne[6], trueRunInOne[6], input);
} else if (input < rawRunInOne[7]) {
// 7 顶部 → 底部:贝赛尔曲线4
input = calculateNewPercent(rawRunInOne[6], rawRunInOne[7], 0, 1, input);
return calculateBezierQuadratic(trueRunInOne[6], p1_7, trueRunInOne[7], input);
} else {
// 8 消失
if (view.getVisibility() != INVISIBLE) {
view.setVisibility(INVISIBLE);
}
return 1;
}
}
});
return objAnim;
}
/**
* 根据旧范围,给定旧值,计算在新范围中的值
*
* @param oldStart 旧范围的开始值
* @param oldEnd 旧范围的结束值
* @param newStart 新范围的开始值
* @param newEnd 新范围的结束之
* @param value 给定旧值
* @return 新范围的值
*/
private float calculateNewPercent(double oldStart, double oldEnd, double newStart, double newEnd, double value) {
if ((value < oldStart && value < oldEnd) || (value > oldStart && value > oldEnd)) {
throw new IllegalArgumentException(String.format("参数输入错误,value必须在[%f, %f]范围中", oldStart, oldEnd));
}
return (float) ((value - oldStart) * (newEnd - newStart) / (oldEnd - oldStart));
}
/**
* 根据两点坐标形成的直线,计算给定X坐标在直线上对应的Y坐标值
*
* @param x1 起点X坐标
* @param y1 起点Y坐标
* @param x2 终点X坐标
* @param y2 终点Y坐标
* @param x 给定的X坐标
* @return 给定X坐标对应的Y坐标
*/
private float calculateLineY(double x1, double y1, double x2, double y2, double x) {
if (x1 == x2) {
return (float) y1;
}
return (float) ((x - x1) * (y2 - y1) / (x2 - x1) + y1);
}
/**
* 计算贝塞尔二阶曲线的X(或Y)坐标值
* 给定起点、控制点、终点的X(或Y)坐标值,和给定时间t(∈[0, 1]),算出此时贝塞尔曲线的X(或Y)坐标值
*
* @param p0 起点值
* @param p1 控制点值
* @param p2 终点值
* @param t 给定的时间
* @return 曲线的位置值
*/
private float calculateBezierQuadratic(double p0, double p1, double p2, @FloatRange(from = 0, to = 1) double t) {
double tmp = 1 - t;
return (float) (tmp * tmp * p0 + 2 * tmp * t * p1 + t * t * p2);
}
public synchronized void setDotColor(int dotColor) {
this.dotColor = dotColor;
for (View view : mDotViews) {
GradientDrawable gradientDrawable = (GradientDrawable) view.getBackground();
gradientDrawable.setColor(dotColor);
}
}
http://www.2cto.com/kf/201610/553402_2.html