かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

GSAP batch でアニメーションが実行された要素だけをアレコレしたい

GSAP の ScrollTrigger.batch を使えば、リストなどに簡単にアニメーションを設定することができる。

https://gsap.com/docs/v3/Plugins/ScrollTrigger/static.batch()/

batch でアニメーションを設定したときに、アニメーションが実行された要素に class を付けたいとか、要素に何か処理をしたい要望があったのでそのメモ

  • TypeScript: ^5.5.4
  • GSAP: ^3.12.5

ScrollTrigger.batch を使ったアニメーションの基本

cf. static-batch | GSAP | Docs & Learning

import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);

// アニメーションさせる要素の表示を初期化
gsap.set(".listItem", {
  autoAlpha: 0,
  y: 50
});

ScrollTrigger.batch('.listItem', {
  interval: 0.1, // バッチ処理が行われるまでの時間 (秒)
  start: "top-=200 center", // ターゲットの top - 200px に画面の中央が来たら開始
  end: "bottom+=50 center" // ターゲットの bottom + 50px に画面の中央が来たら終了
  once: true, // 一度だけ実行する
  markers: true, // debug 用に start, end のガイドを表示させる
  onEnter: (elements) => {
    // onEnter 内でアニメーションの設定をする
    gsap.to(elements, {
      stagger: 0.5, // 同じバッチ内でアニメーションが実行されたときの時差
      autoAlpha: 1,
      y: 0,
    });
  },
});

対象要素に class 名を付けたいだけなら attr が便利

例えばアニメーションが始まった際に isLoaded クラスを付け、data-status="loaded" にするなら下記のような感じ

ScrollTrigger.batch('.listItem', {
  interval: 0.1,
  start: "top-=200 center",
  end: "bottom+=50 center"
  once: true,
  markers: true,
  onEnter: (elements) => {
    gsap.to(elements, {
      stagger: 0.5,
      autoAlpha: 1,
      y: 0,
      // attr は完全に置換するので元のクラス名を残す必要があるなら書いておく
      attr: { class: ".listItem .isLoaded", "data-status": "loaded" }
    });
  },
});

⚠️ attr の注意点

  • attribute をまるっと置換してしまうので、必要なものは記述する必要があり HTML と密結合になってしまう
  • attr は trigger になっている要素しか対象にしないので、子要素や親要素になにかしたい場合は対応できない (CSSで対応できる範囲でしか操作できない)
  • 文字列を返す関数を設置できたが、ドキュメントには書かれてないので動作は担保されてなさそう
ScrollTrigger.batch('.listItem', {
  // ç•¥
  onEnter: (elements) => {
    gsap.to(elements, {
      stagger: 0.5,
      autoAlpha: 1,
      y: 0,
      attr: {
        class: (_index, item) => {
          const cx = item.classList.value;
          return cx + ' isLoaded';
        },
      },
    });
  },
});

↑ これも動作したが、ドキュメントにこの方法は載ってないっぽいので動作保証はなさそう

🙆 1度だけ対象要素以外も操作するなら onEnter 内の gsap.to 内にイベントを設定するのが良さそう

後述するが、onToggle は start, end を跨ぐ度に実行され、onLeave はスクロール速度では発火しないケースがあった。
onEnter さえすればアニメーションは発火するので、その中で操作する処理を書いてしまうのが確実かなという印象

ScrollTrigger.batch('.listItem', {
  // ç•¥
  onEnter: (elements) => {
    gsap.to(elements, {
      stagger: 0.5,
      autoAlpha: 1,
      y: 0,
      // attr は置換で扱いづらいので使わない
      // attr: { class: ".listItem .isLoaded", "data-status": "loaded" } 
      },
      onStart: (index, item) => {
        // アニメーションが開始された時点で実行される
        item?.classList.add('isLoaded');
        item?.dataset.status = 'loaded';
        const parent = item?.parentElement;
        parent?.classList.add('isChildLoaded');
      },
      onLeave: (index, item) => {
        // gsap.to の設定的に end が無いので onStart 直後に発火するので使い分ける意味はほぼ無い
      },
      onComplete: (arg: undefined) => {
        // バッチで実行されたアニメーションが全て完了したら呼び出されるが、引数が `undefined` なので使いづらい
      },
    });
  },
});

onComplete

onComplete はバッチになっているアニメーションが全て完了したら呼び出されるので便利だが、引数が無いので扱いづらい
Arrow function ではなく通常の関数にすることで this から対象を引っ張ってくることもできるが _target というアンダースコア付きのプロパティなので、アンチパターンではないかと思う。(操作できるにはできる)

ScrollTrigger.batch('.listItem', {
  // ç•¥
  onEnter: (elements) => {
    gsap.to(elements, {
      stagger: 0.5,
      autoAlpha: 1,
      y: 0,
      // attr は置換で扱いづらいので使わない
      // attr: { class: ".listItem .isLoaded", "data-status": "loaded" } 
      },
      onStart: (index, item) => {
        // アニメーションが開始された時点で実行される
        item?.classList.add('isLoaded');
        item?.dataset.status = 'loaded';
        const parent = item?.parentElement;
        parent?.classList.add('isChildLoaded');
      },
      onComplete: function() {
        const self = this;
        const elemens = self?._targets;
        if ( !Array.isArray(elemens) ) {
          return;
        }
        elemens.forEach((el) => {
          if (!el || el instanceof HTMLElement === false) {
            return;
          }
          el.classList.add('isComplete');
          el.dataset.status = 'complete';
        });
      }
    });
  },
});

