Moe's Tech Blog

[디자인 페턴] Factory를 이용해 scroll animation 코드 관리하기 본문

Software Design Pattern/Journal

[디자인 페턴] Factory를 이용해 scroll animation 코드 관리하기

moe12825 2022. 3. 22. 13:43

필자는 회사에서 다음의 웹사이트 처럼 scroll animation 코드를 구현을 하고 싶습니다. 

 

구글의 everydayrobots.com

 

한꺼번에 하고 싶었으나, 기술적인 한계로 작게 그리고 주먹구구식으로 시작했습니다. 요소가 화면에 보여지면은 class ‘data-animate’를 요소에 삽입해 애니메이션을 하나씩 trigger 했습니다. 다른 코드블록에 에니메이션을 적용하고 싶을땐 코드를 복사 그리고 붙여넣기를 했습니다.

// CSS 애니메이션
function runAnimations() {
    $(".ANIMATION_BLOCK .menu_item").attr('data-animate', 'fade-in-up');

    $(".ANIMATION_BLOCK .search_bar").attr('data-animate', 'fade-in-up');

    $(".ANIMATION_BLOCK .button").attr('data-animate', 'fade-in-up');
};

$burger.click(function () {
	...
    if ($mobileNavigationMenu.hasClass('open')) {
        runAnimations();
    } else {
        resetAnimations();
        ...
    }
});
// 카운터 애니메이션
(function() {
  const animationDuration = 2000;
  const options = {
    root: null,
    rootMargin: '0px',
    threshold: 0.3
  };
  ...

  function callback(entries, observer) {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        $($component).find(".number").each(function() {
          $(this).prop('Counter',0).animate({
            Counter: $(this).prop('CounterOriginalValue')
          }, {
            duration: animationDuration,
            easing: 'linear',
            step: function (now) {
              applyAnimationStep(this, now);
            },
            complete:function(){
              applyAnimationStep(this, $(this).prop('CounterOriginalValue'));
            }
          });
        });
      } else {
        $(ourImpact).find(".number").each(function() {
          $(this).prop('Counter',0);
          $(this).stop();
        });
      }
    });
  }

  initNumberAnimation();
  const observer = new IntersectionObserver(callback, options);
  observer.observe(document.getElementById('COMPONENT_ID'));
})();

 

주먹구구식이다보니 시간이 지나면서 문제가 생겼습니다. 사용하는 빈도가 올라가면서 수정하는 횟수가 많아지고 비슷했던 코드가 조금씩 달라져갔습니다. 그뿐만이 아니였습니다. 한군데에 모을려고 해도 성격이 다른 카운터 애니메이션하고 CSS hover 애니메이션을 한군데로 어떻게 모을지 모르고 계속 암기를 해야 했었습니다. 그리고 가독성이 점점 떨어져 갔습니다. 필자의 머리는 아파져갔고 여기에 해결책이 절실했습니다.

 

해결하기 전에

 

Udemy에서 팩토리 디자인 페턴 비디오를 보았습니다. 강사의 말씀에 의하면 펙토리 디자인 페턴은

  1. 객체 생성 로직이 복잡하게 되는것을 최소화 하게 해줍니다
  2. 비슷한 특성을 지닌 객체들의 생성 로직들을 한군데로 모여줍니다
  3. 가독성을 향상 시켜줍니다

필자는 강의와 다음의 예를 보며 레가시 코드를 개선 했습니다.

 

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }

  toString() {
    return `x: ${this.x}, y: ${this.y}`;
  }
}

class PointFactory {
  static newCartesianPoint(x, y) {
    return Point(x,y);
  }

  static newPolarPoint(rho, theta) {
    return Point(rho * Math.cos(theta), rho * Math.sin(theta));
  }
}

const p1 = PointFactory.newCartesianPoint(1,2); 
const p2 = PointFactory.newPolarPoint(1,2); 
console.log(p1, p2);

레거시 코드 개선 후

class CounterAnimation {
  #$number;
  #isInteger;
  #requestedAnimation;

  constructor($component, isEnabled, delay=0, duration=2000) {
    this.$component = $component;
    this.isEnabled= isEnabled;
    this.delay = delay;
    this.duration = duration;

    this.#init();
  }

