Meteor 動畫

2022-06-30 14:00 更新

動畫

我們現(xiàn)在有了實時的投票、評分和排名。然而,由于帖子在首頁上跳來跳去,導(dǎo)致了跳動不穩(wěn)的用戶體驗。我們用動畫來平滑這種過渡。

介紹 _uihooks

_uihooks 相對較新,Blaze 文檔也未包含該特性。正如其名稱所示,它提供了每當(dāng)插入、刪除或動畫元素時可以被觸發(fā)的 hooks。

Hooks 的全部清單如下:

  • insertElement: 當(dāng)新元素被插入時調(diào)用。
  • moveElement: 當(dāng)元素被移動時調(diào)用。
  • removeElement: 當(dāng)元素被刪除時調(diào)用。

一旦定義,這些 hooks 就會替代 Meteor 的默認(rèn)行為。換句話說,Meteor 會用我們規(guī)定的行為來替代默認(rèn)的插入、移動或刪除元素的行為 ———— 這由我們來確定這行為會真正地工作!

Meteor 與 DOM

在我們開始有趣部分(使東西移動)之前,我們需要理解 Meteor 如何與 DOM(Document Object Model————組成頁面內(nèi)容的 HTML 元素集合)交互的。

要記住的最關(guān)鍵的一點是,DOM 元素不能真正被“移動”;但是,它們可以被刪除,被創(chuàng)建(注意,這是 DOM 本身的限制,而不是 Meteor 的)。所以要給元素 A 和 B 互換位置的錯覺,Meteor 實際上會刪除元素 B,并在元素 A 前插入一個全新的副本(B')。

這使得動畫有點麻煩,因為我們不能只是把 B 動畫移動到新位置,因為 B 在 Meteor 重新渲染頁面時就會消失(由于響應(yīng)性,這瞬間發(fā)生)。但請不要擔(dān)心,我們會找到一個解決辦法。

蘇聯(lián)賽跑者

不過首先,讓我們講個故事。

在 1980 年,正值冷戰(zhàn)。奧運會正在莫斯科舉行,蘇聯(lián)決心不惜任何代價要贏得 100 米短跑的金牌。所以,一群聰明的蘇聯(lián)科學(xué)家為其中一名運動員裝備了一臺傳送器,只要槍聲一響,那名運動員就會瞬間消失,通過時空連續(xù)的作用直接出現(xiàn)在終點線上。

還好,賽事官員立刻注意到了這個違規(guī)行為,這名運動員沒有辦法只好又瞬時移動回到起跑器上,才能被允許像其他選手一樣賽跑參賽。

我的歷史資料沒有那么可靠,所以你應(yīng)該對這個故事半信半疑。但是,盡量嘗試記住“有傳送器的蘇聯(lián)賽跑者”這個比喻,我們要在這一章中用到這一點。

分解

當(dāng) Meteor 接收到更新并實時地更改 DOM 時,我們的帖子會立即傳送到它的終點位置,就像蘇聯(lián)賽跑者一樣。但是不論是在奧運會還是在我們的應(yīng)用中,我們不能瞬移任何東西。所以我們需要把元件傳送回到“起跑器”上,使它“跑”(換句話說,“動畫”它)到終點。

所以交換帖子 A 和 B (分別位于 p1 和 p2 位置),我們會經(jīng)過如下步驟:

  1. 刪除 B
  2. 在 DOM 中,在 A 之前創(chuàng)建 B'
  3. 傳送 B' 到 p2 位置
  4. 傳送 A 到 p1 位置
  5. 動畫 A 到 p2 位置
  6. 動畫 B' 到 p1 位置

下面圖表詳細(xì)解釋上述步驟:

再次說明,第 3 、4 步中,我們沒有動畫 A 和 B' 到它們的位置,而是瞬間“傳送”了它們。因為這是瞬間發(fā)生的,這會產(chǎn)生 B 沒有被刪除的幻覺,并且兩個元素被動畫到了它們的新位置。

默認(rèn)情況下,Meteor 負(fù)責(zé)步驟 1 和 2,我們自己很容易重新實施它們。在步驟 5 和 6 中所有我們在做的事情是移動元素到正確的位置。因此,唯一我們真正需要擔(dān)心的部分是步驟 3 和 4,即,發(fā)送元素到動畫的起點。

CSS 定位

為了在頁面中動畫渲染的帖子,我們必須用到 CSS 樣式。讓我們按順序快速瀏覽 CSS 定位。

頁面元素默認(rèn)使用靜態(tài)定位。靜態(tài)定位的元素適應(yīng)頁面內(nèi)容流,它們在屏幕上的坐標(biāo)不能更改或動畫。

另一方面,相對定位是說元素也同樣適應(yīng)頁面內(nèi)容流,但是可以相對于原始位置進(jìn)行定位。

絕對定位更進(jìn)一步,允許你規(guī)定元素的 x/y 坐標(biāo),坐標(biāo)相對于文檔第一個絕對或相對定位的父元素。

我們使用相對定位來動畫我們的帖子。我們已經(jīng)為你準(zhǔn)備好了 CSS,你需要做的就是將代碼添加到你的樣式表中:

.post{
  position:relative;
}
.post.animate{
  transition:all 300ms 0ms ease-in;
}

注意我們只動畫有 .animate CSS 的帖子。通過添加或刪除 CSS 名來控制是否添加動畫效果。

這使步驟 5 和 6 變得簡單:我們需要做的是重置 top 坐標(biāo)值為 0px(默認(rèn)值),帖子就會回到它們“正常的”位置。

基本上,我們僅有的挑戰(zhàn)是搞明白元素要從相對于它們新位置的哪里開始動畫(步驟 3 和 4),換句話說,它們要偏移多少。但這也不難:正確的偏移量就是帖子的原來位置減去它的新位置。

使用 _uihooks

既然我們了解了為帖子列表添加動畫的各種因素,我們算是準(zhǔn)備好開始添加動畫了。我們首先需要把帖子列表放入一個新的 .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,并定義一個 moveElement 的 hook。

Template.postsList.onRendered(function () {
  this.find('.wrapper')._uihooks = {
    moveElement: function (node, next) {
      // 現(xiàn)在不做任何事情
    }
  }
});

剛剛定義的 moveElement 會在元素位置改變時被調(diào)用,從而取代 Blaze 的默認(rèn)行為。由于現(xiàn)在這個函數(shù)還是空的,意味著什么都不會發(fā)生。

去試一下:打開“Best”最佳帖子頁面,給一些帖子投票:帖子排序不會發(fā)生變化,除非強(qiáng)制刷新(刷新頁面或改變路徑)。

我們已經(jīng)驗證 _uihooks 可以工作,現(xiàn)在讓我們來動畫它!

帖子排序的動畫效果

moveElement hook 接受兩個參數(shù):nodenext。

  • node 是當(dāng)前正在移動到新位置的 DOM 元素
  • nextnode 移動的新位置之后的元素

了解這些之后,我們可以逐一實現(xiàn)如下動畫過程(如果你需要刷新一下你的記憶,可參考之前“蘇聯(lián)賽跑者”的例子)。當(dāng)一個新的位置改變發(fā)生時,我們將:

  1. next 前插入 node(換句話說,如果我們沒有指定任何 moveElement hook 的話,默認(rèn)行為就會發(fā)生)。
  2. 移動 node 回到它的起始位置。
  3. 微調(diào) nodenext 之間的每個元素,為 node 騰出空間。
  4. 動畫所有元素回到它們的新默認(rèn)位置。

我們通過 jQuery 的魔力來做這些事情,這也是迄今為止最好的操作 DOM 的 JavaScript 庫。jQuery 已經(jīng)超出本書范圍,但是讓我們快速瀏覽一下我們即將用到的 jQuery 方法:

  • $():使任何一個 DOM 元素成為 jQuery 對象。
  • offset():取得元素相對于文檔的當(dāng)前位置,返回包含 topleft 屬性的對象。
  • outerHeight():取得“outer”元素的高度(包括 padding 和可選的 margin)。
  • nextUntil(selector):取得所有目標(biāo)元素之后到(但不包含)匹配 selector 的元素。
  • insertBefore(selector):在匹配 selector 的元素之前插入另一個元素。
  • 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();

      // 動畫,重置所有元素的 top 坐標(biāo)為 0
      $node.addClass('animate').css('top', 0);
      $inBetween.addClass('animate').css('top', 0);
    }
  }
});

