首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

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

前言

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

本文将从为什么探究缓动曲线、利用物理公式探究缓动曲线、常用缓动曲线、使用曲线拟合尝试剖析苹果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
double fsQuadraticEaseIn(double p)
{
return p * p;
}
// Modeled after the parabola y = -x^2 + 2x
double fsQuadraticEaseOut(double p)
{
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]
double fsQuadraticEaseInOut(double p)
{
if(p < 0.5){
return 2 * p * p;
}else{
return (-2 * p * p) + (4 * p) - 1;
}
}


// Modeled after the cubic y = x^3
double fsCubicEaseIn(double p)
{
return p * p * p;
}
// Modeled after the cubic y = (x - 1)^3 + 1
double fsCubicEaseOut(double p)
{
double f = (p - 1);
return f * 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]
double fsCubicEaseInOut(double p)
{
if(p < 0.5){
return 4 * p * p * p;
}else{
double f = ((2 * p) - 2);
return 0.5 * f * f * f + 1;
}
}


// Modeled after the quartic x^4
double fsQuarticEaseIn(double p)
{
return p * p * p * p;
}
// Modeled after the quartic y = 1 - (x - 1)^4
double fsQuarticEaseOut(double p)
{
double f = (p - 1);
return f * 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]
double fsQuarticEaseInOut(double p)
{
if(p < 0.5){
return 8 * p * p * p * p;
}else{
double f = (p - 1);
return -8 * f * f * f * f + 1;
}
}
// Modeled after the quintic y = x^5
double fsQuinticEaseIn(double p)
{
return p * p * p * p * p;
}
// Modeled after the quintic y = (x - 1)^5 + 1
double fsQuinticEaseOut(double p)
{
double f = (p - 1);
return f * 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]
double fsQuinticEaseInOut(double p)
{
if(p < 0.5){
return 16 * p * p * p * p * p;
}else{
double f = ((2 * p) - 2);
return  0.5 * f * f * f * f * f + 1;
}
}

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

// Modeled after quarter-cycle of sine wave
double fsSineEaseIn(double p)
{
return sin((p - 1) * M_PI_2) + 1;
}
// Modeled after quarter-cycle of sine wave (different phase)
double fsSineEaseOut(double p)
{
return sin(p * M_PI_2);
}
// Modeled after half sine wave
double fsSineEaseInOut(double p)
{
return 0.5 * (1 - cos(p * M_PI));
}

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

