我們現(xiàn)在有了實(shí)時(shí)的投票、評(píng)分和排名。然而,由于帖子在首頁上跳來跳去,導(dǎo)致了跳動(dòng)不穩(wěn)的用戶體驗(yàn)。我們用動(dòng)畫來平滑這種過渡。
_uihooks
相對(duì)較新,Blaze 文檔也未包含該特性。正如其名稱所示,它提供了每當(dāng)插入、刪除或動(dòng)畫元素時(shí)可以被觸發(fā)的 hooks。
Hooks 的全部清單如下:
insertElement
: 當(dāng)新元素被插入時(shí)調(diào)用。moveElement
: 當(dāng)元素被移動(dòng)時(shí)調(diào)用。removeElement
: 當(dāng)元素被刪除時(shí)調(diào)用。一旦定義,這些 hooks 就會(huì)替代 Meteor 的默認(rèn)行為。換句話說,Meteor 會(huì)用我們規(guī)定的行為來替代默認(rèn)的插入、移動(dòng)或刪除元素的行為 ———— 這由我們來確定這行為會(huì)真正地工作!
在我們開始有趣部分(使東西移動(dòng))之前,我們需要理解 Meteor 如何與 DOM(Document Object Model————組成頁面內(nèi)容的 HTML 元素集合)交互的。
要記住的最關(guān)鍵的一點(diǎn)是,DOM 元素不能真正被“移動(dòng)”;但是,它們可以被刪除,被創(chuàng)建(注意,這是 DOM 本身的限制,而不是 Meteor 的)。所以要給元素 A 和 B 互換位置的錯(cuò)覺,Meteor 實(shí)際上會(huì)刪除元素 B,并在元素 A 前插入一個(gè)全新的副本(B')。
這使得動(dòng)畫有點(diǎn)麻煩,因?yàn)槲覀儾荒苤皇前?B 動(dòng)畫移動(dòng)到新位置,因?yàn)?B 在 Meteor 重新渲染頁面時(shí)就會(huì)消失(由于響應(yīng)性,這瞬間發(fā)生)。但請(qǐng)不要擔(dān)心,我們會(huì)找到一個(gè)解決辦法。
不過首先,讓我們講個(gè)故事。
在 1980 年,正值冷戰(zhàn)。奧運(yùn)會(huì)正在莫斯科舉行,蘇聯(lián)決心不惜任何代價(jià)要贏得 100 米短跑的金牌。所以,一群聰明的蘇聯(lián)科學(xué)家為其中一名運(yùn)動(dòng)員裝備了一臺(tái)傳送器,只要槍聲一響,那名運(yùn)動(dòng)員就會(huì)瞬間消失,通過時(shí)空連續(xù)的作用直接出現(xiàn)在終點(diǎn)線上。
還好,賽事官員立刻注意到了這個(gè)違規(guī)行為,這名運(yùn)動(dòng)員沒有辦法只好又瞬時(shí)移動(dòng)回到起跑器上,才能被允許像其他選手一樣賽跑參賽。
我的歷史資料沒有那么可靠,所以你應(yīng)該對(duì)這個(gè)故事半信半疑。但是,盡量嘗試記住“有傳送器的蘇聯(lián)賽跑者”這個(gè)比喻,我們要在這一章中用到這一點(diǎn)。
當(dāng) Meteor 接收到更新并實(shí)時(shí)地更改 DOM 時(shí),我們的帖子會(huì)立即傳送到它的終點(diǎn)位置,就像蘇聯(lián)賽跑者一樣。但是不論是在奧運(yùn)會(huì)還是在我們的應(yīng)用中,我們不能瞬移任何東西。所以我們需要把元件傳送回到“起跑器”上,使它“跑”(換句話說,“動(dòng)畫”它)到終點(diǎn)。
所以交換帖子 A 和 B (分別位于 p1 和 p2 位置),我們會(huì)經(jīng)過如下步驟:
下面圖表詳細(xì)解釋上述步驟:
再次說明,第 3 、4 步中,我們沒有動(dòng)畫 A 和 B' 到它們的位置,而是瞬間“傳送”了它們。因?yàn)檫@是瞬間發(fā)生的,這會(huì)產(chǎn)生 B 沒有被刪除的幻覺,并且兩個(gè)元素被動(dòng)畫到了它們的新位置。
默認(rèn)情況下,Meteor 負(fù)責(zé)步驟 1 和 2,我們自己很容易重新實(shí)施它們。在步驟 5 和 6 中所有我們?cè)谧龅氖虑槭且苿?dòng)元素到正確的位置。因此,唯一我們真正需要擔(dān)心的部分是步驟 3 和 4,即,發(fā)送元素到動(dòng)畫的起點(diǎn)。
為了在頁面中動(dòng)畫渲染的帖子,我們必須用到 CSS 樣式。讓我們按順序快速瀏覽 CSS 定位。
頁面元素默認(rèn)使用靜態(tài)定位。靜態(tài)定位的元素適應(yīng)頁面內(nèi)容流,它們?cè)谄聊簧系淖鴺?biāo)不能更改或動(dòng)畫。
另一方面,相對(duì)定位是說元素也同樣適應(yīng)頁面內(nèi)容流,但是可以相對(duì)于原始位置進(jìn)行定位。
絕對(duì)定位更進(jìn)一步,允許你規(guī)定元素的 x/y 坐標(biāo),坐標(biāo)相對(duì)于文檔或第一個(gè)絕對(duì)或相對(duì)定位的父元素。
我們使用相對(duì)定位來動(dòng)畫我們的帖子。我們已經(jīng)為你準(zhǔn)備好了 CSS,你需要做的就是將代碼添加到你的樣式表中:
.post{
position:relative;
}
.post.animate{
transition:all 300ms 0ms ease-in;
}
注意我們只動(dòng)畫有 .animate
CSS 的帖子。通過添加或刪除 CSS 名來控制是否添加動(dòng)畫效果。
這使步驟 5 和 6 變得簡單:我們需要做的是重置 top
坐標(biāo)值為 0px
(默認(rèn)值),帖子就會(huì)回到它們“正常的”位置。
基本上,我們僅有的挑戰(zhàn)是搞明白元素要從相對(duì)于它們新位置的哪里開始動(dòng)畫(步驟 3 和 4),換句話說,它們要偏移多少。但這也不難:正確的偏移量就是帖子的原來位置減去它的新位置。
既然我們了解了為帖子列表添加動(dòng)畫的各種因素,我們算是準(zhǔn)備好開始添加動(dòng)畫了。我們首先需要把帖子列表放入一個(gè)新的 .wrapper
容器元素中:
<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>
現(xiàn)在讓我們加入 _uihooks
。在模板 onRendered
回調(diào)函數(shù)中,選擇 .wrapper
div,并定義一個(gè) moveElement
的 hook。
Template.postsList.onRendered(function () {
this.find('.wrapper')._uihooks = {
moveElement: function (node, next) {
// 現(xiàn)在不做任何事情
}
}
});
剛剛定義的 moveElement
會(huì)在元素位置改變時(shí)被調(diào)用,從而取代 Blaze 的默認(rèn)行為。由于現(xiàn)在這個(gè)函數(shù)還是空的,意味著什么都不會(huì)發(fā)生。
去試一下:打開“Best”最佳帖子頁面,給一些帖子投票:帖子排序不會(huì)發(fā)生變化,除非強(qiáng)制刷新(刷新頁面或改變路徑)。
我們已經(jīng)驗(yàn)證 _uihooks
可以工作,現(xiàn)在讓我們來動(dòng)畫它!
moveElement
hook 接受兩個(gè)參數(shù):node
和 next
。
node
是當(dāng)前正在移動(dòng)到新位置的 DOM 元素next
是 node
移動(dòng)的新位置之后的元素了解這些之后,我們可以逐一實(shí)現(xiàn)如下動(dòng)畫過程(如果你需要刷新一下你的記憶,可參考之前“蘇聯(lián)賽跑者”的例子)。當(dāng)一個(gè)新的位置改變發(fā)生時(shí),我們將:
next
前插入 node
(換句話說,如果我們沒有指定任何 moveElement
hook 的話,默認(rèn)行為就會(huì)發(fā)生)。node
回到它的起始位置。node
和 next
之間的每個(gè)元素,為 node
騰出空間。我們通過 jQuery 的魔力來做這些事情,這也是迄今為止最好的操作 DOM 的 JavaScript 庫。jQuery 已經(jīng)超出本書范圍,但是讓我們快速瀏覽一下我們即將用到的 jQuery 方法:
$()
:使任何一個(gè) DOM 元素成為 jQuery 對(duì)象。offset()
:取得元素相對(duì)于文檔的當(dāng)前位置,返回包含 top
和 left
屬性的對(duì)象。outerHeight()
:取得“outer”元素的高度(包括 padding 和可選的 margin)。nextUntil(selector)
:取得所有目標(biāo)元素之后到(但不包含)匹配 selector
的元素。insertBefore(selector)
:在匹配 selector
的元素之前插入另一個(gè)元素。removeClass(class)
:如果該元素有 class
CSS 類,刪除它。css(propertyName, propertyValue)
:設(shè)置 CSS propertyName
屬性為 propertyValue
。height()
:取得該元素的高度。addClass(class)
:為元素添加 class
CSS 類。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 放在預(yù)訂位置
$node.insertBefore(next);
// 測量新 top 偏移坐標(biāo)
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);
// 強(qiáng)制重繪
$node.offset();
// 動(dòng)畫,重置所有元素的 top 坐標(biāo)為 0
$node.addClass('animate').css('top', 0);
$inBetween.addClass('animate').css('top', 0);
}
}
});
注解:
$node
的高度,便于知道要偏移 $inBetween
的元素多少距離。我們使用 outerHeight(true)
使 margin 和 padding 加入計(jì)算中。next
是在 node
之前還是之后,所以我們?cè)诙x $inBetween
時(shí)同時(shí)考慮這兩種情況。animate
CSS 類(在 CSS 樣式表中定義了實(shí)際動(dòng)畫)。top
屬性值為 0 來把元素歸位到應(yīng)在位置。你也許在想 $node.offset()
這行代碼。為什么我們不打算移動(dòng) $node
,而去關(guān)心它的位置呢?
要這么想:如果你告訴一臺(tái)有完美邏輯的機(jī)器人向北奔跑 5 千米,跑完后再跑回起點(diǎn),它也許認(rèn)為既然又回到起點(diǎn),那么何不節(jié)省能量而待在原地。
所以為了確保機(jī)器人能跑完 10 千米,我們會(huì)告訴它在跑到 5 千米時(shí)記錄它的坐標(biāo)才能轉(zhuǎn)向。
瀏覽器以相似的方式工作:如果我們?cè)谕粫r(shí)間只給出 css('top', oldTop - newTop)
和 css('top', 0)
的話,新坐標(biāo)就會(huì)簡單地替換舊坐標(biāo),什么也不會(huì)發(fā)生。如果我們想真正地看到動(dòng)畫,就需要強(qiáng)制瀏覽器去在元素改變位置后重新繪制它。
一個(gè)簡單的強(qiáng)制重繪的方法是讓瀏覽器檢查元素的 offset
屬性————再次重繪元素才能讓瀏覽器識(shí)別它。
讓我們?cè)僭囈淮?。回到“Best”最佳帖子頁面,給帖子投票:現(xiàn)在應(yīng)該可以看到帖子如芭蕾舞般優(yōu)雅地上下滑動(dòng)。
既然我們已經(jīng)搞定比較難的重新排序,那么插入和刪除帖子的動(dòng)畫就是小菜一碟了!
首先,我們漸入新帖子(注意為了簡單,我們?cè)诖擞?JavaScript 動(dòng)畫):
Template.postsList.onRendered(function () {
this.find('.wrapper')._uihooks = {
insertElement: function (node, next) {
$(node)
.hide()
.insertBefore(next)
.fadeIn();
},
moveElement: function (node, next) {
//...
}
}
});
為了更好看到效果,我們通過控制臺(tái)插入新帖子,來測試動(dòng)畫:
Meteor.call('postInsert', {url: 'http://apple.com', title: 'Testing Animations'})
其次,我們動(dòng)畫淡出刪除的帖子:
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();
});
}
}
});
再次,在控制臺(tái)(用 Posts.remove('somePostId')
)刪除一個(gè)帖子來測試動(dòng)畫效果。
到目前為止,我們已經(jīng)在頁面內(nèi)動(dòng)畫了元素。但是如果我們想添加頁面之間的過渡動(dòng)畫呢?
頁面過渡是 Iron Router 的任務(wù)。點(diǎn)擊一個(gè)鏈接,{{> yield}}
helper 的內(nèi)容自動(dòng)地更換。
就像我們?yōu)樘恿斜砀淖?Blaze 默認(rèn)行為一樣,我們也可以為 {{> yield}}
做同樣的事情,在不同路由之間添加漸隱過渡動(dòng)畫效果!
如果我們想漸入漸隱頁面,我們必須要確保它們?cè)诟髯陨戏斤@示。我們用添加了 position:absolute
屬性的 .page
container div 來包裹每個(gè)頁面模板。
但不能相對(duì)于窗口來絕對(duì)定位我們的頁面,因?yàn)檫@樣頁面會(huì)覆蓋應(yīng)用的 header。所以我們給 #main
div 添加 position:relative
以便 .page
div 的 position:absolute
會(huì)得到其正確位置。
為了節(jié)省時(shí)間,我們已經(jīng)在 sytle.css
中添加了必要的 CSS 代碼:
//...
#main{
position: relative;
}
.page{
position: absolute;
top: 0px;
width: 100%;
}
//...
是時(shí)候添加頁面過渡代碼了。代碼看起來很熟悉,因?yàn)檫@和我們添加和刪除帖子時(shí)的代碼完全一致:
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();
});
}
}
});
我們剛剛看了一些為 Meteor 應(yīng)用添加動(dòng)畫元素的模式。雖然這不是一個(gè)詳盡的清單,但是希望這會(huì)提供一個(gè)基礎(chǔ),在其上去構(gòu)建更復(fù)雜的過渡動(dòng)畫。
更多建議: