🔖

Custom Directiveを使ったJavaScriptアニメーションパターン

2022/05/04に公開

先日、Vueでのアニメーション管理方法を記事にしました。
https://zenn.dev/lanberb/articles/603fc7a8ca6d87
こちらの記事ではフェードインアニメーションのみ紹介しました。
本記事では色んなアニメーションを紹介します。
目次から好きな部分をご覧ください。
また、新たなアニメーションを思いついたら追記します。

やること

やらないこと

  • vueやgsapの記法説明
  • アニメーション管理方法の細かい紹介

アニメーション管理のおさらい

index.vue
<template>
    <div v-scroll></div>
</template>
customDirectives.js
import Vue from "vue";
import { isInScreen } from "./utilities";
import { initMethod, animateMethod } from "./animations";

Vue.directive("animateFadeIn", {
  bind: (el) => initMethod(),
  inserted: (el) => {
    let handleOnScroll = () => {
      // 画面内判定がtrueの際に実行
      if (isInScreen(el)) {
        window.removeEventListener("scroll", handleOnScroll);
	//アニメーション関数にHTML要素を引渡し
        animateMethod(el);
      }
    };
    window.addEventListener("scroll", handleOnScroll);
  },
});
utilities.js
// 画面内判定メソッド
const isInScreen = (el) => {
  const { top: elementTop, bottom: elementBottom } = el.getBoundingClientRect();
  return window.screenTop < elementTop && elementBottom < window.innerHeight);
};
animations.js
import gsap from "gsap";
export const initMethod = (el) => gsap.to(el, 1, {
  // 事前に調整したいパラメータ
});
export const animateMethod = (el) => gsap.to(el, 1, {
  // アニメーションさせたいパラメータ
});

アニメーションパターンの紹介

#1 フェードイン

animations.js
import gsap from "gsap";
export const initFadeIn = (el) =>
// 透明度: 0
  gsap.set(el, {
    opacity: 0,
  });
export const animateFadeIn = (el) =>
// 1秒で透明度を1に戻す
  gsap.to(el, 1, {
    opacity: 1,
  });

#2 フェードイン+動き

animations.js
import gsap from "gsap";
export const initFadeIn = (el) =>
  gsap.set(el, {
    opacity: 0,
    x: 400,
  });
export const animateFadeIn = (el) =>
  gsap.to(el, 1, {
    opacity: 1,
    x: 0,
  });

#3 レタープッシュアップ

animations.js
import gsap from "gsap";
export const initPushupLetter = (el) => {
  // 1文字ずつ分割
  const letters = el.textContent.trim().split("");
  letters.forEach((letter) => {
    // 1文字ずつdivで梱包
    const div = document.createElement("div");
    div.textContent = letter;
    el.appendChild(div);
    // 1文字ずつ初期値設定
    gsap.set(el.lastChild, {
      y: 32,
    });
  });
  gsap.set(el, {
    display: "flex",
    justifyContent: "center",
    overflow: "hidden",
  });
  // 既存の文字を削除
  el.removeChild(el.firstChild);
};
export const animatePushupLetter = (el) => {
  gsap.to(el.childNodes, 1, {
    y: 0,
    stagger: {
      each: 0.05,
    },
  });
};

#4 シャッフルテキスト

index.vue
<div
  class="flex justify-center overflow-hidden w-screen h-8"
  v-animateLetterShuffle
>
  SampleText~~~~
</div>
utilities.js
// ミリ秒指定でresolveを返すスリープ関数
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
animations.js
export const initLetterShuffle = (el) => {
  // 1文字ずつ分割
  const letters = el.textContent.trim().split("");
  letters.forEach((letter) => {
    // 1文字ずつdivで梱包
    const span = document.createElement("span");
    span.textContent = letter;
    el.appendChild(span);
  });
};
export const animateLetterShuffle = async (el) => {
  const defaultText = el.textContent.trim();
  const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!?#$%&¥@".split("");
  // requestAnimationFrameの返り値(requestID)を保持
  let id;
  function startShuffle() {
    el.childNodes.forEach((item) => {
      // lettersからランダムに文字列を取得 → 各divに挿入
      item.textContent = letters[Math.floor(Math.random() * letters.length)];
    });
    id = requestAnimationFrame(startShuffle);
  }
  function stopShuffle() {
    cancelAnimationFrame(id);
    // 元々の文字に修正
    el.childNodes.forEach(async (item, index) => {
      // 1フレーム(60fps時)ごとに遅延させる
      // 1f: 16.66...(ms) = 1秒 / 60回描画
      await sleep(16.666 * index);
      item.textContent = defaultText.split("")[index];
    });
  }
  // 既存の文字を削除
  el.removeChild(el.firstChild);
  // 実行処理(タイムライン)
  startShuffle();
  await sleep(1000);
  stopShuffle();
};


シャッフル部分は割と即席です。
シャッフルテキストアニメーションは天下のICS様がライブラリを出してたりします。
https://ics.media/entry/15498/

#5 カバーオープン

index.vue
<div class="text-left overflow-hidden w-screen h-8" v-animateCoverOpen>
  SampleText~~~~
</div>
<div class="text-right overflow-hidden w-screen h-8" v-animateCoverOpen>
  ランダムな文字列が入ります〜〜〜
