SVG动画案例的学习

平面设计已成为2016年可见的趋势,显然,这就是SVG用法又重新走入人们的视野的原因。好处有很多: 独立的分辨率、跨浏览器兼容性以及DOM节点的可访问性。本文中,我们将看看如何使用SVG从简单的插图创建看似复杂的动画。

简明介绍

图1. 创建的效果如何?从简单的SVG插图创建看似复杂的动画。

该项目始于一个简单的实验想法: 我们可将SVG动画效果进行到多远?

那时候,设计师Chris Halaska和我是一个插图竞选网站的同事。在所有的创意搜索中,作品虽然很美观,但是缺乏所需的“魅力”。我们在“摄像系列”中找到了答案,即图形动画。可以使用动画将插图赋予生命,而SVG是做到这一点的最佳媒介。

我们所面临的问题(当然也是今天依旧所存在的问题)是,SVG动画应该委托给正在尝试艺术方向的前端开发人员还是正在试图学习JavaScript的设计师。当然,这些场景都是本质上的错误,但是使用少量的应用程序解决问题,实现动画在视觉的天然显示,需要在代码和设计之间搭建一个桥梁。

我们的想法是创建一个数据驱动程序,使设计师可以从静态插图到原型动画的快速实现。

动画的相关规则

生命的幻象中,Disney概述了添加人物到动画的12个基本原则。适用于将所有无生命或者其他对象赋予生命的: 挤压与伸展(squash and stretch),预期动作(anticipation),渐快与渐慢(slow in and slow out),时间控制(timing)以及夸张(exaggeration)。在我们的项目中想要遵守这些原则,使僵硬的DOM操作更加流畅与自然。围绕变形(transformation),时间控制(timing)以及延缓过渡(easing),我们可以创建风格统一的动画,但是各自又有自己的特点。

变形(transformations)

因为插画的简单性,平面设计本身有点趋向于SVG用法。我们在动画中模仿这一点,将简单几何图形与简单几何运动进行配对。这里遵守一个单一规则: 使用基本原点(left,right,top,bottom以及center)和变形(translate,rotate,scale)。

transformations-opt

图2. 9个可能的动画原点,组合使用left,right,center,top以及bottom

时间控制(timing)

为了保持类似的步调与节奏,我们约束到具体的时间增量。动画持续2s并且由10个单独的步骤组成。一个补间中,动画一个单一的变形(translate,rotate以及scale)也必须始于并终止于这些步骤中,我们称之为 关键帧

timing-opt

图3. 具有三个补间,每200ms进行递增的一个动画示例。

延缓过渡(easing)

变形以及时间控制足以创建具有运动的视觉感知,但是延缓过渡则赋予了生命力。我们发现了三个延缓过渡的公式——添加角色到运动时提供更多的变化: easeOutBack,easeInOutBack以及easeOutQuint

easing-opt

图4. 有无延缓过渡的动画比较。注意,使用easingBack的任何变化都会在某种程度上影响变形。

让我们开始吧

前期准备

随着Sketch以及Inkscape的流行,插图应用程序近年来成熟了很多,我们选择在Adobe Illustrator中绘制SVGs。

breakdown-opt

图5. 最终动画元素地分解。

illustrator-layers-opt

图6. 使用Illustrator导出SVG时,会自动创建图层ID。

在导出SVG时,分组以及标释每一图层。Illustrator导出步骤中会自动根据图层名称创建ID。对于每一个动画元素,输出应该类似于如下所示的XML。需要注意即使没有子元素,仍旧需要一个g标签进行包裹。这是为SVG添加变形做准备,后续会进行解释。

<g id="zipper">
    <path fill="#272C40" d="…"/>
</g>

illustrator-exporting-opt

图7. SVG导出设置。这里没有勾选"responsive"是因为动画单位是基于像素的。

处理蒙版

你可能已经在图6注意到了<Clip Group>图层。本质上它们是在Illustrator中创建的剪切蒙版。当导出SVG时,它们会自动被重新定义为clipPaths,可以以相同的方式遮挡元素。

<g>
    <defs>
        <rect id="SVGID_1_" x="235" y="-106.3" width="500" height="309"/>
    </defs>

    <clipPath id="SVGID_2_">
        <use xlink:href="#SVGID_1_"  overflow="visible"/>
    </clipPath>

    <g id="strap-right" clip-path="url(#SVGID_2_)">
        <path fill="#93481F" stroke="#000000" stroke-width="1.5" stroke-miterlimit="10" d="…"
    />
    </g>
</g>

clipPath-opt

图8. 动画之前,使用clipPath隐藏皮带。

原型,原型,原型