// Modeled after the exponential function y = 2^(10(x - 1))
double fsExponentialEaseIn(double p)
{
return (p == 0.0) ? p : pow(2, 10 * (p - 1));
}
// Modeled after the exponential function y = -2^(-10x) + 1
double fsExponentialEaseOut(double p)
{
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]
double fsExponentialEaseInOut(double p)
{
if(p == 0.0 || p == 1.0) return p;

if(p < 0.5){
return 0.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
double fsCircularEaseIn(double p)
{
return 1 - sqrt(1 - (p * p));
}
// Modeled after shifted quadrant II of unit circle         ? ?
double fsCircularEaseOut(double p)
{
return sqrt((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]
double fsCircularEaseInOut(double p)
{
if(p < 0.5){
return 0.5 * (1 - sqrt(1 - 4 * (p * p)));
}else{
return 0.5 * (sqrt(-((2 * p) - 3) * ((2 * p) - 1)) + 1);
}
}

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

double fsBounceEaseIn(double p)
{
return 1 - fsBounceEaseOut(1 - p);
}
double fsBounceEaseOut(double p)
{
if(p < 4/11.0){
return (121 * p * p)/16.0;
}
else if(p < 8/11.0){
return (363/40.0 * p * p) - (99/10.0 * p) + 17/5.0;
}
else if(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;
}
}
double fsBounceEaseInOut(double p)
{
if(p < 0.5){
return 0.5 * fsBounceEaseIn(p*2);
}else{
return 0.5 * fsBounceEaseOut(p * 2 - 1) + 0.5;
}
}

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

// Modeled after the overshooting cubic y = x^3-x*sin(x*pi)
double fsBackEaseIn(double p)
{
return p * p * p - p * sin(p * M_PI);
}
// Modeled after overshooting cubic y = 1-((1-x)^3-(1-x)*sin((1-x)*pi))
double fsBackEaseOut(double p)
{
double f = (1 - p);
return 1 - (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]
double fsBackEaseInOut(double p)
{
if(p < 0.5){
double f = 2 * p;
return 0.5 * (f * f * f - f * sin(f * M_PI));
}else{
double f = (1 - (2*p - 1));
return 0.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))
double fsElasticEaseIn(double p)
{
return sin(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
double fsElasticEaseOut(double p)
{
return sin(-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]
double fsElasticEaseInOut(double p)
{
if(p < 0.5){
return 0.5 * sin(13 * M_PI_2 * (2 * p)) * pow(2, 10 * ((2 * p) - 1));
}else{
return 0.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.481126 7.077155 6.764386 6.615895 6.398847 6.012318 5.336042 5.037438 4.632423 2.499686 1.901368 1.376071 0.770763 0.523422 0.507804 0.460093 ];
ydata = [3732.000000 3530.000000 3374.000000 3299.500000 3191.000000 2998.000000 2660.500000 2511.000000 2309.000000 1243.500000 944.500000 682.500000 380.000000 256.500000 248.500000 225.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:

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

fit.m:

xdata = [7.481126 7.077155 6.764386 6.615895 6.398847 6.012318 5.336042 5.037438 4.632423 2.499686 1.901368 1.376071 0.770763 0.523422 0.507804 0.460093 ];
ydata = [3.296769 3.279325 3.246985 3.230296 3.229400 3.196763 3.160944 3.096877 3.051205 2.768784 2.634592 2.467705 2.184812 1.984712 1.950520 1.900448 ];

x0 = [2 0];
[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:

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

fit.m:

xdata = [0.000000 0.000378 0.005109 0.011560 0.018097 0.024582 0.031021 0.037514 0.043967 0.050396 0.056848 0.063343 0.069754 0.076235 0.082703 0.089150 0.095607 0.102078 0.108535 0.114984 0.121442 0.127901 0.134363 0.140835 0.147283 0.153736 0.160199 0.166660 0.173118 0.179591 0.186018 0.192399 0.198885 0.205318 0.211814 0.218280 0.224735 0.231249 0.237728 0.244167 0.250627 0.257105 0.263540 0.269978 0.276416 0.282928 0.289383 0.295862 0.302294 0.308781 0.315241 0.321681 0.328127 0.334616 0.341050 0.347515 0.353993 0.360436 0.366884 0.373369 0.379804 0.386289 0.392751 0.399191 0.405641 0.412126 0.418565 0.425037 0.431430 0.437948 0.444395 0.450879 0.457318 0.463781 0.470227 0.476694 0.483150 0.489788 0.496073 0.502528 0.509003 0.515450 0.521907 0.528384 0.534848 0.541223 0.547798 0.554215 0.560576 0.567079 0.573566 0.579977 0.586440 0.592902 0.599360 0.605827 0.612332 0.618809 0.625250 0.631715 0.638173 0.644650 0.651082 0.657577 0.663956 0.670472 0.676926 0.683405 0.689841 0.696304 0.702780 0.709226 0.715672 0.722162 0.728595 0.735059 0.741537 0.747972 0.754428 0.760873 0.767310 0.773747 0.780275 0.786729 0.793185 0.799670 0.806105 0.819003 0.825398 0.831953 0.838424 0.851283 0.857681 0.864243 0.877113 0.890031 0.896437 0.909412 0.922341 0.935256 0.948169 0.967446 0.980412 0.999779 ];
ydata = [0.000000 0.033053 0.056022 0.086835 0.117087 0.146218 0.174230 0.201681 0.228011 0.253221 0.277871 0.301961 0.324930 0.347339 0.368627 0.389916 0.410084 0.429692 0.448179 0.466667 0.484034 0.501401 0.517647 0.533894 0.549020 0.564146 0.578711 0.592717 0.606162 0.619048 0.631933 0.644258 0.656022 0.667227 0.678431 0.689076 0.699720 0.709804 0.719328 0.728852 0.737815 0.746218 0.755182 0.763025 0.770868 0.778711 0.785994 0.793277 0.800560 0.806723 0.813445 0.819608 0.825770 0.831933 0.837535 0.843137 0.848179 0.853221 0.858263 0.863305 0.867787 0.872269 0.876751 0.880672 0.885154 0.889076 0.892437 0.896359 0.899720 0.903081 0.906443 0.909804 0.913165 0.915966 0.918768 0.921569 0.924370 0.927171 0.929412 0.932213 0.934454 0.936695 0.938936 0.941176 0.943417 0.945098 0.947339 0.949020 0.950700 0.952941 0.954622 0.956303 0.957423 0.959104 0.960784 0.961905 0.963585 0.964706 0.966387 0.967507 0.968627 0.969748 0.970868 0.971989 0.973109 0.974230 0.975350 0.976471 0.977031 0.978151 0.978711 0.979832 0.980392 0.981513 0.982073 0.983193 0.983754 0.984314 0.984874 0.985434 0.986555 0.987115 0.987675 0.988235 0.988796 0.989356 0.989916 0.990476 0.991036 0.991597 0.992157 0.992717 0.993277 0.993838 0.994398 0.994958 0.995518 0.996078 0.996639 0.997199 0.997759 0.998319 0.998880 0.999440 ];

x0 = [0.001  -10 0];
[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进行关键帧参数测量:

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

float calculate(float begin, float end, float lowerBound, float upperBound, float curVal)
{
    if (curVal<lowerBound) {
        curVal = lowerBound;
    }
    if (curVal>upperBound) {
        curVal = upperBound;
    }
    float t = (curVal-lowerBound) / (upperBound-lowerBound);
    return begin + (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

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/1zAb5xOz13KiWMWKIHZa
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券