guest@blog.cmj.tw: ~/posts $

CSS Animation


回到前端工程師之路~

很久沒有改動 Blog 的 WUI 介面,這次改成支援 CSS 的 動畫功能 ,以及利用 JS 來做出選單物件,可以點取文章直接跳轉到相對的文章頁面。所以預計要有的功能如下:

  • 滑動式文章選單。類似方塊 (cube) 的方式左右選轉文章,並且可以上下滾動文章。
  • 提供有一個選單,列出目前所有的文章。支援點選單上的文章名稱,直接跳轉到相對的文章。

動畫

既然說到 CSS 動畫就不得不提到 HTML5 。HTML5 最大增進的功能在於增加 UX 上的表現。 可以利用額外提供的 TAG ,瀏覽器就可以搖身一變,成為一個 文字編輯器 甚至是 遠端桌面 。但是除了 HTML5 提供額外的 TAG 之外,CSS 以及更加豐富的 JS 也增加了不少的功能。 以 CSS 為例,過往需要利用才可以做到的動畫功能,已經可以利用 CSS 來模擬出來了。

以上是廢話:CSS 主要利用 transform、rotate 來做到變形與旋轉一個物件,並且用 transition 來做到簡單動畫的能力。transition 表示將一個物件的狀態,轉移到另一個狀態的意思。首先, 要做的事情就是撰寫一個 JavaScript 的邏輯:根據使用者的 I/O 來決定跳往下一個還是上一個文章。 一開始先實作旋轉的邏輯:根據順時針或逆時針來決定接下來的文章,該套用哪一個 CSS Style。 先掃所有的文章來獲得當下的文章 index,接下來根據方向來修改 style。

CSS 的部分跟上面提到的一樣,提供三種 Post 的樣式:前、當下、後三種樣式,分別表示將這三個文章作旋轉。

.content {
  /* Support 3-D CSS animation */
  -webkit-tansform-style: preserve-3d;
  -ms-tansform-style: preserve-3d;
  -o-tansform-style: preserve-3d;
  tansform-style: preserve-3d;

  -webkit-transition: transform 0.8s;
  -ms-transition: transform 0.8s;
  -o-transition: transform 0.8s;
  transition: transform 0.8s;
}

.prevPost {
  -webkit-transform: translateZ(-512px) rotateY(-90deg) translateZ(512px);
  -ms-transform: translateZ(-512px) rotateY(-90deg) translateZ(512px);
  -o-transform: translateZ(-512px) rotateY(-90deg) translateZ(512px);
  transform: translateZ(-512px) rotateY(-90deg) translateZ(512px);
}
.currPost {
  -webkit-transform: translateZ(-512px) rotateY(0deg) translateZ(512px);
  -ms-transform: translateZ(-512px) rotateY(0deg) translateZ(512px);
  -o-transform: translateZ(-512px) rotateY(0deg) translateZ(512px);
  transform: translateZ(-512px) rotateY(0deg) translateZ(512px);
}
.nextPost {
  -webkit-transform: translateZ(-512px) rotateY(90deg) translateZ(512px);
  -ms-transform: translateZ(-512px) rotateY(90deg) translateZ(512px);
  -o-transform: translateZ(-512px) rotateY(90deg) translateZ(512px);
  transform: translateZ(-512px) rotateY(90deg) translateZ(512px);
}

等到旋轉的邏輯實作好之後就替文章上 Event Listener 。我們需要替文章上兩種不同的事件 Hook: 方向鍵以及平版的滑動事件。可以直接對 document 下的事件 hook,當使用者按下任何按鈕的時候, 都會觸發到 onkeydown 的事件並執行我們的 anonymouns function 。其中我們只需要關心左方向鍵 (37) 跟右方向鍵 (39)。

/* Key-Event Listener */
document.onkeydown = function (key) {
  if (37 == key.keyCode) SlidePost(false);
  else if (39 == key.keyCode) SlidePost(true);
};

另一方面,也需要支援使用者直接利用平板滑動物件選擇上/下一個文章。先判斷目前的 browser 是否有支援 ontouch 系列的 evnet hook,當有支援之後我們就監聽 ontouchstart 跟 ontouchmove 兩個事件: ontouchstart - 監控當使用者碰觸螢幕的時候,紀錄當時的位子並且開始監聽 ontouchend 事件。 ontouchend - 監聽最後使用者手指移動的位子,當移動的位置超過設定的大小的時候,取消掉 ontouchend hook 並且換成相對應的文章。

/* Touch-Pad Slide event */
if ("ontouchstart" in document.documentElement || "ontouchstart" in window) {
  var posX = 0,
    posY = 0,
    isMove = false;
  var onTouchEnd = function (ev) {
      var deltaX = 0,
        deltaY = 0;

      if (isMove) {
        deltaX = ev.changedTouches[0].screenX - posX;
        deltaY = ev.changedTouches[0].screenY - posY;

        /* Remove the touchend hook if left/right with angle < 15 deg */
        if (0.0834 >= Math.abs(deltaY) / Math.abs(deltaX)) {
          CancleTouchEvent();
          if (deltaX < 0) SlidePost(true);
          else SlidePost(false);
        }
      }
    },
    CancleTouchEvent = function () {
      document.removeEventListener("touchend", onTouchEnd);
      isMove = false;
      posX = 0;
      posY = 0;
    };

  document.addEventListener(
    "touchstart",
    function (ev) {
      if (1 == ev.touches.length) {
        posX = ev.changedTouches[0].screenX;
        posY = ev.changedTouches[0].screenY;
        isMove = true;
      }
      document.addEventListener("touchend", onTouchEnd, false);
    },
    false,
  );
}

