近期想透過「翻頁」這個互動方式來做一個 web 小遊戲,過程中發現意外地不好實現。

這篇文章需要讀者對 React 這個前端框架及 CSS 的 transition 屬性的應用有基本的了解,成果及原始碼是建立於這兩個技術之上的,內文會大略地對實踐的方式說明,但不會詳細地提及所使用的框架及函式庫確切 API 的功能及用法。

TL;DR

Repo | Live demo

  • 透過 CSS 的 translateZ 做出書頁的正反面
.back {
  transform: translateZ(-1px);
}

2023/9/22 基於前端社群網友的建議,我把原先用 opacity 變換做出的頁面正反面,換成靜態、單純加在 CSS class– back 中的 translateZ,另外,為了讓它能正確作用,也需要在 page 這個 CSS class 中加入 transform-style: preserve-3d,如此一來,應該多少能減輕瀏覽器的運算負擔。

  • 透過 position: relative/absolute 來堆疊書頁的上下一頁
  • z-index 切換堆疊的順序
  • react-spring 控制 rotateY 做翻頁轉場
  • 配合 perspective 屬性改變視角增添立體感
  • use-gesture 加入拖曳翻頁功能

讓套件再次偉大

首先我想到的是有沒有現成的(免費)翻頁書效果套件可以用,

  • turn.js– 建立在 jQuery 之上的套件,快十年沒更新了,考量後續擴充功能可能有困難,暫不考慮。
  • StPageFlip– 效果很擬真,從 demo 看起來使用體驗流暢;而且有 React 版本,但是,從 commits 看來有兩年沒更新了,以 front-end 的迭代速度加上,擔心搭 React 18 會不會出現不明的坑因此沒有使用。

後續又看了幾個套件(比較新的)好像也是搭在這些套件上實踐出來,覺得難以在短期理解其運作方式,怕未來不好改。

純 CSS 的現成範例

  • Notebook page flip animation on scrimba– 程式碼很單純,效果簡單,但能夠接受;嘗試了一陣子,發現在呈現次頁的內容上,一旦有多頁,雖然做得到,但程式碼可讀性不是那麼好,擔心後續增加應用,改動起來會有問題,而且試做的是動畫的版本,實際上我需要的是手動觸發。

這邊,我認為應用 transform 有搞頭,又看到 MDN 裡的範例有 matrix (最近剛好在看線性代數的教學影片),拿這個網站沙盤推演了一下,我以為能透過操作scaleXskewY 的矩陣轉換來做出翻頁效果殊不知…

這個作法下 backface-visibility: hidden 不管用;也沒有 rotate 能用,翻頁效果有奇怪的抖動。

Demo: https://codepen.io/allieschen/pen/NWOQxwx

  • Book page flip animation on Codepen– 實踐單純,效果也不錯,嘗試讀改了一下程式碼,發現有些可以簡化的地方,主要的問題在 position: relative/absolute 堆疊使用有點複雜,還用上了 overflow: hidden 遮蓋。

從這個例子中,我確信了可以用 rotateYperspective,加上 z-index 切換當作主要實踐概念。


題外話:製作期間突然好奇如果是設計師會怎麼做,得知有個叫 Flip PDF Pro 的軟體,拿來當關鍵字查發現製作翻頁書網頁的應用蠻多的,只是我需要的是造輪子的方法。

另外也被建議說可以從 PPT 過場動畫的方向去想,也是我上面查 CSS 範例的想法來源;

  • figma 翻頁動畫– 這個例子是一格一格的排出來
  • 也有看到蠻多 youtube 影片是教用 Adobe After Effect 做

Animation library for React

React 強大的生態系,有好多很棒的 animation library 可以選,這也是我選擇用 React 製作的原因:

那個例子的原始碼實踐中看來有複雜的轉換…我理解不能,所以沒有 fork 直接使用。他也用了 use-gesture,所以我後續也選這個做 drag-n-drop 應用。

純 JS 也有關於動畫的套件,比如:

Vercel 這一篇文章分享他們怎麼用 three.js 做出 Next conf 的活動頁面。

這兩個 lib 的進入門檻都不低,又 react-spring 有人做出過我想要的效果,因此我也選擇了它。