前期准备的完成,开始制作。我们在创建原型以及测试各种技术之间创建一个迭代过程,以查找解决方案。这里简单概述一下我们每一个尝试的优点以及缺点,以及为什么我们从一个方案转到了另一个。

CSS 以及 VELOCITY.JS

最初使用CSS创建动画的尝试也是很有希望的。我们坚信基于硬件加速的变形,动画将平滑运行,而执行也会变得简单不需要加载多余的库。虽然我们可以在Chrome中创建一个功能版本,但是却不适用于其它浏览器。

Firefox不支持SVG transform-origin属性,当然Internet Explorer的支持更无从谈起了。最后,随着CSS以及JavaScript的紧密耦合,我们需要在自认为很优雅的众多解决方案文件中来回跳转。

当我们转向Velocity.js的使用时也出现了同样的问题。因为动画引擎也需要使用CSS 变形,但是Firefox以及Internet Explorer的支持性问题仍未解决。

GSAP

自从Flash之后,GSAP一直是工业标准,被移植到JavaScript之后所受欢迎程度愈加明显,凭借着链式书写语法,SVG的支持以及无与伦比的性能,GSAP是一个明显的竞争者——除一个问题外: 矫枉过正。导入TweenMax 以及 TimelineMax 就会立即使我们的文件大小翻一番,并且这被证明有些过度。Chris Gannon使我们知道,这里存有一个误区。TimelineMax 包含在TweenMax 之中,结合起来也只有37kb。

SNAP.SVG

最后的尝试中我们使用了Snap.svg —— Raphael的继承者。Snap在DOM操作方面提供了丰富的功能,但是在动画方面的支持却很受限。我们认识到了这一受限点,决定使用JavaScript来进行空白填补。这是一个轻量级的解决方案,可以实现动画的逼真度追求。

MO.JS,ANIME以及WEB ANIMATIONS API

书写这篇文章时,三个很有前途的SVG动画库在社区得到了一定的吸引力: Mo.js,Anime以及Web Animation API。如果我们可以重新审视这个问题,肯定会考虑这些方案。尽管如此,这篇文章背后的概念应该转向你想要使用的任何动画库。

文件结构

首先在我们的项目中导入Snap.svg库以及基本样式表。当然还有之后会用到的 Robert Penner的延缓过度函数

folder-structure-opt

图9. 项目最后的文件结构。“Hello world”支撑仅仅和高亮文件相关。

<!DOCTYPE html>
<html lang="en" class="no-js">
<head>
  <meta charset="UTF-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>The Illusion of Life: An SVG Animation Case Study</title>

  <!-- Styles -->
  <link rel="stylesheet" type="text/css" href="css/style.css" />

  <!-- Libraries -->
  <script src="js/libs/snap.svg.min.js"></script>
  <script src="js/libs/snap.svg.easing.min.js"></script></html>
</head>
</html>

/* Full screen */
html, body {
  position: relative;
  width: 100%;
  height: 100%;
  margin: 0;
  overflow: hidden;
  background-color: #E6E6E6;
  font-family: sans-serif;
}

/* Centered canvas */
#canvas {
  position: absolute;
  top: 50%;
  left: 50%;
  -webkit-transform: translateX(-50%) translateY(-50%);
  -ms-transform: translateX(-50%) translateY(-50%);
  transform: translateX(-50%) translateY(-50%);
  overflow: hidden;
}

Hello World

“Hello world” ——一个小的,简单的实验胜利成果。对于我们而言,只是在屏幕上显示了一些内容。首先实例化一个Snap对象,使用DOM ID代表画布。使用Snap.load 函数声明外部的SVG资源以及使用一个匿名回调将节点附加到DOM树。

<body>
<div id="canvas"></div>

<script>
  (function() {
    var s = Snap('#canvas');

    Snap.load("svg/backpack.svg", function (data) {
      s.append(data);
    });
  })();
</script>
</body>

制作一个简单的插件

创建一个可重用的多个动画的组件,我们使用 原型模式创建了一个“插件”。使用了一个 立即调用函数表达式(IIFE)确保数据的封装,同时仍将SVGAnimation添加到全局命名空间。如果我们将现有的代码放在一个独立的init函数中,就打下了SVGAnimation的基础。

; (function(window) {
    'use strict';

    var svgAnimation = function () {
        var self = this;
        self.init();
    };

    svgAnimation.prototype = {
        constructor: svgAnimation,

        init: function() {
            var s = Snap('#canvas');

            Snap.load("svg/backpack.svg", function (data) {
                s.append(data);
            });
        }
    };

    // Add to global namespace
    window.svgAnimation = svgAnimation;
})(window);

添加选项

解析Snap.load,可以发现两个潜在的可以作为参数传递的选项,画布以及外部SVG的资源。让我们创建一个loadJSON函数来进行处理。