接著,動態的增加兩個箭頭的 Canvas 樣式,讓使用者想使用滑鼠的時候,點選箭頭來選擇上/下一個文章, 解決不想用滑鼠使用者。使用 HTML5 提供的 canvas 來畫出來簡單的箭頭樣式,這個新 TAG 可以根據要求, 畫出各種不同的幾何圖形 (現在使用兩條線來畫出箭頭符號)。然後再給這兩個 TAG 上 onclick 的 event hook, 這樣當使用者點擊之後,就可以根據方向來跳往前、後一個文章。

/* Create extra direct arrow if need, and always disable on mobile env */
if (!("ontouchstart" in document.documentElement)) {
  var arrowSize = [40, 330]; /* posX, posY */
  var offSize = [5, 10];

  /* Make sure the space is enough */
  if ($(window).width() > arrowSize[0] * 4 + $(".blog").outerWidth()) {
    for (var i = 0; i < 2; ++i) {
      var dom = document.createElement("canvas");
      var ctx = dom.getContext("2d");

      /* DOM Property */
      if (0 === i) {
        dom.id = "leftArrow";
      } else {
        dom.id = "rightArrow";
      }

      dom.classList.toggle("arrowWidget");
      dom.width = arrowSize[0] + offSize[0] * 4;
      dom.height = arrowSize[1];
      dom.style.marginTop = (-1 * arrowSize[1]) / 2 + "px";

      /* Canvas Property */
      ctx.strokeStyle = "#CCC";
      ctx.shadowColor = "#DEDEDE";
      ctx.shadowBlur = 20;
      ctx.shadowOffsetX = offSize[0];

      /* Draw line as arrow */
      ctx.moveTo(arrowSize[0] - offSize[0], offSize[1]);
      ctx.lineTo(offSize[0], arrowSize[1] / 2 - offSize[1] / 2);
      ctx.lineTo(arrowSize[0] - offSize[0], arrowSize[1] - offSize[1]);
      ctx.lineWidth = 6;
      ctx.stroke();

      dom.onclick = function (ev) {
        if ("leftArrow" === this.id) {
          SlidePost(false);
        } else if ("rightArrow" === this.id) {
          SlidePost(true);
        }
      };

      $("body")[0].appendChild(dom);
    }
  }
}

最後我們想要得到的功能,是將文章當成是 cube 的若干鉛直面,當下的文章為 front,前一個以及後一個文章, 分別是 left、right。當我們要跳往下一個文章的時候,left 的文章就變為 invisible,font 就變成 left, right 就變成 font,下下一個文章就轉換成 right。

function SlidePost(clockwise) {
  /* First, get the post contain, and get the current Post index */
  var blogs = $("#blogs")[0];
  var idx = 0,
    length = 0;

  length = blogs.children.length;
  for (idx = 0; idx < blogs.children.length; ++idx) {
    if (blogs.children[idx].classList.contains("currPost")) break;
  }

  if (
    !blogs.children[idx] ||
    !blogs.children[idx].classList.contains("currPost")
  ) {
    idx = 0;
    blogs.children[(idx + length - 1) % length].classList.toggle("prevPost");
    blogs.children[idx].classList.toggle("currPost");
    blogs.children[(idx + 1) % length].classList.toggle("nextPost");
    return;
  }

  if (clockwise) {
    blogs.children[(idx - 1 + length) % length].classList.toggle("prevPost");
    blogs.children[(idx - 1 + length) % length].classList.toggle("slideHidden");
    blogs.children[idx].classList.toggle("prevPost");
    blogs.children[idx].classList.toggle("currPost");
    blogs.children[(idx + 1 + length) % length].classList.toggle("currPost");
    blogs.children[(idx + 1 + length) % length].classList.toggle("nextPost");
    blogs.children[(idx + 2 + length) % length].classList.toggle("nextPost");
    blogs.children[(idx + 2 + length) % length].classList.toggle("slideHidden");
    idx = (idx + 1) % length;
  } else {
    blogs.children[(idx - 2 + length) % length].classList.toggle("slideHidden");
    blogs.children[(idx - 2 + length) % length].classList.toggle("prevPost");
    blogs.children[(idx - 1 + length) % length].classList.toggle("currPost");
    blogs.children[(idx - 1 + length) % length].classList.toggle("prevPost");
    blogs.children[idx].classList.toggle("currPost");
    blogs.children[idx].classList.toggle("nextPost");
    blogs.children[(idx + 1 + length) % length].classList.toggle("nextPost");
    blogs.children[(idx + 1 + length) % length].classList.toggle("slideHidden");
    idx = (idx - 1 + length) % length;
  }

  /* Back-to the page top */
  $("html, body").animate({ scrollTop: 0 }, 850);
}

利用兩個固定的 CSS class:PrevSlide、NextSlide 來表示跳往上一個還是下一個文章。而我們需要旋轉的, 就只是最外面的框架而已。

[EDIT] 在 IPAD mini / IPhone 6 測試的時候突然發現 layout 跑掉 (但是用 chrome debugger mobile mode 的時候不會有問題),原因是因為當 div 的 position 屬性是 absolute 的時候, margin 的特性會不一樣。用 CSS Box-Model 來看,margin 是元素以外的空間, 所以當下元素外面沒有其他元素的時候,margin 就沒有發生作用。解決的方式是使用兩層 div,外層設定 position: absolute,而內層用 margin 來空出所需要的空間。