要想做好动效,你得先知道这些

前言

动效是用户体验很重要的一部分。随着设备性能提升和人们对于用户体验越来越高的追求,动效将会越来越重要。动效要遵循客观物理规律以及人的视觉经验,符合用户预期,让用户感觉自然,才能获得用户的喜爱。

本文将从为什么探究缓动曲线、利用物理公式探究缓动曲线、常用缓动曲线、使用曲线拟合尝试剖析苹果 ScrollView 动效参数、使用线性插值高仿 APP 动效进行介绍。希望阅读后,本文能给你在制作动效时带来一点帮助。

一、为什么探究缓动曲线

动画是源自现实世界的,人类早已习惯了一个变速运动的物理环境,一个简单的匀速动画会让人相对感觉不适。所以需要让我们的动效符合物理规律。缓动曲线表述动画变化的程度与时间的关系,常用于模拟物理世界中一些常见动作。而从动画体验来说,不同的缓动曲线会带给用户不同体验。一般为:匀速运动 < 变速运动 < 物理缓动。

苹果官方的 UIView 提供了 Linear,EaseIn,EaseOut,EaseInout 还有 bezier 动画函数,然而只是局限于使用,知其然而不知其所以然。例如用 ease-in 来做小球从高处掉下的效果,这个加速效果没有遵循相关物理原理,使得出来的动画效果不太自然。

二、利用物理公式探究缓动曲线

以下以弹簧动画为例,探究一下怎样模拟出这个效果。

iOS 9 提供了 CASpringAnimation 类实现该效果,而 Web 上就没有提供类似函数。但我们仍然可以通过以前学过的物理学和数学知识来做一下研究。

下面有一个弹簧块,假设它质量为 1,在它不动的时候位置是 x = 1,则拉伸时的距离就是 x-1 了:

将这比作一个动画,弹簧块在时间 t 时所处的位置 x 就可以看作动画曲线函数 x = f(t)。如果我们求得这个函数公式,就可以模拟出这个动画效果了。对此,下图将通过物理学公式和数学知识进行探讨。

在 Wolfram | Alpha 中输入以上公式后得出:

使用工具绘制函数得:

感觉还是蛮像一个弹簧曲线的运动轨迹的嘛。像这样,如果我们要模仿自然生活中的某个运动轨迹,可以如上探究一下背后的物理方程,运用数学知识计算,和使用合适的工具,来模拟出对应的运动曲线。但估计很多人都把这些知识还给老师了,因此如果所有曲线都要自己探究的话,就真是太难了。

不要担心,后面还有一大半的篇幅,就是帮你解决这个问题的。

三、常用缓动曲线

下面是常见的缓动曲线(tween 算法),我们下面将给出对应曲线的函数公式和代码,cubic-bezier 这个网站还提供了对应贝塞尔参数。

EaseIn 是从慢到快的曲线,就像开车时先慢后快,EaseOut 和 EaseIn 的曲线图像关于 (0.5,0.5) 中心对称,EaseInOut:分别由 EaseIn、EaseOut 分别缩小一半,然后再拼接一起。

Quad,Cubic,Quart ,Quint:幂函数二次到五次曲线

复制代码

// Modeled after the parabola y = x^2
doublefsQuadraticEaseIn(doublep)
{
returnp * p;
}
// Modeled after the parabola y = -x^2 + 2x
doublefsQuadraticEaseOut(doublep)
{
return-(p * (p -2));
}
// Modeled after the piecewise quadratic
// y = (1/2)((2x)^2) ; [0, 0.5)
// y = -(1/2)((2x-1)*(2x-3) - 1) ; [0.5, 1]
doublefsQuadraticEaseInOut(doublep)
{
if(p <0.5){
return2* p * p;
}else{
return(-2* p * p) + (4* p) -1;
}
}


// Modeled after the cubic y = x^3
doublefsCubicEaseIn(doublep)
{
returnp * p * p;
}
// Modeled after the cubic y = (x - 1)^3 + 1
doublefsCubicEaseOut(doublep)
{
doublef = (p -1);
returnf * f * f +1;
}
// Modeled after the piecewise cubic
// y = (1/2)((2x)^3) ; [0, 0.5)
// y = (1/2)((2x-2)^3 + 2) ; [0.5, 1]
doublefsCubicEaseInOut(doublep)
{
if(p <0.5){
return4* p * p * p;
}else{
doublef = ((2* p) -2);
return0.5* f * f * f +1;
}
}