/*
Loads the SVG into the DOM
@param {Object}   canvas
@param {String}   svg
*/
loadSVG: function(canvas, data) {
    Snap.load(svg, function(data) {
        canvas.append(svg);
    });
}

OBJECTS AS PARAMETERS

对象作为参数

现在需要一种方式将选项传递到SVGAnimation函数中。有许多方式可以实现,以标准方式进行各个参数的传递。

var backpack = new svgAnimation(Snap('#canvas'), 'svg/backpack.svg');

但是这里有一个更好的解决方案。传递一个对象参数,不仅增强了代码的可读性,也会更具有灵活性。我们无需对顺序进行追踪。可以使参数可选,同时之后还可以再次使用对象。现在我们重写一下之前的代码,传递一个options对象参数。

var backpack = new svgAnimation({
    canvas:       new Snap('#canvas'),
    svg:          'svg/backpack.svg'
});

合并对象

有了options对象后,需要使值可以被其余的插件访问到。在做这一步之前,先将传入的对象值用我们的默认值进行合并。尽管将这两个值设置为null,我们仍然会将他们作为期望接收到的引用类型值的参考。

svgAnimation.prototype = {
    constructor: svgAnimation,

    options: {
        canvas:     null,
        svg:        null
    }
};

设置了默认值,使用一个extend函数进行两个对象的合并。本质上,函数会循环遍历每个对象的所有属性并将它们复制到另外一个对象之中。

/*
Merges two objects
@param  {Object}  a
@param  {Object}  b
@return {Object}  sum
http://stackoverflow.com/questions/11197247/javascript-equivalent-of-jquerys-extend-method
*/
function extend(a, b) {
    for (var key in b) {
        if (b.hasOwnProperty(key)) {
            a[key] = b[key];
        }
    }

    return a;
}

定义了extend函数,需要对SVGAnimation函数进行修改。你会注意到这里将this设置为了self。对this进行缓存,保证内部的作用域可以接收到当前对象的值与方法。

var svgAnimation = function (options) {
    var self = this;
    self.options = extend({}, self.options);
    extend(self.options, options);
    self.init();
}

最后,更新init,调用loadSVG,将实例化过程中设置的canvas以及svg进行引用传递。

init: function() {
    var self = this;

    self.loadSVG(self.options.canvas, self.options.svg);
}

硬编码原型

添加SVG变形组

正如之前提到的,Snap.svg的动画引擎类似于CSS,相当原始,仅支持单一的字符串变形。意味着如果你想要对多个类型的动画变形,要么按照顺序依次进行,要么全部一次进行(共享持续时间以及过渡延缓)。我们可以添加额外的节点到DOM树中来解决这个问题,尽管不是最优解决方案。为每一个translaterotate以及scale变形,使用一个单独的分组元素,可以独立控制每一个动画补间。最好的用例说明是zipper,也就是我们的最初原型。

首先在createTransformGroup函数中进行zipper元素的传递,之后对其进行定义。

var $zipper = canvas.select("#zipper");
self.createTransformGroup($zipper);

选择所有的子节点后,使用Snap.g函数进行各自变形组的嵌套。

/*
Create scale, rotate and transform groups around an SVG DOM node
@param {object} Snap element
*/

createTransformGroup: function(element) {
    if (element.node) {
        var childNodes = element.selectAll('*');

        element.g().attr('class', 'translate')
            .g().attr('class', 'rotate')
            .g().attr('class', 'scale')
            .append(childNodes);
    }
}

这会创建独立的变形群组,也就可以对动画进行目标设置。

<!-- Old node -->
<g id="zipper">
    <path fill="#272C40" d="…"/>
</g>

<!-- New node -->
<g id="zipper">
    <g class="translate">
        <g class="rotate">
            <g class="scale">
                <path fill="#272C40" d="…"></path>
            </g>
        </g>
    </g>
</g>

一个SNAP.SVG动画

现在准备对第一个元素进行动画处理。Snap.svg提供了两个函数来执行此操作: transform以及animate。在第一个关键帧,使用transform进行动画设置,使用animate进行之后的处理。

Snap.svg支持标准的SVG变形符号,但是我们选择使用 变形字符串作为参数设置的一种手段。官方网站上的相关解释很少,但是可以在Raphael上找到原始文档。最初的大写字母是变形的缩写。参数 xy以及我们期望的动画的angle表示值,中心原点的cxcy

// Scale
Snap.animate({transform: 'S x y cx cy'}, duration, easing, callback);

// Rotation
Snap.animate({transform: 'R angle cx cy'}, duration, callback);

// Translate
Snap.animate({transform: 'T x y'}, duration, callback);

计算原点