</div>
animations.js
export const initCoverOpen = (el) => {
  // ディレクティブを付与した要素内に直接文字列が入っていた場合
  if (el.firstElementChild === null) {
    // インライン要素で文字列をラップする → カーテンのサイズに反映
    const span = document.createElement("span");
    span.textContent = el.firstChild.textContent.trim();
    el.removeChild(el.firstChild);
    el.insertAdjacentElement("afterbegin", span);
  }
  // ↑で作成したspan要素、もしくはデフォで入っていた要素のRectを取得
  const childRect = el.firstElementChild.getBoundingClientRect();
  // カバー要素を作成
  const div = document.createElement("div");
  gsap.set(div, {
    position: "absolute",
    left: childRect.left,
    width: childRect.width + "px",
    height: childRect.height + "px",
    backgroundColor: "#000000",
    willChange: "transform",
    // ディレクティブを付与した要素の開始タグ直後に挿入
    onComplete: () => el.insertAdjacentElement("afterbegin", div),
  });
};
export const animateCoverOpen = (el) => {
  // カバー要素を取得
  const firstChildRect = el.firstElementChild.getBoundingClientRect();
  gsap.to(el.firstElementChild, 1, {
    left: firstChildRect.width + firstChildRect.left,
    width: 0,
    // アニメーション後、カバー要素を削除
    onComplete: () => el.removeChild(el.firstElementChild),
  });
};

#6 カーテン

#5の応用です。
文字列(span)にopacity: 0を指定し、gsap.timeline()を使ってカーテン要素(div)の動きと連動させます。

animations.js
export const initCurtain = (el) => {
  if (el.firstElementChild === null) {
    const span = document.createElement("span");
    span.textContent = el.firstChild.textContent.trim();
    el.removeChild(el.firstChild);
    el.insertAdjacentElement("afterbegin", span);
  }
  const childRect = el.firstElementChild.getBoundingClientRect();
  const div = document.createElement("div");
  // ここで文字列要素(el.lastChild: span)を`opacity: 0`にします
  gsap.set(el.lastChild, {
    opacity: 0,
  });
  gsap.set(div, {
    position: "absolute",
    left: childRect.left,
    width: childRect.width,
    height: childRect.height + "px",
    backgroundColor: "#000000",
    willChange: "transform",
    onComplete: () => el.insertAdjacentElement("afterbegin", div),
  });
};
export const animateCurtain = (el) => {
  const firstChildRect = el.firstElementChild.getBoundingClientRect();
  // timelineを作成
  const tl = gsap.timeline();
  tl.set(el.firstElementChild, {
    width: 0
  }).to(el.firstElementChild, {
    // カーテンを開きます
    width: firstChildRect.width,
    duration: 1,
  }).set(el.lastChild, {
    // カーテン開き後、文字列を表示します
    opacity: 1,
  }).to(el.firstElementChild, {
    // カーテンを閉じます
    left: firstChildRect.width + firstChildRect.left,
    width: 0,
    duration: 1,
    // カーテン閉じ後、カーテン要素を削除します
    onComplete: () => el.removeChild(el.firstElementChild),
  });
};

Tips

①アニメーションのパラメータを動的に変えたいとき

コンポーネント化を進めると、呼び出す時々でパラメータを微調整したくなりますよね。
そんな時はディレクティブの付与時に値を渡してしまいましょう。

<div v-animateDirective="{ background: 'red' }">
  ...
</div>

こんな感じで渡します。

ここで渡した引数はbindinsertedといったフック関数の第2引数で取得できます

Vue.directive("animateDirective", {
  // ↓binding.valueに渡したパラメータがObjectで保管されています
  bind: (el, binding) => initShuffleLetter(el, binding.value),
  inserted: (el) => {
    let handleOnScroll = () => {
      // 画面内判定がtrueの際に実行
      if (isInScreen(el)) {
        console.log("ok");
        window.removeEventListener("scroll", handleOnScroll);
        animateShuffleLetter(el);
      }
    };
    window.addEventListener("scroll", handleOnScroll);
  },
});

背景色や秒数といったパラメータを渡してアニメーションを使いまわしやすくできたら良いですね。

②任意のy座標でアニメーションを発火させたいとき

上のサンプルでは『画面内に要素が収まったら』という判定でアニメーションを発火させました。
が、その判定が必要なアニメーションってあまり無い気が。
なので更にシンプルに『任意のy座標をHTML要素を通過したら』という判定器を作ってみます。

// 画面上端を0、下端を1としてpointに値を引き渡します。
export const isThroughTriggerPoint = (el, point) => {
  // 指定されたy座標(point)を使ってトリガーポイント
  const triggerPoint = (point) => {
    if (!point) return window.innerHeight * 0.8;
    return window.innerHeight * point;
  };
  const { top: elementTop, bottom: elementBottom } = el.getBoundingClientRect();
  // 要素の上端がトリガーポイントを通過したか否か(boolean)を返す
  return triggerPoint < elementTop;
};

上のサンプルで使用したisInScreenと入れ替えて使えます。

おわりに

アニメーションやTipsは思いついたら追記します。
基本オレオレ実装なので気持ち悪い部分が多々あるかと思います。
何卒ご参考までに。
お時間が許される方はご指摘いただけると幸いです。

Discussion