// Modeled after the quartic x^4
doublefsQuarticEaseIn(doublep)
{
returnp * p * p * p;
}
// Modeled after the quartic y = 1 - (x - 1)^4
doublefsQuarticEaseOut(doublep)
{
doublef = (p -1);
returnf * f * f * (1- p) +1;
}
// Modeled after the piecewise quartic
// y = (1/2)((2x)^4) ; [0, 0.5)
// y = -(1/2)((2x-2)^4 - 2) ; [0.5, 1]
doublefsQuarticEaseInOut(doublep)
{
if(p <0.5){
return8* p * p * p * p;
}else{
doublef = (p -1);
return-8* f * f * f * f +1;
}
}
// Modeled after the quintic y = x^5
doublefsQuinticEaseIn(doublep)
{
returnp * p * p * p * p;
}
// Modeled after the quintic y = (x - 1)^5 + 1
doublefsQuinticEaseOut(doublep)
{
doublef = (p -1);
returnf * f * f * f * f +1;
}
// Modeled after the piecewise quintic
// y = (1/2)((2x)^5) ; [0, 0.5)
// y = (1/2)((2x-2)^5 + 2) ; [0.5, 1]
doublefsQuinticEaseInOut(doublep)
{
if(p <0.5){
return16* p * p * p * p * p;
}else{
doublef = ((2* p) -2);
return0.5* f * f * f * f * f +1;
}
}

Sine :正弦函数曲线,常用于模拟波浪和呼吸效果

复制代码

// Modeled after quarter-cycle of sine wave
doublefsSineEaseIn(doublep)
{
returnsin((p -1) * M_PI_2) +1;
}
// Modeled after quarter-cycle of sine wave (different phase)
doublefsSineEaseOut(doublep)
{
returnsin(p * M_PI_2);
}
// Modeled after half sine wave
doublefsSineEaseInOut(doublep)
{
return0.5* (1-cos(p * M_PI));
}

Expo:2^(10(x-1)),指数函数,开始很慢后期很快

复制代码

// Modeled after the exponential function y = 2^(10(x - 1))
doublefsExponentialEaseIn(doublep)
{
return(p ==0.0) ? p : pow(2,10* (p -1));
}
// Modeled after the exponential function y = -2^(-10x) + 1
doublefsExponentialEaseOut(doublep)
{
return(p ==1.0) ? p :1- pow(2,-10* p);
}
// Modeled after the piecewise exponential
// y = (1/2)2^(10(2x - 1)) ; [0,0.5)
// y = -(1/2)*2^(-10(2x - 1))) + 1 ; [0.5,1]
doublefsExponentialEaseInOut(doublep)
{
if(p ==0.0|| p ==1.0)returnp;

if(p <0.5){
return0.5* pow(2, (20* p) -10);
}else{
return-0.5* pow(2, (-20* p) +10) +1;
}
}

Circ:顾名思义就是弧(1/4 圆,如果选择了 InOut 就是两个外切的 1/4 圆)

复制代码

// Modeled after shifted quadrant IV of unit circle
doublefsCircularEaseIn(doublep)
{
return1- sqrt(1- (p * p));
}
// Modeled after shifted quadrant II of unit circle ? ?
doublefsCircularEaseOut(doublep)
{
returnsqrt((2- p) * p);
}
// Modeled after the piecewise circular function
// y = (1/2)(1 - sqrt(1 - 4x^2)) ; [0, 0.5)
// y = (1/2)(sqrt(-(2x - 3)*(2x - 1)) + 1) ; [0.5, 1]
doublefsCircularEaseInOut(doublep)
{
if(p <0.5){
return0.5* (1- sqrt(1-4* (p * p)));
}else{
return0.5* (sqrt(-((2* p) -3) * ((2* p) -1)) +1);
}
}

Bounce:这是个模拟小球落地的反弹曲线,运动时间每次按 0.5 倍衰减

复制代码

doublefsBounceEaseIn(doublep)
{
return1- fsBounceEaseOut(1- p);
}
doublefsBounceEaseOut(doublep)
{
if(p <4/11.0){
return(121* p * p)/16.0;
}
elseif(p <8/11.0){
return(363/40.0* p * p) - (99/10.0* p) +17/5.0;
}
elseif(p <9/10.0){
return(4356/361.0* p * p) - (35442/1805.0* p) +16061/1805.0;
}else{
return(54/5.0* p * p) - (513/25.0* p) +268/25.0;
}
}
doublefsBounceEaseInOut(doublep)
{
if(p <0.5){
return0.5* fsBounceEaseIn(p*2);
}else{
return0.5* fsBounceEaseOut(p *2-1) +0.5;
}
}