定义原点时,我们出现了一个有趣的问题。Snap.svg中,animate以及transform函数只接受参数作为像素值,这导致难以对其进行衡量。理想情况下,我们想要结合topright,bottom,left以及center定义原点。

幸运的是,Snap.svg提供了getBBox,可以衡量任何给定元素的边界并返回大量的描述符,当然包括我们想要获取的值。这里我们书写了两个函数,getOriginX以及getOriginY,它们接收一个bBox对象以及一个direction字符串参数,返回所需的像素值。

/*
Translates the horizontal origin from a string to pixel value
@param {Object}     Snap bBox
@param {String}     "left", "right", "center"
@return {Object}    pixel value
*/

getOriginX: function (bBox, direction) {
    if (direction === 'left') {
        return bBox.x;
    }

    else if (direction === 'center') {
        return bBox.cx;
    }

    else if (direction === 'right') {
        return bBox.x2;
    }
},

/*
Translates the vertical origin from a string to pixel value
@param {Object}     Snap bBox
@param {String}     "top", "bottom", "center"
@return {Object}    pixel value
*/

getOriginY: function (bBox, direction) {
    if (direction === 'top') {
        return bBox.y;
    }

    else if (direction === 'center') {
        return bBox.cy;
    }

    else if (direction === 'bottom') {
        return bBox.y2;
    }
}

动画实践

创建一个缩放动画进行实践。根据类名对变形组进行选择,进行缩放直至隐藏,之后再返回原来的大小。你会注意到我们从拉链的顶部进行缩放,时间间隔为400ms,将原始的过延缓设置为easeOutBack

// Scale Tween
var $scaleElement = $zipper.select('.scale');
var scaleBBox = $scaleElement.getBBox();
$scaleElement.transform('S' + 0 + ' ' + 0 + ' ' + self.getOriginX(scaleBBox, 'center') + ' ' + self.getOriginY(scaleBBox, 'top'));
$scaleElement.animate({transform: 'S' + 1 + ' ' + 1 + ' ' + self.getOriginX(scaleBBox, 'center') + ' ' + self.getOriginY(scaleBBox, 'top')}, 400, mina['easeOutBack']);

旋转也遵循相同的模式,有几个复杂性。在这种情况下,有三个连续的补间播放。当每一个动画结束时,使用回调函数进行下一个动画的有序播放。

// Rotate Tween
var $rotateElement = $zipper.select('.rotate');
var rotateBBox = $rotateElement.getBBox();
$rotateElement.transform('R' + 45 + ' ' + rotateBBox.cx + ' ' + rotateBBox.cy);

$rotateElement.animate({ transform: 'R' + -60 + ' ' + self.getOriginX(rotateBBox, 'center') + ' ' + self.getOriginY(rotateBBox, 'top')}, 400, mina['easeOutBack'], function() {
$rotateElement.animate({ transform: 'R' + 30 + ' ' + self.getOriginX(rotateBBox, 'center') + ' ' + self.getOriginY(rotateBBox, 'top')}, 400, mina['easeOutBack'], function() {
  $rotateElement.animate({ transform: 'R' + 0 + ' ' + self.getOriginX(rotateBBox, 'center') + ' ' + self.getOriginY(rotateBBox, 'top')}, 400, mina['easeInOutBack']);
});
});

translate的补间类似于scale以及rotate,但是有一个关键的区别。因为translate动画并不立即开始,我们使用setTimeout设置了400毫秒的开始延时。

// Translate Tween
var $translateElement = $zipper.select('.translate');
$translateElement.transform('T' + 110 + ' ' + 0);

setTimeout(function() {
$translateElement.animate({ transform: 'T' + 0 + ' ' + 0 }, 600, mina['easeOutQuint']);
}, 400);

关键帧是关键

关于这一点,你可能会想,“这么简单的一个动画就相当这么复杂了。”我们不会反对你的观点。

我们的目标是创建一个数据驱动程序,快速到达原型动画。通过创建的单独的补间类以及关于关键帧的概念介绍,我们可以书写如下的代码...

// Translate Tween
var $translateElement = $zipper.select('.translate');
$translateElement.transform('T' + 110 + ' ' + 0);

setTimeout(function() {
    $translateElement.animate({ transform: 'T' + 0 + ' ' + 0 }, 600, mina['easeOutQuint']);
}, 400);

...像这样:

// Translate Tween
new svgTween({
    element: $zipper.select('.translate'),
    keyframes: [
        {
            "step": 2,
            "x": 110,
            "y": 0
        },

        {
            "step": 5,
            "x": 0,
            "y": 0,
            "easing": "easeOutQuint"
        }
    ],
    duration: 2000/10
});

将每一个动画分解为单一的步骤,让我们看看这种格式如何使成型更加容易。分解位移补间的参数并思考这些数字的来源。

