🐦

IntersectionObserver + テキストアニメーション用クラスで沼ったところを共有したい

2022/11/18に公開約15,400字2件のコメント

記事の目的

クラス構文は全くもって不慣れで、知見が浅いのですがCodemafia先生のUdemy講座を参考にして
タイトルのクラス二つを作成してみました。
しかし、コラボさせた時に何度やってもエラーになり困惑しました。
今回、解決(パワープレイ)できたので共有したいのと自分の整理のために記事を書きます。

コード全体像

JavaScript
document.addEventListener("DOMContentLoaded", function () {
  const cb = function (el,isIntersecting) {
    if (isIntersecting) {
      const targetList = el.classList;//elにはDOMが渡ってくるのでクラスをListで取得
      const target = '.' + targetList[0];//セレクタに書き換える
      const ta = new SplitTextAnimation(target);
      ta.fadeUpText();
    }
  }
  const so = new ScrollObserver('.target',cb);
});

//----------------------------------------------------------------
//引数にDOMを渡して文字列分割テキストアニメーションをする用のクラス

class SplitTextAnimation {
  constructor(el) {
    this.el = document.querySelector(el);
    this.chars = this.el.innerText.trim();
    this.concatStr = "";
    this.el.innerHTML = this._splitText(); //クラスに渡された引数が分割された状態のDOM
    this.animations = [];
  }

  //テキストがcharクラスをもつspanで1文字ずつ分割される関数
  _splitText() {
    for (let c of this.chars) {
      c = c.replace(" ", " ");
      this.concatStr += `<span class="char">${c}</span>`;
    }
    return this.concatStr;
  }

  //1文字ずつfadeUpする
  fadeUpText() {
    //タイミング制御用オブジェクトを定義
    let timings = {
      easing: "ease-out",
      fill: "forwards",
    };

    const chars = this.el.querySelectorAll(".char"); //指定した要素(el)のcharクラスを取得する
    chars.forEach((char, i) => {
      char.style.display = "inline-block";//デフォルトのスタイルを当てる
      char.style.opacity = 0;
      char.style.transform = "translateY(100px)";
      timings.delay = i * 20;
      timings.duration = 800;
      const animation1 = char.animate(
        [{ transform: "translateY(100px) " }, { transform: "translateY(0px) " }],
        timings
      );
      animation1.cancel();
      this.animations.push(animation1);
  
      timings.duration = 800;
      const animation2 = char.animate([{ opacity: 0 }, { opacity: 1 }], timings);
      animation2.cancel();
      this.animations.push(animation2);
    });
    this.animations.forEach((anim) => {
      anim.play();
    });
  }
}

//使い方
//1.インスタンス化
// const ta = new SplitTextAnimation(ここにアニメーションさせたい要素のセレクタを渡す)
//2.アニメーション関数の実行
// ta.fadeUpText()
//----------------------------------------------------------------


//----------------------------------------------------------------
//IntersectionObserver用クラス
class ScrollObserver {
  constructor(els, cb, options) {
    this.els = document.querySelectorAll(els);
    const defaultOptions = {
      root: null, //交差対象
      rootMargin: "0px", //交差判定境界線
      threshold: 0//targetのどこで交差判定するか
    };
    this.cb = cb;
    this.options = Object.assign(defaultOptions, options); //オブジェクトを合体させる
    this._init();
  }
  
  //初期化
  _init() {
    const callback = function (entries, observer) {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          //画面内に入った時
          this.cb(entry.target, true);
          observer.unobserve(entry.target); //監視を終了する
        } else {
          //画面外に出た時
          this.cb(entry.target, false);
        }
      });
    };
    
    const io = new IntersectionObserver(callback.bind(this), this.options);
    this.els.forEach(el => io.observe(el));
  }
}

//使い方
//1.コールバック関数を定義する
// const cb = function (el, isIntersecting) {
//   if (isIntersecting) {
//     ここに画面内に入ったら行いたい処理をかく
//   }
// }
//※上記のelにはセレクタではなくDOM自体が渡ることに注意する
//2.インスタンス化する
// const so = new ScrollObserver('監視対象セレクタ', cb, options:あってもなくても良い);
//----------------------------------------------------------------

詰まったところ

上記コードでは「画面内検知、一度検知したら監視終了、画面内に入ったらテキストアニメーションの実行」
までを行なっています。
今回詰まったところは、コメントアウトの注意書きでも書いていますが、以下です。

ScrollObserverに渡すcbのelにはDOMが格納されている

考えてみれば当たり前の話でした。
cbはつまり、this.cbな訳で
this.cbにはinitの中で「entry.target」と「boolean」が渡されています。

