---
title: 動畫
slug: animations
date: 0014/01/01
number: 14
points: 10
photoUrl: http://www.flickr.com/photos/ikewinski/8377615133/
photoAuthor: Mike Lewinski
contents: 看看當 Meteor 交替兩個 DOM 元素時幕后發生了什么。|學習重排帖子時如何加入動畫效果。|學習如何在插入和刪除帖子時加入動畫效果。|學習兩頁面之間的切換動畫
paragraphs: 58
---
我們現在有了實時的投票、評分和排名。然而,由于帖子在首頁上跳來跳去,導致了跳動不穩的用戶體驗。我們用動畫來平滑這種過渡。
### 介紹 `_uihooks`
`_uihooks` 相對較新,Blaze 文檔也未包含該特性。正如其名稱所示,它提供了每當插入、刪除或動畫元素時可以被觸發的 hooks。
Hooks 的全部清單如下:
- `insertElement`: 當新元素被插入時調用。
- `moveElement`: 當元素被移動時調用。
- `removeElement`: 當元素被刪除時調用。
一旦定義,這些 hooks 就會*替代* Meteor 的默認行為。換句話說,Meteor 會用我們規定的行為來替代默認的插入、移動或刪除元素的行為 ———— 這由我們來確定這行為會真正地工作!
### Meteor 與 DOM
在我們開始有趣部分(使東西移動)之前,我們需要理解 Meteor 如何與 DOM(Document Object Model————組成頁面內容的 HTML 元素集合)交互的。
要記住的最關鍵的一點是,DOM 元素不能真正被“移動”;但是,它們可以被刪除,被創建(注意,這是 DOM 本身的限制,而不是 Meteor 的)。所以要給元素 A 和 B 互換位置的錯覺,Meteor 實際上會刪除元素 B,并在元素 A 前插入一個全新的副本(B')。
這使得動畫有點麻煩,因為我們不能只是把 B 動畫移動到新位置,因為 B 在 Meteor 重新渲染頁面時就會消失(由于響應性,這瞬間發生)。但請不要擔心,我們會找到一個解決辦法。
### 蘇聯賽跑者
不過首先,讓我們講個故事。
在 1980 年,正值冷戰。奧運會正在莫斯科舉行,蘇聯決心不惜任何代價要贏得 100 米短跑的金牌。所以,一群聰明的蘇聯科學家為其中一名運動員裝備了一臺傳送器,只要槍聲一響,那名運動員就會瞬間消失,通過時空連續的作用直接出現在終點線上。
還好,賽事官員立刻注意到了這個違規行為,這名運動員沒有辦法只好又瞬時移動回到起跑器上,才能被允許像其他選手一樣賽跑參賽。
我的歷史資料沒有那么可靠,所以你應該對這個故事半信半疑。但是,盡量嘗試記住“有傳送器的蘇聯賽跑者”這個比喻,我們要在這一章中用到這一點。
### 分解
當 Meteor 接收到更新并實時地更改 DOM 時,我們的帖子會立即傳送到它的終點位置,就像蘇聯賽跑者一樣。但是不論是在奧運會還是在我們的應用中,我們不能瞬移任何東西。所以我們需要把元件傳送回到“起跑器”上,使它“跑”(換句話說,“動畫”它)到終點。
所以交換帖子 A 和 B (分別位于 p1 和 p2 位置),我們會經過如下步驟:
1. 刪除 B
2. 在 DOM 中,在 A 之前創建 B'
3. 傳送 B' 到 p2 位置
4. 傳送 A 到 p1 位置
5. 動畫 A 到 p2 位置
6. 動畫 B' 到 p1 位置
下面圖表詳細解釋上述步驟:
<%= diagram "animation_diagram", "兩個帖子換位", "pull-center" %>
再次說明,第 3 、4 步中,我們沒有*動畫* A 和 B' 到它們的位置,而是瞬間“傳送”了它們。因為這是瞬間發生的,這會產生 B 沒有被刪除的幻覺,并且兩個元素被動畫到了它們的新位置。
默認情況下,Meteor 負責步驟 1 和 2,我們自己很容易重新實施它們。在步驟 5 和 6 中所有我們在做的事情是移動元素到正確的位置。因此,唯一我們真正需要擔心的部分是步驟 3 和 4,即,發送元素到動畫的起點。
### CSS 定位
為了在頁面中動畫渲染的帖子,我們必須用到 CSS 樣式。讓我們按順序快速瀏覽 CSS 定位。
頁面元素默認使用**靜態**定位。靜態定位的元素適應頁面內容流,它們在屏幕上的坐標不能更改或動畫。
另一方面,**相對**定位是說元素也同樣適應頁面內容流,但是可以*相對于原始位置*進行定位。
**絕對**定位更進一步,允許你規定元素的 x/y 坐標,坐標相對于**文檔**或**第一個絕對或相對定位的父元素**。
我們使用相對定位來動畫我們的帖子。我們已經為你準備好了 CSS,你需要做的就是將代碼添加到你的樣式表中:
~~~css
.post{
position:relative;
}
.post.animate{
transition:all 300ms 0ms ease-in;
}
~~~
<%= caption "client/stylesheets/style.css" %>
注意我們只動畫有 `.animate` CSS 的帖子。通過添加或刪除 CSS 名來控制是否添加動畫效果。
這使步驟 5 和 6 變得簡單:我們需要做的是重置 `top` 坐標值為 `0px`(默認值),帖子就會回到它們“正常的”位置。
基本上,我們僅有的挑戰是搞明白元素要從相對于它們新位置的哪里開始動畫(步驟 3 和 4),換句話說,它們要偏移多少。但這也不難:正確的偏移量就是帖子的原來位置減去它的新位置。
### 使用 `_uihooks`
既然我們了解了為帖子列表添加動畫的各種因素,我們算是準備好開始添加動畫了。我們首先需要把帖子列表放入一個新的 `.wrapper` 容器元素中:
```html
<template name="postsList">
<div class="posts page">
<div class="wrapper">
{{#each posts}}
{{> postItem}}
{{/each}}
</div>
{{#if nextPath}}
<a class="load-more" href="{{nextPath}}">Load more</a>
{{else}}
{{#unless ready}}
{{> spinner}}
{{/unless}}
{{/if}}
</div>
</template>
```
<%= caption "client/templates/posts/posts_list.html" %>
<%= highlight "3,7" %>
在做其他事情之前,讓我們看看當前**沒有**動畫效果的帖子列表:
<%= gifscreenshot "14-1", "沒有動畫效果的帖子列表。" %>
現在讓我們加入 `_uihooks`。在模板 `onRendered` 回調函數中,選擇 `.wrapper` div,并定義一個 `moveElement` 的 hook。
```js
Template.postsList.onRendered(function () {
this.find('.wrapper')._uihooks = {
moveElement: function (node, next) {
// 現在不做任何事情
}
}
});
```
<%= caption "client/templates/posts/posts_list.js" %>
<%= highlight "1~7" %>
剛剛定義的 `moveElement` 會在元素位置改變時被調用,從而取代 Blaze 的默認行為。由于現在這個函數還是空的,意味著*什么都不會發生*。
去試一下:打開“Best”最佳帖子頁面,給一些帖子投票:帖子排序不會發生變化,除非強制刷新(刷新頁面或改變路徑)。
<%= gifscreenshot "14-2", "空的 moveElement 回調函數:什么也不會發生" %>
我們已經驗證 `_uihooks` 可以工作,現在讓我們來動畫它!
### 帖子排序的動畫效果
`moveElement` hook 接受兩個參數:`node` 和 `next`。
- `node` 是當前正在移動到新位置的 DOM 元素
- `next` 是 `node` 移動的新位置*之后*的元素
了解這些之后,我們可以逐一實現如下動畫過程(如果你需要刷新一下你的記憶,可參考之前“蘇聯賽跑者”的例子)。當一個新的位置改變發生時,我們將:
1. 在 `next` 前插入 `node`(換句話說,如果我們沒有指定任何 `moveElement` hook 的話,默認行為就會發生)。
2. 移動 `node` 回到它的起始位置。
3. 微調 `node` 和 `next` 之間的每個元素,為 `node` 騰出空間。
4. 動畫所有元素回到它們的新默認位置。
我們通過 [jQuery](http://jquery.com) 的魔力來做這些事情,這也是迄今為止最好的操作 DOM 的 JavaScript 庫。jQuery 已經超出本書范圍,但是讓我們快速瀏覽一下我們即將用到的 jQuery 方法:
- [`$()`](http://api.jquery.com/jQuery/):使任何一個 DOM 元素成為 jQuery 對象。
- [`offset()`](http://api.jquery.com/offset/):取得元素相對于*文檔*的當前位置,返回包含 `top` 和 `left` 屬性的對象。
- [`outerHeight()`](http://api.jquery.com/outerHeight/):取得“outer”元素的高度(包括 padding 和可選的 margin)。
- [`nextUntil(selector)`](http://api.jquery.com/nextUntil/):取得所有目標元素之后到(但不包含)匹配 `selector` 的元素。
- [`insertBefore(selector)`](http://api.jquery.com/insertBefore/):在匹配 `selector` 的元素之前插入另一個元素。
- [`removeClass(class)`](http://api.jquery.com/removeClass/):如果該元素有 `class` CSS 類,刪除它。
- [`css(propertyName, propertyValue)`](http://api.jquery.com/css/):設置 CSS `propertyName` 屬性為 `propertyValue`。
- [`height()`](http://api.jquery.com/height/):取得該元素的高度。
- [`addClass(class)`](http://api.jquery.com/addClass/):為元素添加 `class` CSS 類。
```js
Template.postsList.onRendered(function () {
this.find('.wrapper')._uihooks = {
moveElement: function (node, next) {
var $node = $(node), $next = $(next);
var oldTop = $node.offset().top;
var height = $node.outerHeight(true);
// 找出 next 與 node 之間所有的元素
var $inBetween = $next.nextUntil(node);
if ($inBetween.length === 0)
$inBetween = $node.nextUntil(next);
// 把 node 放在預訂位置
$node.insertBefore(next);
// 測量新 top 偏移坐標
var newTop = $node.offset().top;
// 將 node *移回*至原始所在位置
$node
.removeClass('animate')
.css('top', oldTop - newTop);
// push every other element down (or up) to put them back
$inBetween
.removeClass('animate')
.css('top', oldTop < newTop ? height : -1 * height);
// 強制重繪
$node.offset();
// 動畫,重置所有元素的 top 坐標為 0
$node.addClass('animate').css('top', 0);
$inBetween.addClass('animate').css('top', 0);
}
}
});
```
<%= caption "client/templates/posts/posts_list.js" %>
注解:
- 我們計算 `$node` 的高度,便于知道要偏移 `$inBetween` 的元素多少距離。我們使用 `outerHeight(true)` 使 margin 和 padding 加入計算中。
- 在 DOM 中,我們不知道 `next` 是在 `node` 之前還是之后,所以我們在定義 `$inBetween` 時同時考慮這兩種情況。
- 為了在“傳送 teleporting”和“動畫 animating”元素之間轉換,我們簡單地 toggle `animate` CSS 類(在 CSS 樣式表中定義了實際動畫)。
- 由于我們用相對定位,所以我們總可以通過重置任何元素的 `top` 屬性值為 0 來把元素歸位到應在位置。
<% note do %>
### 強制 Redraw
你也許在想 `$node.offset()` 這行代碼。為什么我們不打算移動 `$node`,而去關心它的位置呢?
要這么想:如果你告訴一臺有完美邏輯的機器人向北奔跑 5 千米,跑完后再跑回起點,它也許認為既然又回到起點,那么何不節省能量而待在原地。
所以為了確保機器人能跑完 10 千米,我們會告訴它在跑到 5 千米時記錄它的坐標才能轉向。
瀏覽器以相似的方式工作:如果我們在同一時間只給出 `css('top', oldTop - newTop)` 和 `css('top', 0)` 的話,新坐標就會簡單地替換舊坐標,什么也不會發生。如果我們想真正地看到動畫,就需要強制瀏覽器去在元素改變位置后重新繪制它。
一個簡單的強制重繪的方法是讓瀏覽器檢查元素的 `offset` 屬性————再次重繪元素才能讓瀏覽器識別它。
<% end %>
讓我們再試一次。回到“Best”最佳帖子頁面,給帖子投票:現在應該可以看到帖子如芭蕾舞般優雅地上下滑動。
<%= gifscreenshot "14-3", "帶動畫的排序" %>
<%= commit "14-1", "添加了帖子排序動畫。" %>
### Can't Fade Me
既然我們已經搞定比較難的重新排序,那么插入和刪除帖子的動畫就是小菜一碟了!
首先,我們漸入新帖子(注意為了簡單,我們在此用 JavaScript 動畫):
```js
Template.postsList.onRendered(function () {
this.find('.wrapper')._uihooks = {
insertElement: function (node, next) {
$(node)
.hide()
.insertBefore(next)
.fadeIn();
},
moveElement: function (node, next) {
//...
}
}
});
```
<%= caption "client/templates/posts/posts_list.js" %>
<%= highlight "3~7" %>
為了更好看到效果,我們通過控制臺插入新帖子,來測試動畫:
```js
Meteor.call('postInsert', {url: 'http://apple.com', title: 'Testing Animations'})
```
<%= gifscreenshot "14-4", "漸入新帖子" %>
其次,我們動畫淡出刪除的帖子:
```js
Template.postsList.onRendered(function () {
this.find('.wrapper')._uihooks = {
insertElement: function (node, next) {
$(node)
.hide()
.insertBefore(next)
.fadeIn();
},
moveElement: function (node, next) {
//...
},
removeElement: function(node) {
$(node).fadeOut(function() {
$(this).remove();
});
}
}
});
```
<%= caption "client/templates/posts/posts_list.js" %>
<%= highlight "12~16" %>
再次,在控制臺(用 `Posts.remove('somePostId')`)刪除一個帖子來測試動畫效果。
<%= gifscreenshot "14-5", "動畫淡出刪除的帖子" %>
<%= commit "14-2", "Fade items in when they are drawn." %>
### 頁面過渡
到目前為止,我們已經在頁面內動畫了元素。但是如果我們想添加頁面之間的過渡動畫呢?
頁面過渡是 Iron Router 的任務。點擊一個鏈接,`{{> yield}}` helper 的內容自動地更換。
就像我們為帖子列表改變 Blaze 默認行為一樣,我們也可以為 `{{> yield}}` 做同樣的事情,在不同路由之間添加漸隱過渡動畫效果!
如果我們想漸入漸隱頁面,我們必須要確保它們在各自上方顯示。我們用添加了 `position:absolute` 屬性的 `.page` container div 來包裹每個頁面模板。
但不能相對于窗口來絕對定位我們的頁面,因為這樣頁面會覆蓋應用的 header。所以我們給 `#main` div 添加 `position:relative` 以便 `.page` div 的 `position:absolute` 會得到其正確位置。
為了節省時間,我們已經在 `sytle.css` 中添加了必要的 CSS 代碼:
```css
//...
#main{
position: relative;
}
.page{
position: absolute;
top: 0px;
width: 100%;
}
//...
```
<%= caption "client/stylesheets/style.css" %>
是時候添加頁面過渡代碼了。代碼看起來很熟悉,因為這和我們添加和刪除帖子時的代碼完全一致:
```js
Template.layout.onRendered(function() {
this.find('#main')._uihooks = {
insertElement: function(node, next) {
$(node)
.hide()
.insertBefore(next)
.fadeIn();
},
removeElement: function(node) {
$(node).fadeOut(function() {
$(this).remove();
});
}
}
});
```
<%= caption "client/templates/application/layout.js" %>
<%= gifscreenshot "14-6", "頁面之間的過渡動畫" %>
<%= commit "14-3", "頁面間的漸隱過渡。" %>
我們剛剛看了一些為 Meteor 應用添加動畫元素的模式。雖然這不是一個詳盡的清單,但是希望這會提供一個基礎,在其上去構建更復雜的過渡動畫。