Back: 这是个模拟弹簧运动过阻尼曲线

复制代码

// Modeled after the overshooting cubic y = x^3-x*sin(x*pi)
doublefsBackEaseIn(doublep)
{
returnp * p * p - p * sin(p * M_PI);
}
// Modeled after overshooting cubic y = 1-((1-x)^3-(1-x)*sin((1-x)*pi))
doublefsBackEaseOut(doublep)
{
doublef = (1- p);
return1- (f * f * f - f * sin(f * M_PI));
}
// Modeled after the piecewise overshooting cubic function:
// y = (1/2)*((2x)^3-(2x)*sin(2*x*pi)) ; [0, 0.5)
// y = (1/2)*(1-((1-x)^3-(1-x)*sin((1-x)*pi))+1) ; [0.5, 1]
doublefsBackEaseInOut(doublep)
{
if(p <0.5){
doublef =2* p;
return0.5* (f * f * f - f * sin(f * M_PI));
}else{
doublef = (1- (2*p -1));
return0.5* (1- (f * f * f - f * sin(f * M_PI))) +0.5;
}
}

Elastic:这是个模拟弹簧运动欠阻尼曲线,就是我们前面研究想得出的曲线,

复制代码

// Modeled after the damped sine wave y = sin(13pi/2*x)*pow(2, 10 * (x - 1))
doublefsElasticEaseIn(doublep)
{
returnsin(13* M_PI_2 * p) * pow(2,10* (p -1));
}
// Modeled after the damped sine wave y = sin(-13pi/2*(x + 1))*pow(2, -10x) + 1
doublefsElasticEaseOut(doublep)
{
returnsin(-13* M_PI_2 * (p +1)) * pow(2,-10* p) +1;
}
// Modeled after the piecewise exponentially-damped sine wave:
// y = (1/2)*sin(13pi/2*(2*x))*pow(2, 10 * ((2*x) - 1)) ; [0,0.5)
// y = (1/2)*(sin(-13pi/2*((2x-1)+1))*pow(2,-10(2*x-1)) + 2) ; [0.5, 1]
doublefsElasticEaseInOut(doublep)
{
if(p <0.5){
return0.5* sin(13* M_PI_2 * (2* p)) * pow(2,10* ((2* p) -1));
}else{
return0.5* (sin(-13* M_PI_2 * ((2* p -1) +1)) * pow(2,-10* (2* p -1)) +2);
}
}

下面是使用 MATLAB 绘制的幂函数缓动曲线里程、速度、加速度随时间变化图,可以看到幂函数次数越高,曲线前期越平缓后期越陡峭,动效的动静对比也就越强。

LTMorphingLabel 用 Swift 编写的 UILabel 子类,实现了 iOS8 中 iMessage 文字变换动画。它用到了 EaseInQuint,EaseOutQuint,EaseOutBack,EaseOutBounce 4 个缓动曲线,下面是一些效果图,另外附上我写的 OC 版本源码 FSMorphingLabel:

使用 5 阶幂函数 EaseQuint 突出动效的动静对比:

使用 EaseOutBack 模拟悬挂掉落的过程:

使用 EaseOutBounce 模拟自然掉落后回弹:

下面的思维导图是我对 FSMorphingLabel 的一些解读,建议结合源码一起查看:

四、使用 Matlab 曲线拟合尝试剖析苹果 ScrollView 动效参数

苹果公司对用户体验做的可以说是行业典范。学习经典案例不但能让我们学到新知识,还可以少走弯路。通过代码获取 ScrollView 开始拖动减速后滑动的速度,减速过程中每个时间段的位移,最终位移数据。我们使用 matlab 来分析这些数据之间的一些关系。

初始速度 (V) 和滑动位移 (S) 之间的多项式拟合:

复制代码

xdata = [7.4811267.0771556.7643866.6158956.3988476.0123185.3360425.0374384.6324232.4996861.9013681.3760710.7707630.5234220.5078040.460093];
ydata = [3732.0000003530.0000003374.0000003299.5000003191.0000002998.0000002660.5000002511.0000002309.0000001243.500000944.500000682.500000380.000000256.500000248.500000225.000000];
p = polyfit(xdata, ydata,1)

fitxdataArr =0:0.2:15;
yFitArr = polyval(p, fitxdataArr);

plot(xdata, ydata,'o');
hold on; grid on;
plot(fitxdataArr, yFitArr,'linewidth',2);
xlabel('速度(v)');
ylabel('里程(s)');
legend('原始数据','拟合曲线')