JavaScript
 _init() {
    const callback = function (entries, observer) {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          //画面内に入った時
          this.cb(entry.target, true);//この部分
          observer.unobserve(entry.target); //監視を終了する
        } else {
          //画面外に出た時
          this.cb(entry.target, false);//この部分
        }
      });

cbを定義する際に最初に僕がやっていたことは、以下です

JavaScript
 const cb = function (el,isIntersecting) {
    if (isIntersecting) {
      const ta = new SplitTextAnimation(el);
      ta.fadeUpText();
    }
  }

今回はScrollObserverクラスに「.target」クラスを渡しているので
そのDOMが

<p class='target'>TEXT</p>

だった場合、cbの中は以下のようになります。(elを置き換えています)

JavaScript
 const cb = function (<p class='target'>TEXT</p>,isIntersecting) {
    if (isIntersecting) {
      const ta = new SplitTextAnimation(<p class='target'>TEXT</p>);
      ta.fadeUpText();
    }
  }

そうなると、SplitTextAnimationクラスのコンストラクタ内でエラーが発生しました。

JavaScript
constructor(el) {
    this.el = document.querySelector(el);//この部分
    this.chars = this.el.innerText.trim();
    this.concatStr = "";
    this.el.innerHTML = this._splitText(); //クラスに渡された引数が分割された状態のDOM
    this.animations = [];
  }

上記のelにDOMが入ってくるので、querySelectorが実行できるわけがありません。
そんなこんなで、DOMからパワープレイで強引にセレクタに書き換えて困難を乗り切りました。

JavaScript
const cb = function (el,isIntersecting) {
    if (isIntersecting) {
      const targetList = el.classList;//elにはDOMが渡ってくるのでクラスをListで取得
      const target = '.' + targetList[0];//セレクタに書き換える
      const ta = new SplitTextAnimation(target);
      ta.fadeUpText();
    }
  }

まとめ

とりあえず解決はしましたが、この書き方果たして良いのだろうか。いや、きっと良くない。
と思っています。そう思っているものを記事にするのもどうかと思いましたが、
クラス構文の練習になったので、学んだことを整理したいという思いで書きました。
あくまでご参考程度に(参考にもならんと思いますが)お願いします。

11/18追記:コード全体像

JavaScript
document.addEventListener("DOMContentLoaded", function () {
  const cb = function (el,isIntersecting) {
    const targetList = el.classList;//elにはDOMが渡ってくるのでクラスをListで取得
    const target = '.' + targetList[0];//セレクタに書き換える
    const ta = new SplitTextAnimation(target);
    if (isIntersecting) {
      ta.fadeUpText();
    }
  }
  const so = new ScrollObserver('.target',cb);
});

//----------------------------------------------------------------
//引数にDOMを渡して文字列分割テキストアニメーションをする用のクラス
class SplitTextAnimation {
  constructor(el) {
    this.el = document.querySelector(el);
    this.chars = this.el.innerText.trim();
    this.concatStr = "";
    this.el.innerHTML = this._splitText(); //クラスに渡された引数が分割された状態のDOM
    this.animations = [];
    this.chars = '';
    this.transY = "170px";//transformY
    this.outer = document.createElement('div');//対象の親にdivを追加
    this._clip();
    this._init();
  }

  //テキストがcharクラスをもつspanで1文字ずつ分割される関数
  _splitText() {
    for (let c of this.chars) {
      c = c.replace(" ", "&nbsp;");
      this.concatStr += `<span class="char">${c}</span>`;
    }
    return this.concatStr;
  }
 
  //分割したテキストにデフォルトスタイルを付与
  _init() {
    this.chars = this.el.querySelectorAll(".char"); //指定した要素(el)のcharクラスを取得する
    this.chars.forEach(char => {
      char.style.display = 'inline-block';
      // char.style.opacity = 0;
      char.style.transform = `translateY(${this.transY})`;
    })
  }

  //アニメーションの対象をdivで囲みclip-path
  _clip() {
    this.outer.classList.add('js-outer');//囲むdivにクラスを付与
    this.el.parentNode.insertBefore(this.outer, this.el);//対象の親要素の子要素にdivを挿入
    this.outer.appendChild(this.el);//生成したdivの中に対象を入れる
    this.outer.style.clipPath = "polygon(0 0,100% 0,100% 100%,0 100%)";//divより外側をclip-pathで切り取る
  }

