iT邦幫忙

2025 iThome 鐵人賽

DAY 18
0

嗨咿,我是 illumi。昨天在gsap 進入的動畫中有沒有看到一堆字掉下來呢?
Yes
我們在前幾天一起做了GSAP掉落效果,現在這個是他的進階版!為了不讓文字重疊在一起看不到,我加上了帶有物理效果的Matter.js,可以加上物理碰撞和堆疊!
Yes
一起來做做看吧!


前置需求(在開始前)

請先安裝必要套件(在你的 React 專案根目錄):

npm install gsap matter-js
# TypeScript 專案建議也裝 type 定義(開發時)
npm install -D @types/matter-js


STEP 1 — 載入(import)必要模組

這裡你需要三個主要東西:

  • React hooks(useRef, useEffect, useState, useCallback
  • gsapScrollTrigger(用來偵測畫面進入/離開)
  • matter-js(物理引擎,處理掉落、碰撞、彈性)
import { useEffect, useRef, useState, useCallback } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import * as Matter from "matter-js";

gsap.registerPlugin(ScrollTrigger);

為什麼要這樣載入?

  • gsap.registerPlugin(ScrollTrigger):讓 GSAP 知道要使用 ScrollTrigger(否則 ScrollTrigger.create 會無效)。
  • Matter 是整套 2D 物理系統,我們會用它創建 engine、body、world。

STEP 2 — HTML / JSX(畫面結構)與基本 CSS 意念

我們把掉落的文字放在一個固定(fixed)容器上,並用 position: absolute 放每個文字元素,畫面由 state 控制每個文字的 x、y、angle。

簡化的 JSX 架構長這樣(你可以把它放在任何頁面的適合位置):

<div
  ref={containerRef}
  className="fixed inset-0 pointer-events-none z-40 transition-opacity duration-300"
  style={{ opacity: containerOpacity }}
>
  {textElements.map((element) => (
    <div
      key={element.id}
      className="absolute rounded-full bg-schema-primary text-schema-on-primary text-xs font-medium px-4 py-2 shadow-lg"
      style={{
        left: element.x - 60,
        top: element.y - 20,
        transform: `rotate(${element.angle}rad)`,
        minWidth: "120px",
        height: "40px",
        transformOrigin: "center center",
      }}
    >
      {element.text}
    </div>
  ))}
</div>

重點說明

  • fixed inset-0:讓容器覆蓋整個視窗空間。這樣物理世界和 DOM 座標是以視窗左上為 (0,0)。
  • pointer-events-none:避免遮蔽頁面互動(文字純裝飾不阻塞點擊)。
  • containerOpacity:用來做淡入/淡出(配合 ScrollTrigger 的 onEnter/onLeave)。
  • 每個文字 DOM 的 left/top 由物理引擎回傳的 body.position 決定(我們會把 body 的位置寫回 state)。

STEP 3 — 用 GSAP + ScrollTrigger 控制「何時啟動掉落」

我們不想文字永遠都掉;通常做法是在「當使用者滾動到某區域」時,讓文字掉下來一次;離開後移除,回來再重置,這樣不會一直跑而浪費資源。

下面是關鍵函數(用在 useEffect 中):

ScrollTrigger.create({
  trigger: document.body,
  start: "1% top",
  end: "18% top",
  onEnter: () => { /* show container, add bodies to world, start physics */ },
  onLeave: () => { /* hide container, remove bodies, stop physics */ },
  onEnterBack: () => { /* 同 onEnter,回滾回來時再播 */ },
  onLeaveBack: () => { /* 同 onLeave */ },
});

屬性簡單解釋

  • trigger: 要監聽哪個元素(也可以是某個 section)。此例用 document.body(代表整頁)做範例,實務上可以改成你想偵測進入的區塊。
  • start: "1% top":觸發起點,當頁面往下 1% 時就會觸發 onEnter
  • end: "18% top":結束點(當滾動到 18% 時會觸發離開)。
  • onEnter / onLeave / onEnterBack / onLeaveBack:常用的四個回呼,負責開始/停止效果。
  • onEnter 裡,我們會把 bodies 加入 engine.world,並啟動物理更新循環;在 onLeave 則移除並停止循環。

STEP 4 — 把 Matter.js 加進來(建立 engine、地板、牆壁、文字 body)

Matter 的流程大概是:

  1. Matter.Engine.create():建立物理引擎。
  2. 在 world 增加靜態地板與牆壁,避免文字掉出螢幕邊界。
  3. 把每個文字用 Bodies.rectangle(x,y,width,height, options) 建成物理體(body)。
  4. 在每個 frame(requestAnimationFrame)呼叫 Matter.Engine.update(engine, delta),並將 body 的 positionangle 拿回來更新 DOM(用 state 或直接 transform DOM)。

關鍵參數說明(在建立 body 時)

  • restitution:彈性,數字越大越會彈起(0 ~ 1)。

  • friction:摩擦力(碰撞時的阻力)。

  • frictionAir:空氣阻力(速度衰減)。

  • density:密度,影響慣性與碰撞力。

    這些參數影響掉落的感覺:彈性高、摩擦低會彈跳多、移動激烈。

在你的範例裡,我們會把每個 text 的初始 x 隨機,y 放在畫面上方(-100、-150 …),然後在 onEnter 把它們 Matter.World.add(world, bodies),這會使文字從天而降。


STEP 5 — 更新畫面(把物理引擎的結果映射回 DOM)

物理引擎跑在 JS 世界,DOM 要靠 React 更新畫面。範例用 requestAnimationFrame 做循環(approx 60FPS):

const updatePhysics = () => {
  if (engineRef.current && isPhysicsRunning) {
    Matter.Engine.update(engineRef.current, 16.666); // 約 60fps
    setTextElements(prev => prev.map((el, idx) => {
      const body = bodiesRef.current[idx];
      return body ? {
         ...el,
         x: body.position.x,
         y: body.position.y,
         angle: clampedAngle
      } : el;
    }));
  }
  if (isPhysicsRunning) {
    animationFrameRef.current = requestAnimationFrame(updatePhysics)
  }
}

要注意的效能點

  • 每幀用 setState 會觸發 React re-render;大量物件會造成效能壓力。
  • 如果你發現效能不佳,改用「直接操作 DOM transform(使用 refs)」會較快(把 DOM 元素存在 refs 上,直接改 el.style.transform),但那樣程式會更複雜。先用 state 版本簡單可理解,再優化。

STEP 6 — 啟動/停止物理(visibility 控制)

為了節省效能,我們不會在頁面任何時候都跑 physics;ScrollTrigger 的回呼會控制 isPhysicsRunning

  • onEnter / onEnterBack → 設 isPhysicsRunning = true,把 bodies 加回 world,並呼叫 updatePhysics() 開始 RAF 循環。
  • onLeave / onLeaveBack → 設 isPhysicsRunning = false,取消 RAF 並從 world 中移出 bodies。

你的原始程式用 handleVisibilityChangesetContainerOpacity(1|0) 來做這件事,這樣畫面也會配合淡入淡出。


STEP 7 — 清理(unmount / 停止)

React 組件卸載時必須:

  • cancelAnimationFrame(若還有循環)

  • Matter.Engine.clear(engineRef.current) 或直接移除 engine

  • ScrollTrigger 的 ctx.revert()(若使用 gsap.context

    否則會造成記憶體洩漏或 background 工作持續跑。


STEP 8 — 可調參數 & 常見問題排查

  • 若文字出現在錯誤位置,檢查 left: element.x - 60 的補正值(這裡 60 是半寬補正,依你實際 width 調整)。
  • 角度單位:body.angle 是「弧度(radians)」,直接在 CSS 用 rotate(${angle}rad)
  • 若物理跑太快或太慢:調整 engine.world.gravity.y,或改 restitution/friction
  • 若在手機或低效能裝置會卡:降低文字數量、降低 update 頻率或直接用 CSS 動畫替代物理。
  • 加入 prefers-reduced-motion 支援:用 window.matchMedia("(prefers-reduced-motion: reduce)") 跳過物理動畫。

Yes

最後 — 完整的程式碼

// TextDrop.tsx
import { useEffect, useRef, useState, useCallback } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import * as Matter from "matter-js";

gsap.registerPlugin(ScrollTrigger);

const TEXTS = [
  "1",
  "2",
  "3",
  "4",
];

const TextDrop = ({ className }: { className?: string }) => {
  const containerRef = useRef<HTMLDivElement>(null);
  const [containerOpacity, setContainerOpacity] = useState(0);
  const engineRef = useRef<Matter.Engine | null>(null);
  const bodiesRef = useRef<Matter.Body[]>([]);
  const animationFrameRef = useRef<number | undefined>(undefined);
  const [isPhysicsRunning, setIsPhysicsRunning] = useState(false);
  const [textElements, setTextElements] = useState<
    Array<{ text: string; x: number; y: number; angle: number; id: string }>
  >([]);

  // 用來啟動/停止物理更新循環 (避免循環依賴)
  const handleVisibilityChange = useCallback((isVisible: boolean) => {
    setIsPhysicsRunning((prev) => {
      if (isVisible && !prev) {
        return true;
      } else if (!isVisible && prev) {
        if (animationFrameRef.current) {
          cancelAnimationFrame(animationFrameRef.current);
          animationFrameRef.current = undefined;
        }
        return false;
      }
      return prev;
    });
  }, []);

  // 初始化 engine、bodies 與 ScrollTrigger
  useEffect(() => {
    if (!containerRef.current) return;

    // 1) 初始化 Matter 引擎
    const engine = Matter.Engine.create();
    engine.world.gravity.y = 0.8; // 重力大小,可調
    engineRef.current = engine;

    // 2) 建立靜態地板與牆壁(避免掉出邊界)
    const ground = Matter.Bodies.rectangle(
      window.innerWidth / 2,
      window.innerHeight - 30,
      window.innerWidth,
      120,
      { isStatic: true }
    );
    const leftWall = Matter.Bodies.rectangle(
      -25,
      window.innerHeight / 2,
      50,
      window.innerHeight,
      { isStatic: true }
    );
    const rightWall = Matter.Bodies.rectangle(
      window.innerWidth + 25,
      window.innerHeight / 2,
      50,
      window.innerHeight,
      { isStatic: true }
    );
    Matter.World.add(engine.world, [ground, leftWall, rightWall]);

    // 3) 創建文字物理體(初始放在視窗上方,會掉下)
    const bodies: Matter.Body[] = [];
    const initialElements = TEXTS.map((text, idx) => {
      const width = Math.max(120, text.length * 12 + 32);
      const height = 40;
      const x = Math.random() * (window.innerWidth - width - 100) + width / 2;
      const y = -100 - idx * 50;

      const body = Matter.Bodies.rectangle(x, y, width, height, {
        restitution: 0.6,
        friction: 0.3,
        frictionAir: 0.01,
        density: 0.001,
        angle: (Math.random() * 60 - 30) * (Math.PI / 180),
      });

      bodies.push(body);
      return {
        text,
        x,
        y,
        angle: body.angle,
        id: `text-${idx}`,
      };
    });

    bodiesRef.current = bodies;
    setTextElements(initialElements);

    // 4) 用 GSAP ScrollTrigger 控制何時加入 / 移除物理
    const ctx = gsap.context(() => {
      ScrollTrigger.create({
        trigger: document.body,
        start: "1% top",
        end: "18% top",
        onEnter: () => {
          setContainerOpacity(1);
          handleVisibilityChange(true);

          // 重置物理體位置到天空(每次都重新放置)
          bodies.forEach((body, idx) => {
            const x = Math.random() * (window.innerWidth - 200) + 100;
            const y = -100 - idx * 50;
            Matter.Body.setPosition(body, { x, y });
            Matter.Body.setVelocity(body, { x: 0, y: 0 });
            Matter.Body.setAngle(
              body,
              (Math.random() * 60 - 30) * (Math.PI / 180)
            );
            Matter.Body.setAngularVelocity(body, 0);
          });
          Matter.World.add(engine.world, bodies);
        },
        onLeave: () => {
          setContainerOpacity(0);
          handleVisibilityChange(false);
          Matter.World.remove(engine.world, bodies);
          if (animationFrameRef.current) {
            cancelAnimationFrame(animationFrameRef.current);
            animationFrameRef.current = undefined;
          }
        },
        onEnterBack: () => {
          setContainerOpacity(1);
          handleVisibilityChange(true);
          bodies.forEach((body, idx) => {
            const x = Math.random() * (window.innerWidth - 200) + 100;
            const y = -100 - idx * 50;
            Matter.Body.setPosition(body, { x, y });
            Matter.Body.setVelocity(body, { x: 0, y: 0 });
            Matter.Body.setAngle(
              body,
              (Math.random() * 60 - 30) * (Math.PI / 180)
            );
            Matter.Body.setAngularVelocity(body, 0);
          });
          Matter.World.add(engine.world, bodies);
        },
        onLeaveBack: () => {
          setContainerOpacity(0);
          handleVisibilityChange(false);
          Matter.World.remove(engine.world, bodies);
          if (animationFrameRef.current) {
            cancelAnimationFrame(animationFrameRef.current);
            animationFrameRef.current = undefined;
          }
        },
      });
    }, containerRef);

    // 清理(unmount)
    return () => {
      ctx.revert();
      if (animationFrameRef.current) {
        cancelAnimationFrame(animationFrameRef.current);
      }
      if (engineRef.current) {
        Matter.Engine.clear(engineRef.current);
        engineRef.current = null;
      }
    };
  }, [handleVisibilityChange]);

  // 物理更新函數(每幀把 body 的位置寫回 state)
  const updatePhysics = useCallback(() => {
    if (engineRef.current && isPhysicsRunning) {
      Matter.Engine.update(engineRef.current, 16.666); // 約 60fps

      setTextElements((prevElements) =>
        prevElements.map((element, idx) => {
          const body = bodiesRef.current[idx];
          if (body) {
            // 限制旋轉角度在 ±30度(避免過度旋轉)
            const clampedAngle = Math.max(
              -Math.PI / 6,
              Math.min(Math.PI / 6, body.angle)
            );
            if (body.angle !== clampedAngle) {
              Matter.Body.setAngle(body, clampedAngle);
            }
            return {
              ...element,
              x: body.position.x,
              y: body.position.y,
              angle: clampedAngle,
            };
          }
          return element;
        })
      );
    }
    if (isPhysicsRunning) {
      animationFrameRef.current = requestAnimationFrame(updatePhysics);
    }
  }, [isPhysicsRunning]);

  // 監聽 isPhysicsRunning,啟動或停止更新循環
  useEffect(() => {
    if (isPhysicsRunning && engineRef.current) {
      updatePhysics();
    } else if (!isPhysicsRunning && animationFrameRef.current) {
      cancelAnimationFrame(animationFrameRef.current);
      animationFrameRef.current = undefined;
    }
  }, [isPhysicsRunning, updatePhysics]);

  return (
    <div
      ref={containerRef}
      className={`fixed inset-0 pointer-events-none z-40 transition-opacity duration-300 ${className}`}
      style={{ opacity: containerOpacity }}
    >
      {textElements.map((element) => (
        <div
          key={element.id}
          className="absolute rounded-full bg-schema-primary text-schema-on-primary flex items-center justify-center text-xs md:text-p font-medium shadow-lg px-4 py-2"
          style={{
            left: element.x - 60,
            top: element.y - 20,
            userSelect: "none",
            transform: `rotate(${element.angle}rad)`,
            textAlign: "center",
            lineHeight: "1.2",
            minWidth: "120px",
            height: "40px",
            transformOrigin: "center center",
          }}
        >
          {element.text}
        </div>
      ))}
    </div>
  );
};

export default TextDrop;


呼,終於,今天就到這啦~我們明天見~


上一篇
Hold On! GSAP你先借我滑鼠控制權滑別的東西
下一篇
GSAP ScrollTrigger 讓物件隨著滑動說出故事
系列文
在Vibe Coding 時代一起來做沒有AI感的漂亮網站吧!21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言