🤔 要素を操作したい場合は onToggle が使える

アニメーションが始まった際に親要素に isLoaded クラスを付け、子要素のdata-status 属性をを loaded にするなら下記のような感じ
ただし、onToggle は start, end を跨いだタイミングで実行されるので 1回だけ実行させたいようなケースでは工夫が必要

ScrollTrigger.batch('.listItem', {
  // ç•¥
  onEnter: (elements) => {
    // onEnter 内で各 .listItem のアニメーションを設定
    gsap.to(elements, {
      stagger: 0.5,
      autoAlpha: 1,
      y: 0,
    });
  },
  onToggle: (elements) = > {
     // 今回のバッチで実行される要素の配列が引数として渡される
     elements.forEach((el) = > {
       const parent = el.parentElement;
       const child = el.firstElementChild;
       parent?.classList.add("isLoaded");
       child?.dataset.status = "loaded";
     });
  },
});

⚠️ onToggle の注意点

  • onToggle は start, end をまたぐ度に実行されるので、1度だけ操作したい・終了時に操作したい場合は扱いに注意が必要
  • once のときスクロール速度が早いと onToggle は実行されないケースがあったので確実性は微妙

🤔 対象からスクロールが出た際に何か実行するなら onLeave

公式ドキュメントに例が載っている onLeave はバッチの対象から出る時に実行されるので、アニメーションが完了した後に要素に対して何か処理を行う際に使える

ScrollTrigger.batch('.listItem', {
  // ç•¥
  onEnter: (elements) => {
    // onEnter 内で各 .listItem のアニメーションを設定
    gsap.to(elements, {
      stagger: 0.5,
      autoAlpha: 1,
      y: 0,
    });
  },
  onLeave: (elements) = > {
     // 対象外になったバッチに含まれる要素の配列が引数として渡される
     elements.forEach((el) = > {
       el.dataset.status = "leaved";
       const parent = el.parentElement;
       parent?.classList.add("isLeaved");
     });
  },
});

⚠️ onLeave の注意点

  • スクロール速度によっては結構な確率で発火しない、感覚的に end の onToggle の方がまだ発火される可能性が高い気がする
  • onEnter からアニメーションの時間分 setTimeout させるほうが確実性はありそう

🙅 onStart, onComplete は ScrollTrigger.batch にはない

Timeline .vars に onStart, onComplete の項目もあったが、ScrollTrigger.batch では onStart, onComplete は発火しないっぽい (※ ScrollTrigger .vars のドキュメントが薄く vars の中身は同じだと思っていたが)
対象要素が複数なので、何をもって start なのか complete なのか判断が難しいので使えないのは分からなくもない

ScrollTrigger.batch('.listItem', {
  // ç•¥
  onEnter: (elements) => {
    // onEnter 内で各 .listItem のアニメーションを設定
    gsap.to(elements, {
      stagger: 0.5,
      autoAlpha: 1,
      y: 0,
    });
  },
  // onStart, onComplete は発火ししなかった
  onStart: (evt: any) => {
    console.log('onStart', evt);
  },
  onComplete: (evt: any) => {
    console.log('onComplete', evt);
  },
});

ScrollTrigger.batch でアニメーション実行時に要素に変更を加えたいときのまとめ

  • onEnter 内で実行するのが無難
    • once: true のとき、onToggle, onLeave はスクロール速度によっては発火されない場合がある
    • onEnter, onToggle と onLeave, onToggle の順番は担保されない
      • attr, onToggle の発火順も担保されてない
  • onToggle は start 時と end 時に実行される
    • end になる前に start を逆方向にスクロールして跨いでも再度実行される
  • class 名を追加したいだけなら attr が便利だが、class 名をまるっと置換してしまうので注意が必要
    • attr は対象の要素しか操作できないので、親や子要素など対象要素を起点に他の要素を操作したい場合は別のアプローチが必要
    • attr の value に文字列を返す関数を設定できたがドキュメントに載ってないので動作が担保されるかは怪しい
  • アニメーション完了時に何かをしたいのであれば onEnter 内でアニメーションの時間分 setTimeout させるのが無難そう (微妙だけど)
  • onLoad 時に、既にアニメーションが終了しているスクロール位置の場合は、スクロール量とコンテンツの位置から GSAP を動作させず完了とさせる処理を挟むほうが良さそう

Sample

↓ スクロールを上下させると once: true でも end に到達する前に start を跨ぐ度に onToggle が実行されるのが確認できる

See the Pen GSAP batch animation callback by KIKIKI (@kikiki_kiki) on CodePen.

batch でアニメーションが開始された要素と、その要素を起点に DOM 操作したかっただけだったんだけど、結構沼だった。
そして、このあと ScrollTrigger.batch 使わなくてもできるじゃん… となってしまった訳ですが、それはまた別のお話…

おわり。゚(゚ ◜ᴗ◝゚)゚。


[参考]