  //1文字ずつfadeUpする
  fadeUpText() {
    //タイミング制御用オブジェクトを定義
    let timings = {
      easing: "ease-in-out",
      fill: "forwards",
    };
    let x,easing;

    this.chars = this.el.querySelectorAll(".char"); //指定した要素(el)のcharクラスを取得する
    this.chars.forEach((char, i) => {
      x = i / (this.chars.length - 1);//0 ~ 1
      const maxDelay = 170;//delay最大値
      //イージング関数
      function ease(x){
        return x < 0.5 ? 8 * x * x * x * x : 1 - Math.pow(-2 * x + 2, 4) / 2;
        }
      easing = ease(x);
      timings.delay = easing * maxDelay;
      timings.duration = 550;
      const animation1 = char.animate(
        [{ transform: `translateY(${this.transY}) rotateZ(20deg)` }, { transform: "translateY(0px) rotateZ(0)" }],
        timings
      );
      animation1.cancel();
      this.animations.push(animation1);
  
      const animation2 = char.animate([{ opacity: 0 }, { opacity: 1 }], timings);
      animation2.cancel();
      // this.animations.push(animation2);
    });
    this.animations.forEach((anim) => {
      anim.play();
    });
  }
}

//使い方
//1.インスタンス化
// const ta = new SplitTextAnimation(ここにアニメーションさせたい要素のセレクタを渡す)
//2.アニメーション関数の実行
// ta.fadeUpText()
//----------------------------------------------------------------


//----------------------------------------------------------------
//IntersectionObserver用クラス
class ScrollObserver {
  constructor(els, cb, options) {
    this.els = document.querySelectorAll(els);
    const defaultOptions = {
      root: null, //交差対象
      rootMargin: "-100px", //交差判定境界線
      threshold: 0//targetのどこで交差判定するか
    };
    this.cb = cb;
    this.options = Object.assign(defaultOptions, options); //オブジェクトを合体させる
    this._init();
  }
  
  //初期化
  _init() {
    const callback = function (entries, observer) {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          //画面内に入った時
          this.cb(entry.target, true);
          observer.unobserve(entry.target); //監視を終了する
        } else {
          //画面外に出た時
          this.cb(entry.target, false);
        }
      });
    };
    
    const io = new IntersectionObserver(callback.bind(this), this.options);
    this.els.forEach(el => io.observe(el));
  }
}

//使い方
//1.コールバック関数を定義する
// const cb = function (el, isIntersecting) {
//   if (isIntersecting) {
//     ここに画面内に入ったら行いたい処理をかく
//   }
// }
//※上記のelにはセレクタではなくDOM自体が渡ることに注意する
//2.インスタンス化する
// const so = new ScrollObserver('監視対象セレクタ', cb, options:あってもなくても良い);
//----------------------------------------------------------------

11/22追記(パワープレイから修正)

一応jsのコード全体も載せます。修正前コードもコメントアウトで残してあります。

JavaScript
document.addEventListener("DOMContentLoaded", function () {
  //IntersectionObserver+テキストアニメーションで使用するコールバック関数を定義
  // const cb = function (el, isIntersecting) {
  //   //画面内に入る前に初期化処理をする
  //   const targetList = el.classList;//elにはDOMが渡ってくるのでクラスをListで取得
  //   const target = '.' + targetList[0];//セレクタに書き換える
  //   const ta = new SplitTextAnimation(target);
  //   if (isIntersecting) {
  //     //画面内に入ったらアニメーションの実行
  //     ta.fadeUpText();
  //   }
  // }

  //elにはScrollObserver内のentry.targetが渡ってくる(DOM Element)
  const cb = function (el, isIntersecting) {
    const ta = new SplitTextAnimation(el);
    if (isIntersecting) {
      ta.fadeUpText();
    }
  }

  //.targetに対してfadeUpアニメーションを実行
  // const so = new ScrollObserver('.target',cb);
  const so = new ScrollObserver(document.querySelectorAll('.target'),cb);
});

//----------------------------------------------------------------
//引数にDOMを渡して文字列分割テキストアニメーションをする用のクラス
class SplitTextAnimation {
  constructor(el) {
    // this.el = document.querySelector(el);
    this.el = el;//Elementを渡す
    this.chars = this.el.innerText.trim();
    this.concatStr = "";
    this.el.innerHTML = this._splitText(); //クラスに渡された引数が分割された状態のDOM
    this.animations = [];
    this.chars = '';
    this.transY = "170px";//transformY
    this.outer = document.createElement('div');//対象の親にdivを追加
    // this._clip();
    this._init();
  }

  //テキストがcharクラスをもつspanで1文字ずつ分割される関数
  _splitText() {
    for (let c of this.chars) {
      c = c.replace(" ", "&nbsp;");
      this.concatStr += `<span class="char">${c}</span>`;
    }
    return this.concatStr;
  }
 
  //分割したテキストにデフォルトスタイルを付与
  _init() {
    this.chars = this.el.querySelectorAll(".char"); //指定した要素(el)のcharクラスを取得する
    this.chars.forEach(char => {
      char.style.display = 'inline-block';
      // char.style.opacity = 0;
      char.style.transform = `translateY(${this.transY})`;
    })
  }