使用 react-spring 實踐翻頁動畫

首先參考了 react-spring 官網的這個例子做為轉場實踐的基底,並把頁面與圖面的動畫分開;也就是,翻頁與圖片變化是分開的 useSpring 管理:

  • rotateY– 翻頁效果都靠它

然後是不在 useSpring 管理的 CSS 屬性,這裡要注意,他們要在 pageStyle 前,不然會沒有效果:

  • z-index– 書本的上下一頁正確堆疊的關鍵是它
  • transformOrigin– 改變翻頁的軸心
  • transform: perspective– 這個提供視角的變化,讓翻頁效果從消失點延伸,視覺上更擬真
// @/App.tsx
import { useState } from "react";
import { useSpring, animated } from "@react-spring/web";

const [flipped, setFlipped] = useState(false);
const pageStyle = useSpring({
  rotateY: 0,
  config: { mass: 5, tension: 500, friction: 150 },
  touchAction: "none"
});

return (
    <animated.div
      style={{
        zIndex: flipped ? 10 + zIndex : 100 - zIndex,
        transformOrigin: "left",
        transform: "perspective(600px)",
        ...pageStyle
      }}
      className="page"
    >
      ...the pictures
    </animated.div>
)

關於 config 屬性的作用,引用自 Getting started with react-spring: physics, API, performance - Apptension Blog

  • Mass (when mass is higher, the element needs more velocity to be moved and more time to stop)
  • Friction (higher friction reduces velocity and bounciness)
  • Tension (higher tension reduces the impact of friction).

確保單頁的效果可以之後,我就把它封成一個元件– FlippablePage

接著是放複數功能頁形成一本書,先透過 position 屬性讓各功能頁脫離預設的排版,堆疊在一起,然後調整 z-index;前面的頁數 z-index 為 x 從 0 算起,元件中使用邏輯為

  • 尚未翻頁 = $100 - x$
  • 翻頁後 = $10 + x$

聰明的你應該會發現,當 x = 45,也就是頁數來到 46 頁時,這個算法會出現問題,但因為我的應用不會到這個頁數,也應該能用 virtual list 的方式避免這個問題,所以沒關係

這邊遇到一個問題,如果把 z-index 傳入 useSpring,在正反面調換時,會產生延遲,所以改直接給進 animated.divstyle 屬性中。

在製作前以及過程中使用盡量少的元素來達成想要的動畫效果,因為曾被社群中群友的提醒,在 React 的機制中操作動畫,可能會產生預期外的效能問題

到此,已經有翻頁書的樣子了。

使用 🖐️use-gesture 實踐拖拉翻頁

參考 use-gesture 官網的這個範例,提供了跟 useSpring 一起使用的作法。

接著就慢慢試怎麼樣的屬性和邏輯呈現出來的翻頁效果可以接受,又不會太複雜😵‍💫

注意為了拿 useSpring 的 api 物件給進 useDrag 裡操作,參數會從物件改為一個箭頭函式

import { useState } from "react";
import { useDrag } from "@use-gesture/react";

const [flipped, setFlipped] = useState(false);
const [pageStyle, api] = useSpring(() => ({
  rotateY: 0,
  config: { mass: 5, tension: 500, friction: 150 },
  touchAction: "none"
}));
const bind = useDrag(({ movement: [movX], cancel, dragging }) => {
  // console.log(movX, pageStyle.rotateY.get());
  const currentRY = pageStyle.rotateY.get();
  if (currentRY > -60) {
    api.start({ rotateY: 0 });
    setFlipped(false);
  }
  if (currentRY <= -60 && (movX < 0 || !dragging)) {
    api.start({ rotateY: -180 });
    setFlipped(true);
  } else if (dragging && currentRY <= 0) {
    api.start({ rotateY: currentRY + movX });
  }
});

一個小插曲是要注意圖片載入速度,會影響翻頁動畫的流暢度(會卡住)

最後,打開封面後,書本寬度改變,一來會蓋到邊界超出畫面,二來沒有置中,所以在 FlippablePage 加入一個 onFlipped 屬性透過 useEffect 來觸發 callback– onFlipped,做 translateX 的改變。