🔖
Custom Directiveを使ったJavaScriptアニメーションパターン
先日、Vueでのアニメーション管理方法を記事にしました。
本記事では色んなアニメーションを紹介します。
目次から好きな部分をご覧ください。
また、新たなアニメーションを思いついたら追記します。
やること
やらないこと
- 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様がライブラリを出してたりします。
#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>
こんな感じで渡します。
ここで渡した引数はbind
やinserted
といったフック関数の第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