  //アニメーションの対象をdivで囲みclip-path
  _clip() {
    this.outer.classList.add('js-outer');//囲むdivにクラスを付与
    this.el.parentNode.insertBefore(this.outer, this.el);//対象の親要素の子要素にdivを挿入
    this.outer.appendChild(this.el);//生成したdivの中に対象を入れる
    this.outer.style.clipPath = "polygon(0 0,100% 0,100% 100%,0 100%)";//divより外側をclip-pathで切り取る
  }

  //1文字ずつfadeUpする
  fadeUpText() {
    this._clip();//isIntersectingになったら実行される
    //タイミング制御用オブジェクトを定義
    let timings = {
      easing: "ease-in-out",
      fill: "forwards",
    };
    let x,easing;

    this.chars = this.el.querySelectorAll(".char"); //指定した要素(el)のcharクラスを取得する
    this.chars.forEach((char, i) => {
      x = i / (this.chars.length - 1);//0 ~ 1
      const maxDelay = 170;//delay最大値
      //イージング関数
      function ease(x){
        return x < 0.5 ? 8 * x * x * x * x : 1 - Math.pow(-2 * x + 2, 4) / 2;
        }
      easing = ease(x);
      timings.delay = easing * maxDelay;
      timings.duration = 550;
      const animation1 = char.animate(
        [{ transform: `translateY(${this.transY}) rotateZ(20deg)` }, { transform: "translateY(0px) rotateZ(0)" }],
        timings
      );
      animation1.cancel();
      this.animations.push(animation1);
  
      const animation2 = char.animate([{ opacity: 0 }, { opacity: 1 }], timings);
      animation2.cancel();
      // this.animations.push(animation2);
    });
    this.animations.forEach((anim) => {
      anim.play();
    });
  }
}

//使い方
//1.インスタンス化
// const ta = new SplitTextAnimation(ここにアニメーションさせたい要素のセレクタを渡す)
//2.アニメーション関数の実行
// ta.fadeUpText()
//----------------------------------------------------------------


//----------------------------------------------------------------
//IntersectionObserver用クラス
class ScrollObserver {
  constructor(els, cb, options) {
    // this.els = document.querySelectorAll(els);
    this.els = els;//nodelistを渡す
    const defaultOptions = {
      root: null, //交差対象
      rootMargin: "-100px", //交差判定境界線
      threshold: 0//targetのどこで交差判定するか
    };
    this.cb = cb;
    this.options = Object.assign(defaultOptions, options); //オブジェクトを合体させる
    this._init();
  }
  
  //初期化
  _init() {
    const callback = function (entries, observer) {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          //画面内に入った時
          this.cb(entry.target, true);
          observer.unobserve(entry.target); //監視を終了する
        } else {
          //画面外に出た時
          this.cb(entry.target, false);
        }
      });
    };
    
    const io = new IntersectionObserver(callback.bind(this), this.options);
    this.els.forEach(el => io.observe(el));
  }
}

//使い方
//1.コールバック関数を定義する
// const cb = function (el, isIntersecting) {
//   if (isIntersecting) {
//     ここに画面内に入ったら行いたい処理をかく
//   }
// }
//※上記のelにはセレクタではなくDOM自体が渡ることに注意する
//2.インスタンス化する
// const so = new ScrollObserver('監視対象セレクタ', cb, options:あってもなくても良い);
//----------------------------------------------------------------

Discussion

SplitTextAnimationのコンストラクタの引数を、セレクタではなくElementにするとよいのではないでしょうか。ついでにScrollObserverのコンストラクタの引数もNodeListにします。

class SplitTextAnimation {
  constructor(el /* これはElement */) {
    this.el = el;
    // ...そのまま
  }
// ...そのまま
}

class ScrollObserver {
  constructor(els /* これはNodeList */, cb, options) {
    this.els = els;
    // ...そのまま
  }
// ...そのまま
}

使用している部分は次のようになります。

document.addEventListener("DOMContentLoaded", function () {
  const cb = function (el,isIntersecting) {
    const ta = new SplitTextAnimation(el);
    if (isIntersecting) {
      ta.fadeUpText();
    }
  }
  const so = new ScrollObserver(document.querySelectorAll('.target'),cb);
});

コメントありがとうございます!!
実際に検証してみたのですが、確かにコンストラクタ内でqueryselestorしなくても
引数に渡す時にすれば良い話でした。。
お恥ずかしながらその発想が思い浮かばず、逆に自分で自分の首を絞めてしまっていました。
御提案頂いた方法をとることで、問題なく動作しかつ、一度インスタンス化すれば
targetクラスを持つ要素全てを監視してアニメーションを適応することができました!!
ありがとうございます!!

ログインするとコメントできます