在原始的代码中,你可能已经注意到持续时间以及延迟时间都可以被200ms整除。这不是一个巧合。如果整个动画持续2000ms并包括10个步骤,我们只需要由后者来划分前者计算某个单一步骤的持续时间。现在我们可以使用相同的逻辑思考为什么关键帧始于步骤2止于步骤5。初始的400ms延迟的setTimeout对应于这两个步骤。此外,动画的持续时间为600ms,这被计算为三个步骤,步骤2到步骤5

svgTween: Translate

随着定义的小黑箱的输出,让我们书写SVGTween类的功能。使用和SVGAnimation相同的模式,我们可以很快书写出一个基本的框架。

/*
svgTween.js v1.0.0
Licensed under the MIT license.
http://www.opensource.org/licenses/mit-license.php

Copyright 2015, Smashing Magazine
http://www.smashingmagazine.com/
http://www.hellomichael.com/
*/

; (function(window) {
    'use strict';

    var svgTween = function (options) {
        var self = this;
        self.options = extend({}, self.options);
        extend(self.options, options);
        self.init();
    };

    svgTween.prototype = {
        constructor: svgTween,

        options: {
            element:    null,
            keyframes:  null,
            duration:   null
        },

        init: function () {
            var self = this;
        }
    };

    /*
      Merges two objects
      @param {Object} a
      @param {Object} b
      @return {Object} sum
      http://stackoverflow.com/questions/11197247/javascript-equivalent-of-jquerys-extend-method
    */
    function extend(a, b) {
        for (var key in b) {
            if (b.hasOwnProperty(key)) {
                a[key] = b[key];
            }
        }

        return a;
    }

    // Add to namespace
    window.svgTween = svgTween;
})(window);

使用之前的算法,设置动画的初始隐藏状态,之后进行动画处理。不使用Snap.svg的transform以及animate函数,我们重写了resetTween以及playTween来处理关键帧。

resetTween会接收一个元素以及一个关键帧数组。唯一的区别在于,不是直接设置变形字符串,我们使用第一个关键帧中的值。

/*
Resets the animation to the first keyframe

@param {Object} element
@param {Array}  keyframes
*/
resetTween: function (element, keyframes) {
    var self = this;

    var translateX = keyframes[0].x;
    var translateY = keyframes[0].y;

    element.transform('T' + translateX + ',' + translateY);
}

因为Snap.svg不提供链式的动画方法,我们必须为连续动画设置回调函数。

Snap.animation(attr, duration, [easing], [callback]);

然而,如果多于两个关键帧,这一瞬间就会变的不规则,本质上是把我们送入回调函数的一种方式。处理这个问题,我们将playTween作为一个递归函数,允许我们不进行嵌套就可以进行动画循环。

首先定义动画的参数。在resetTween中将变形字符串设置为关键帧值。过渡延缓已多次使用这种方式。持续时间设置为导致第一个动画暂停或者两个步骤之间计算的时间跨度。

/*
Recursively loop through keyframes to create pauses or tweens

@param {Object} element
@param {Array}  keyframes
@param {Int}    duration
@param {Int}    index
*/
playTween: function(element, keyframes, duration, index) {
    var self = this;

    // Set keyframes we’re transitioning to
    var translateX = keyframes[index].x;
    var translateY = keyframes[index].y;

    // Set easing parameter
    var easing = mina[keyframes[index].easing];

    // Set duration as an initial pause or the difference of steps between keyframes
    var newDuration = index ? ((keyframes[index].step - keyframes[(index-1)].step) * duration) : (keyframes[index].step * duration);
}

准备好参数后,对暂停的条件语句进行书写,暂停或者终止动画。第一个条件语句用于判断动画是否立即开始于步骤0。如果是的话,继续进行,因为变形函数已经处理了第一个关键帧。如果我们试图使用resetTween进行相同值的动画处理,有时会发现产生一个短暂的闪动,这是后期发现的一个bug。之后两个条件语句用于检测是否需要暂停动画或者开始补间的播放。需要注意的一点是使用的嵌套条件语句,检查递归函数是否应该再次启用。没有相关说明,playTween会无终止的运行。

// Play first tween immediately if starts on step 0
if (index === 0 && keyframes[index].step === 0) {
    self.playTween(element, keyframes, duration, (index + 1));
}

// Or pause tween if initial keyframe
else if (index === 0 && keyframes[index].step !== 0) {
    setTimeout(function() {
        if (index !== (keyframes.length - 1)) {
            self.playTween(element, keyframes, duration, (index + 1));
        }
    }, newDuration);
}

// Or animate tweens if keyframes exist
else {
    element.animate({
        transform: 'T' + translateX + ' ' + translateY
    }, newDuration, easing, function() {
        if (index !== (keyframes.length - 1)) {
            self.playTween(element, keyframes, duration, (index + 1));
        }
    });
}