注解:

  • 我們計算 $node 的高度,便于知道要偏移 $inBetween 的元素多少距離。我們使用 outerHeight(true) 使 margin 和 padding 加入計算中。
  • 在 DOM 中,我們不知道 next 是在 node 之前還是之后,所以我們在定義 $inBetween 時同時考慮這兩種情況。
  • 為了在“傳送 teleporting”和“動畫 animating”元素之間轉(zhuǎn)換,我們簡單地 toggle animate CSS 類(在 CSS 樣式表中定義了實際動畫)。
  • 由于我們用相對定位,所以我們總可以通過重置任何元素的 top 屬性值為 0 來把元素歸位到應(yīng)在位置。

強(qiáng)制 Redraw

你也許在想 $node.offset() 這行代碼。為什么我們不打算移動 $node,而去關(guān)心它的位置呢?

要這么想:如果你告訴一臺有完美邏輯的機(jī)器人向北奔跑 5 千米,跑完后再跑回起點,它也許認(rèn)為既然又回到起點,那么何不節(jié)省能量而待在原地。

所以為了確保機(jī)器人能跑完 10 千米,我們會告訴它在跑到 5 千米時記錄它的坐標(biāo)才能轉(zhuǎn)向。

瀏覽器以相似的方式工作:如果我們在同一時間只給出 css('top', oldTop - newTop)css('top', 0) 的話,新坐標(biāo)就會簡單地替換舊坐標(biāo),什么也不會發(fā)生。如果我們想真正地看到動畫,就需要強(qiáng)制瀏覽器去在元素改變位置后重新繪制它。