拟合出来的结果不是猜测的匀减速二次关系,而是线性的,S = 500*V – 5; 我们可以看成是 S = 500*V;

初始速度 (V) 和滑动时间 (T) 使用 2 到 5 次的多项式拟合图:

下面是分别使用的 2 到 5 次幂函数用最小二乘法去拟合,下图红色是原始数据,蓝色是拟合后后曲线,预测明显不符合走势。

于是我们又使用了对数模型: F = x(1)*log2(xdata) + x(2);

myfun.m:

复制代码

functionF=myfun(x, xdata)
F=x(1)*log2(xdata)+x(2);
end

fit.m:

复制代码

xdata = [7.4811267.0771556.7643866.6158956.3988476.0123185.3360425.0374384.6324232.4996861.9013681.3760710.7707630.5234220.5078040.460093];
ydata = [3.2967693.2793253.2469853.2302963.2294003.1967633.1609443.0968773.0512052.7687842.6345922.4677052.1848121.9847121.9505201.900448];

x0 = [20];
[coefArr,resnorm] = lsqcurvefit(@myfun, x0,xdata, ydata)

fitxdataArr =0:0.2:20;
yFitArr = myfun(coefArr, fitxdataArr);
plot(xdata, ydata, fitxdataArr, yFitArr,'linewidth',1);

得到滑动时间 T = 0.3452*log2(V) + 2.3019,吻合得很好。

这边如果是匀减速的模型,初始速度和时间的比例就是线性的,这个体验不好,安卓的貌似就是匀减速,改天验证一下。

滑动时间 (T) 与里程 (S) 之间的关系:

类似上面的也使用了多项式进行拟合,发现不对,曲线在样本数据区间走势不一样,最后我们使用的指数函数 F= x(1)*2.^(x(2)*xdata) + x(3) 作为经验公式,初始参数 X=[ 0.001 -10 0] 进行模拟退火计算,得到参数 [-0.9777 -7.4895 1.0043],经过多组数据测试,发现第一个和第三个参数波动在 0.01 内可以近似成 1,而第二个参数会随着不同初始速度大小呈反函数曲线的关系,从而无法确定。不过函数每次计算均方差都在 0.001 以下,可以看到下面的拟合曲线完美遮住了原始数据,可以确定指数模型是正确的。

myfun.m:

复制代码

functionF=myfun(x, xdata)
F=x(1)*2.^(x(2)*xdata) +x(3);
end

fit.m:

复制代码

xdata = [0.0000000.0003780.0051090.0115600.0180970.0245820.0310210.0375140.0439670.0503960.0568480.0633430.0697540.0762350.0827030.0891500.0956070.1020780.1085350.1149840.1214420.1279010.1343630.1408350.1472830.1537360.1601990.1666600.1731180.1795910.1860180.1923990.1988850.2053180.2118140.2182800.2247350.2312490.2377280.2441670.2506270.2571050.2635400.2699780.2764160.2829280.2893830.2958620.3022940.3087810.3152410.3216810.3281270.3346160.3410500.3475150.3539930.3604360.3668840.3733690.3798040.3862890.3927510.3991910.4056410.4121260.4185650.4250370.4314300.4379480.4443950.4508790.4573180.4637810.4702270.4766940.4831500.4897880.4960730.5025280.5090030.5154500.5219070.5283840.5348480.5412230.5477980.5542150.5605760.5670790.5735660.5799770.5864400.5929020.5993600.6058270.6123320.6188090.6252500.6317150.6381730.6446500.6510820.6575770.6639560.6704720.6769260.6834050.6898410.6963040.7027800.7092260.7156720.7221620.7285950.7350590.7415370.7479720.7544280.7608730.7673100.7737470.7802750.7867290.7931850.7996700.8061050.8190030.8253980.8319530.8384240.8512830.8576810.8642430.8771130.8900310.8964370.9094120.9223410.9352560.9481690.9674460.9804120.999779];
ydata = [0.0000000.0330530.0560220.0868350.1170870.1462180.1742300.2016810.2280110.2532210.2778710.3019610.3249300.3473390.3686270.3899160.4100840.4296920.4481790.4666670.4840340.5014010.5176470.5338940.5490200.5641460.5787110.5927170.6061620.6190480.6319330.6442580.6560220.6672270.6784310.6890760.6997200.7098040.7193280.7288520.7378150.7462180.7551820.7630250.7708680.7787110.7859940.7932770.8005600.8067230.8134450.8196080.8257700.8319330.8375350.8431370.8481790.8532210.8582630.8633050.8677870.8722690.8767510.8806720.8851540.8890760.8924370.8963590.8997200.9030810.9064430.9098040.9131650.9159660.9187680.9215690.9243700.9271710.9294120.9322130.9344540.9366950.9389360.9411760.9434170.9450980.9473390.9490200.9507000.9529410.9546220.9563030.9574230.9591040.9607840.9619050.9635850.9647060.9663870.9675070.9686270.9697480.9708680.9719890.9731090.9742300.9753500.9764710.9770310.9781510.9787110.9798320.9803920.9815130.9820730.9831930.9837540.9843140.9848740.9854340.9865550.9871150.9876750.9882350.9887960.9893560.9899160.9904760.9910360.9915970.9921570.9927170.9932770.9938380.9943980.9949580.9955180.9960780.9966390.9971990.9977590.9983190.9988800.999440];