最后一个步骤就是更新init函数,对resetTween以及playTween进行调用。

init: function () {
    var self = this;

    self.resetTween(self.options.element, self.options.keyframes);
    self.playTween(self.options.element, self.options.keyframes, self.options.duration, 0);
}

svgTween: Rotation And Scale

现在,我们的拉链可以从右侧移动到左侧,组合使用了旋转以及缩放。现在修改选项使其包含typeoriginX以及originY。因为svgTween会处理所有的变形,这里会包含一个变量用于处理对象的声明。我们也将跟踪originX以及originY来设置正确的用于缩放和旋转的transform-origin。变形永远不会受到transform-origin的影响,所以默认设置为center center

options: {
    element:    null,
    type:       null,
    keyframes:  null,
    duration:   null,
    originX:    null,
    originY:    null
}

更新resetTween以及playTween处理这些新值。首先检查类型,然后构造各自的变形字符串。我们将创建单独的translateXtranslateYrotationAnglescaleX以及scaleY变量,这样就可以观察变形字符串是如何生成的。

/*
Resets the animation to the first keyframe

@param {Object} element
@param {String} type - "scale", "rotate", "translate"
@param {Array}  keyframes
@param {String} originX - "left", "right", "center"
@param {String} originY - "top", "bottom", "center"
*/
resetTween: function (element, type, keyframes, originX, originY) {
    var transform, translateX, translateY, rotationAngle, scaleX, scaleY;

    if (type === 'translate') {
        translateX = keyframes[0].x;
        translateY = keyframes[0].y;
        transform = 'T' + translateX + ' ' + translateY;
    }

    else if (type === 'rotate') {
        rotationAngle = keyframes[0].angle;
        transform = 'R' + rotationAngle + ' ' + originX + ' ' + originY;
    }

    else if (type === 'scale') {
        scaleX = keyframes[0].x;
        scaleY = keyframes[0].y;
        transform = 'S' + scaleX + ' ' + scaleY + ' ' + originX + ' ' + originY;
    }

    element.transform(transform);

playTween中进行相同模式的模仿,在递归函数中取代相关的索引。我们也会使用新typeoriginX以及originY参数进行function回调更新。

/*
Recursively loop through keyframes to create pauses or tweens

@param {Object} element
@param {String} type - "scale", "rotate", "translate"
@param {Array}  keyframes
@param {String} originX - "left", "right", "center"
@param {String} originY - "top", "bottom", "center"
@param {Int}    duration
@param {Int}    index
*/
playTween: function(element, type, keyframes, originX, originY, duration, index) {
var self = this;

// Set keyframes we're transitioning to
var transform, translateX, translateY, rotationAngle, scaleX, scaleY;

if (type === 'translate') {
    translateX = keyframes[index].x;
    translateY = keyframes[index].y;
    transform = 'T' + translateX + ' ' + translateY;
}

else if (type === 'rotate') {
    rotationAngle = keyframes[index].angle;
    transform = 'R' + rotationAngle + ' ' + originX + ' ' + originY;
}

else if (type === 'scale') {
    scaleX = keyframes[index].x;
    scaleY = keyframes[index].y;
    transform = 'S' + scaleX + ' ' + scaleY + ' ' + originX + ' ' + originY;
}

// Set easing parameter
var easing = mina[keyframes[index].easing];

// Set duration as an initial pause or the difference of steps between keyframes
var newDuration = index ? ((keyframes[index].step - keyframes[(index-1)].step) * duration) : (keyframes[index].step * duration);

// Skip first tween if animation immediately starts on step 0
if (index === 0 && keyframes[index].step === 0) {
    self.playTween(element, type, keyframes, originX, originY, duration, (index + 1));
}

// Or pause tween if initial keyframe
else if (index === 0 && keyframes[index].step !== 0) {
    setTimeout(function() {
        if (index !== (keyframes.length - 1)) {
            self.playTween(element, type, keyframes, originX, originY, duration, (index + 1));
        }
    }, newDuration);
}

// Or animate tweens if keyframes exist
else {
    element.animate({
        transform: transform
    }, newDuration, easing, function() {
        if (index !== (keyframes.length - 1)) {
            self.playTween(element, type, keyframes, originX, originY, duration, (index + 1));
        }
    });
}
}

最后,在调用resetTween以及playTween之前,更新init函数设置typeoriginXoriginY。我们可以对传入元素进行类名处理,从而设置type。在这一点,可以在SVGAnimation进行getOriginX以及getOriginY的转移。然后使用三元操作符进行原点设置,如果值不确定,默认为center

init: function () {
    var self = this;

    // Set type
    self.options.type = self.options.element.node.getAttributeNode('class').value;

    // Set bbox to specific transform element (.translate, .scale, .rotate)
    var bBox = self.options.element.getBBox();

    // Set origin as specified or default to center
    self.options.originX = self.options.keyframes[0].cx ? self.getOriginX(bBox, self.options.keyframes[0].cx) : self.getOriginX(bBox, 'center');
    self.options.originY = self.options.keyframes[0].cy ? self.getOriginY(bBox, self.options.keyframes[0].cy) : self.getOriginY(bBox, 'center');

    // Reset and play tween
    self.resetTween(self.options.element, self.options.type, self.options.keyframes, self.options.originX, self.options.originY);
    self.playTween(self.options.element, self.options.type, self.options.keyframes, self.options.originX, self.options.originY, self.options.duration, 0);
}

让我们通过补间旋转和缩放的实例化完成拉链的动画效果。使用translate,可以通过动画步骤的总数量以及动画的总长度计算出关键帧和持续时间。现实中,更多的是有机的定义了所有这些参数:通过查看动画的进展以及不断微调的数字。

// Rotate tween
new svgTween({
    element: $zipper.select('.rotate'),
    keyframes: [
        {
            "step": 0,
            "angle": 45,
            "cy": "top"
        },

        {
            "step": 2,
            "angle": -60,
            "easing": "easeOutBack"
        },

        {
            "step": 4,
            "angle": 30,
            "easing": "easeOutQuint"
        },

        {
            "step": 6,
            "angle": 0,
            "easing": "easeOutBack"
        }
    ],
    duration: duration
});

// Scale tween
new svgTween({
    element: $zipper.select('.scale'),
    keyframes: [
        {
            "step": 0,
            "x": 0,
            "y": 0,
            "cy": "top"
        },

        {
            "step": 2,
            "x": 1,
            "y": 1,
            "easing": "easeOutBack"
        }
    ],
    duration: duration
});

JSON配置

构建的最后一步是从SVGAnimation中提取硬编码值并将其添加到我们的构造函数中。在实例中添加关键帧,持续时间以及steps的数量。

(function() {
    var backpack = new svgAnimation({
        canvas:       new Snap('#canvas'),
        svg:          'svg/backpack.svg',
        data:         'json/backpack.json',
        duration:     2000,
        steps:        10
    });
})();

通过传入一个JSON文件定义关键帧,设计师可以无需潜入文档创建原型。事实上,如果你使用GSAP,Mo.js或者Web Animation API来替换Snap.svg,这一概念可能就是完全库独立。

JSON文件格式化为单独的补间,包括ID元素以及关键帧。我们使用拉链动画作为示例,但是backpack.json文件包括所有元素的数组(拉链、口袋、标志等)。

{
    "animations": [
        {
            "id": "#zipper",
            "keyframes": {
                "translateKeyframes": [
                    {
                        "step": 6,
                        "x": 110,
                        "y": 0
                    },

                    {
                        "step": 9,
                        "x": 0,
                        "y": 0,
                        "easing": "easeOutQuint"
                    }
                ],

                "rotateKeyframes": [
                    {
                        "step": 4,
                        "angle": 45,
                        "cy": "top"
                    },

                    {
                        "step": 6,
                        "angle": -60,
                        "easing": "easeOutBack"
                    },

                    {
                        "step": 8,
                        "angle": 30,
                        "easing": "easeOutQuint"
                    },

                    {
                        "step": 10,
                        "angle": 0,
                        "easing": "easeOutBack"
                    }
                ],

                "scaleKeyframes": [
                    {
                        "step": 4,
                        "x": 0,
                        "y": 0,
                        "cy": "top"
                    },

                    {
                        "step": 6,
                        "x": 1,
                        "y": 1,
                        "easing": "easeOutBack"
                    }
                ]
            }
        }
    ]
}

options: {
    data:                 null,
    canvas:               null,
    svg:                  null,
    duration:             null,
    steps:                null
}

关于加载JSON文件的相关细节已经超出了本文的讲述范围。重点在于回调函数返的使用,返回JSON数据以供后期使用 - 在本例中,将动画数组传递给loadSVG

/*
Get JSON data and populate options
@param {Object}   data
@param {Function} callback
*/
loadJSON: function(data, callback) {
    var self = this;

    // XML request
    var xobj = new XMLHttpRequest();
    xobj.open('GET', data, true);

    xobj.onreadystatechange = function() {
        // Success
        if (xobj.readyState === 4 && xobj.status === 200) {
            var json = JSON.parse(xobj.responseText);

            if (callback && typeof(callback) === "function") {
                callback(json);
            }
        }
    };

    xobj.send(null);
}

现在使用animation数组可以实现loadSVG的循环更新,以动态创建svgTweens。如果translateKeyframesrotateKeyframes或者scaleKeyframes其中一者被定义,从options文件获取关键帧以及持续时间进行传递,从而实例化一个新的svgTween

loadSVG: function(canvas, svg, animations, duration) {
    var self = this;

    Snap.load(svg, function(data) {
        // Placed SVG into the DOM
        canvas.append(data);

        // Create tweens for each animation
        animations.forEach(function(animation) {
            var element = canvas.select(animation.id);

            // Create scale, rotate and transform groups around an SVG node
            self.createTransformGroup(element);

            // Create tween based on keyframes
            if (animation.keyframes.translateKeyframes) {
                self.options.tweens.push(new svgTween({
                    element: element.select('.translate'),
                    keyframes: animation.keyframes.translateKeyframes,
                    duration: duration
                }));
            }

            if (animation.keyframes.rotateKeyframes) {
                self.options.tweens.push(new svgTween({
                    element: element.select('.rotate'),
                    keyframes: animation.keyframes.rotateKeyframes,
                    duration: duration
                }));
            }

            if (animation.keyframes.scaleKeyframes) {
                self.options.tweens.push(new svgTween({
                    element: element.select('.scale'),
                    keyframes: animation.keyframes.scaleKeyframes,
                    duration: duration
                }));
            }
        });
    });
}

最后,调用loadJSON更新init函数,返回来又调用loadSVG。完美的结束了整个教程。

init: function() {
    var self = this;

    self.loadJSON(self.options.data, function (data) {
        self.loadSVG(self.options.canvas, self.options.svg, data.animations, (self.options.duration/self.options.steps));
    });
}

关于性能

我们的目标是看SVG动画能够走多远;所以我们青睐动画的逼真度多于动画的性能。立足于这一点是因为可以比预期更好地推进动画。但是我们不能完全的忽略性能。

查看Chrome DevTools时间轴,可以看出动画以每帧60ms稳定播放。将背包动画进行分解,有19个元素 以及3个可能的变形。意味着,最糟糕情况下,一次可能出现57个补间。幸运的是,这种情况不会出现,因为动画的补间在生存期是交错的。在CPU图表中可以看出,它们的用法稳步加大,重叠区域的动画达到了峰值,之后每个补间随之结束。视觉上,Firefox以及Internet Explorer在性能播放方面没有显著的差异。

desktop-performance-opt

图10. Chrome DevTools时间轴,展示CPU用法以及桌面帧率。

不出所料,移动设备的性能大大受挫。在旧的Android设备上进行远程调试,我们的帧率每秒下降至60,在30 ~ 60之间徘徊。虽然不是很完美,对于我们的需求而言还是可以接受的。不过还有一线希望,因为在iPhone5以及iPhone6上的最新测试表现堪称完美。

mobile-performance-opt

图11. Android远程调试,手机性能表现较差。

接下来是什么?

不幸的是,这场竞选在没有完成之前就已经结束了,所以我们没有机会深入了解该项目。所提供的源代码也不是完整就绪的;我们本来希望可以解决以下几个关键问题的。

事件驱动

Codepen提供了一个“return”按钮,但是实现不是基于事件驱动的。理想情况下,动画不会立即回放,除非由某种类型的互动(点击鼠标,航点等等)引发。

移动设备

如前所述,虽然这些动画可以在移动设备上运行,但是处理器所承担的代价是沉重的。考虑到项目整体设计的重要性。排除它们就可以大大提高性能并节约文件大小。如果不是绝对的必要性,需要长远的考虑如何使移动视区变得更加灵敏。

向后兼容

动画解决方案在所有现代浏览器均可运行,已经在Internet Explorer 9+,Firefox以及Chrome测试过。这主要是由于Snap.svg的支持。如果你的项目需要得到旧版浏览器的支持可以考虑使用Snap.svg的上一个版本 - Raphael。更易于访问的方法是使用渐进增强,提供一个最初的静态SVG,之后再为一些新版浏览器添加动画。

结尾

现在完成了简单插图到复杂动画的制作。你可以在GitHub下载完整的代码。

backpack-animation

本文根据@Michael Ngo的《The Illusion Of Life: An SVG Animation Case Study》所译,整个译文带有我们自己的理解与思想,如果译得不好或有不对之处还请同行朋友指点。如需转载此译文,需注明英文出处:https://www.smashingmagazine.com/2016/07/an-svg-animation-case-study/

静子

在校学生,本科计算机专业。一个积极进取、爱笑的女生,热爱前端,喜欢与人交流分享。想要通过自己的努力做到心中的那个自己。微博:@静-如秋叶

如需转载,烦请注明出处:http://www.w3cplus.com/svg/an-svg-animation-case-study.html

返回顶部