一個簡單的強(qiáng)制重繪的方法是讓瀏覽器檢查元素的 offset 屬性————再次重繪元素才能讓瀏覽器識別它。

讓我們再試一次。回到“Best”最佳帖子頁面,給帖子投票:現(xiàn)在應(yīng)該可以看到帖子如芭蕾舞般優(yōu)雅地上下滑動。

Can't Fade Me

既然我們已經(jīng)搞定比較難的重新排序,那么插入和刪除帖子的動畫就是小菜一碟了!

首先,我們漸入新帖子(注意為了簡單,我們在此用 JavaScript 動畫):

Template.postsList.onRendered(function () {
  this.find('.wrapper')._uihooks = {
    insertElement: function (node, next) {
      $(node)
        .hide()
        .insertBefore(next)
        .fadeIn();
    },
    moveElement: function (node, next) {
      //...
    }
  }
});

為了更好看到效果,我們通過控制臺插入新帖子,來測試動畫:

Meteor.call('postInsert', {url: 'http://apple.com', title: 'Testing Animations'})

其次,我們動畫淡出刪除的帖子:

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();
      });
    }
  }
});

再次,在控制臺(用 Posts.remove('somePostId'))刪除一個帖子來測試動畫效果。

頁面過渡

到目前為止,我們已經(jīng)在頁面內(nèi)動畫了元素。但是如果我們想添加頁面之間的過渡動畫呢?

頁面過渡是 Iron Router 的任務(wù)。點擊一個鏈接,{{> yield}} helper 的內(nèi)容自動地更換。

就像我們?yōu)樘恿斜砀淖?Blaze 默認(rèn)行為一樣,我們也可以為 {{> yield}} 做同樣的事情,在不同路由之間添加漸隱過渡動畫效果!

如果我們想漸入漸隱頁面,我們必須要確保它們在各自上方顯示。我們用添加了 position:absolute 屬性的 .page container div 來包裹每個頁面模板。

但不能相對于窗口來絕對定位我們的頁面,因為這樣頁面會覆蓋應(yīng)用的 header。所以我們給 #main div 添加 position:relative 以便 .page div 的 position:absolute 會得到其正確位置。

為了節(jié)省時間,我們已經(jīng)在 sytle.css 中添加了必要的 CSS 代碼:

//...

#main{
  position: relative;
}
.page{
  position: absolute;
  top: 0px;
  width: 100%;
}

//...

是時候添加頁面過渡代碼了。代碼看起來很熟悉,因為這和我們添加和刪除帖子時的代碼完全一致:

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)用添加動畫元素的模式。雖然這不是一個詳盡的清單,但是希望這會提供一個基礎(chǔ),在其上去構(gòu)建更復(fù)雜的過渡動畫。

以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號