x0 = [0.001-100];
[coefArr,resnorm] = lsqcurvefit(@myfun, x0, xdata, ydata)

fitxdataArr =0:0.01:1;
yFitArr = myfun(coefArr, fitxdataArr);
subplot(1,2,1);
plot(xdata, ydata, fitxdataArr, yFitArr,'linewidth',2);
legend('原始数据','拟合曲线');
xlabel('时间(t)');
ylabel('');
grid on,axis equal

subplot(1,2,2);
plot(xdata, ydata)
legend('原始数据');
xlabel('时间(t)');
ylabel('');
grid on,axis equal

总结我们发现苹果 ScrollView 减速使用的不是简单的匀减速模型,而看的是更像是复杂过阻尼模型,过阻尼模型更贴近自然,日常的关门就是过阻尼运动,能避免很吵的碰撞冲击。

五、使用线性插值高仿 APP 动效

上面介绍了从物理公式推导中获取运动方程的方式、使用曲线拟合的方法获取缓动公式,下面我们还有一种更加简单的方式来做出跟效果一样的动画。

AppSotre 上有很多让人惊艳的 APP,他们的交互值得我们学习,下面我以蘑菇街为例简单分析一下如何在没有设计稿和动效参数的情况下,使用简单的线性插值来做出几乎跟原版一模一样的交互。

首先我们打开 QuickTimePlayer,点击文件下面的影片录制,然后打开对应 APP 页面进行视频录制。录制完成后使用 GIFBrewery 打开,慢动作播放对应视频,在熟悉了视频中的大部分动作后,使用 XScope 工具对关键动作的真实位置进行测量,结合 GIFBrewery 中对应的时间轴,我们就知道了一个动作的开始时间结束时间、开始位置结束位置,足够我们进行线性插值了。

一些明显使用了缓动曲线的动效,多取几个动效点,用折线段来逼近曲线,可以近似出任何动效曲线效果,近似的思想是无敌的,在实际应用中,我们无需获取到准确的函数或方程,效果一样就行。

忘记了还有一步最重要的,使用 iOS image extractor 提取 ipa 包中的所有图片,这软件就是你的专用切图师,只要有安装包,你就能拿到他们的图片。当然,随着去年 iTunes 的升级,现在已经无法从 iTunes 上面下载到 ipa 安装包了,建议从 apk 文件下手。

下面附上几个工具的 icon 截图:

使用 xScope 和 GifBrwery 进行关键帧参数测量:

插值核心代码,真的简单,难在上面的参数测量上:

复制代码

floatcalculate(floatbegin,floatend,floatlowerBound,floatupperBound,floatcurVal)
{
if(curValupperBound) {
curVal = upperBound;
}
floatt = (curVal-lowerBound) / (upperBound-lowerBound);
returnbegin+ (end-begin)*t;;
}

下面是我仿的两个 app 的代码:

仿蘑菇街:

https://github.com/wengzf/MushroomGuide

仿天巡:

https://github.com/wengzf/SkyScanner

一件优秀的作品需要大量的时间去思考去打磨,仿佛破蛹成蝶。

作者介绍:

翁志方,携程内容信息研发部“旅拍”前端开发。曾参加 ACM-ICPC 获银奖,目前喜欢研究各种新颖的交互和实现。

本文转载自公众号携程技术中心(ID:ctriptech)。

原文链接:

https://mp.weixin.qq.com/s?__biz=MjM5MDI3MjA5MQ==&mid=2697269147&idx=3&sn=4f6b83b736261cd82162c486ce75f84c&chksm=8376f0afb40179b95948495730d42b78f25fbefa7163605101838b7fe2f0085cfc4071db797c&scene=27#wechat_redirect