  #init() {
    this.#$number = this.$component.querySelector(".number");
    this.#isInteger = this.#$number.textContent.indexOf(".") === -1;
    this.#$number.CounterEnd_ = this.isInteger ? parseInt(this.#$number.textContent.replace(",", "")) : parseFloat(this.#$number.textContent.replace(",", ""));
    this.#applyAnimationStep(0);
  }

  #applyAnimationStep(count) {
    const commaSeparateNumber = (val) => {
      while (/(\d+)(\d{3})/.test(val.toString())){
        val = val.toString().replace(/(\d)(?=(\d\d\d)+(?!\d))/g, "$1,");
      }
      return val;
    }

    if(this.#isInteger) {
      this.#$number.textContent = commaSeparateNumber(Math.round(count));
    } else {
      let [wholeNumber, decimal] = count.toFixed(1).split(".");
      wholeNumber = commaSeparateNumber(Math.round(wholeNumber));
      this.#$number.textContent = (wholeNumber + "." + decimal); // Default to 1 decimal digit
    }
  }

  trigger() {
    if (!this.isEnabled) {
      return;
    }

    let start, previousTimeStamp;
    let done = false;

    const step = (timestamp) => {
      if (start === undefined) start = timestamp;

      const elapsed = timestamp - start;
      const elapsedNormalized = (timestamp - start) / this.duration;

      if (previousTimeStamp !== timestamp) {
        const count = Math.min(elapsedNormalized * this.#$number.CounterEnd_, this.#$number.CounterEnd_);
        this.#applyAnimationStep(count);

        if (count === this.#$number.CounterEnd_) done = true;
      }

      // Stop animation after duration seconds
      if (elapsed < this.duration) window.requestAnimationFrame(step);
    }

    setTimeout(_ => {
      this.#requestedAnimation = window.requestAnimationFrame(step);
    }, this.delay);
  }

  reset() {
    if (!this.#requestedAnimation) return;
    window.cancelAnimationFrame(this.#requestedAnimation);
    this.#applyAnimationStep(0);
  }
}

class CSSAnimation {
  constructor($component, isEnabled, type, delay) {
    this.$component = $component;
    this.isEnabled= isEnabled;
    this.delay = delay;
    this.type = type;

    this.#init();
  }

  #init() {
    this.$component.classList.add(`data-animate-${this.type}`);
  }

  trigger(delay) {
    if (!this.isEnabled) {
      return;
    }

    this.$component.style.setProperty("--transition-delay", `${this.delay}ms`);
    this.$component.classList.add(`data-animate-active`);
  }

  reset() {
    this.$component.classList.remove(`data-animate-active`);
  }
}

export class AnimationFactory {
  static none() {
    return null;
  }

  static newCounter($component, delay) {
    return new CounterAnimation($component, true, delay);

  }

  static newFadeInUpward($component, delay) {
    return new CSSAnimation($component, true, "fade-in-upward", delay);

  }

  static newFadeInDownward($component, delay) {
    return new CSSAnimation($component, true, "fade-in-downward", delay);
  }
}

export class AnimationElement {
  constructor(animation, $childElements = []) {
    this.$childElements = $childElements;
    this.animation = animation;
  }

  addChild($animationElement) {
    this.$childElements.push($animationElement);
  }

  animate($parentComponent) {
    const options = {
      root: null,
      rootMargin: '0px',
      threshold: 0.3
    };

    const observer = new IntersectionObserver((entries) => {

      for (const entry of entries) {
        if (!entry.isIntersecting) {
          continue;
        }

        for (let i = 0; i < this.$childElements.length; i++) {
          const $element = this.$childElements[i];
          $element.animation.trigger();
        }
      }
    }, options);

    observer.observe($parentComponent);
  }
}

개선 후 코드가 깔끔해졌습니다. 만든 에니메이션이 잘 모아져 있을 뿐만 아니라 사용할때 필요한 set up 코드를 암기 하지 않아도 되었습니다. 그리고 보다 추상적으로 만들었기 때문에 animation을 apply하고 싶은 요소에 쉽게 유지보수 걱정 없이 apply 할 수 있습니다. 이것으로 인해 필자는 위에 있는 스크롤 애니메이션의 구현이 한걸음 더 가능해졌습니다.

 

결과

여기를 클릭 하시면 결과물을 보실 수 있습니다.

그리고 여기를 클릭 하시면 결과물의 리포지토리 파일들을 보실 수 있습니다.

아쉬운점

    1. 회사 코드 구조의 한계로 컴포넌트식으로 적용하지 못했었습니다. 이 부분에 대해서는 스스로 자그마한 프로잭트를 만들어 나아가야 합니다.
    2. 컴포넌트의 부모 클래스를 만든다음, 부모 클래스 인스턴스 속성에 어떤 animation 할지 저장을 하면은 깔끔해지고  쉽게 scroll animation 구현   있을것 같습니다.
    3. 주먹구구식으로 써서 코드가 불안정 합니다. 마치 있지 말아야 할곳에 있는것 같은 느낌이 듭니다. 처음에야 빠르고 좋은데 나중에 다 무너집니다. 공장 도면처럼 깔끔하게 코드를 짜는 방법을 배우면 이것을 방지 할 수 있지 않을까 생각이 듭니다.

배운점