프로그래밍 공부하기

전체 화면 애니메이션 만들기 본문

Web/[JS] FrontEnd

전체 화면 애니메이션 만들기

ihl 2021. 3. 27. 02:07

  TJ 미디어의 노래 번호를 검색하고 노래를 선택하여 나만의 리스트를 관리하는 프로젝트에 참가하게 되었다. 메인페이지 구현을 담당하였는데, 팀원들이 노래방 답게 화려한 효과들을 원해서 메인페이지에 미러볼 같은 애니메이션을 넣어보자는 생각을 갖게되었다. 

 

1. 화면 위에 무작위의 원 생성하기

무작위의 원 생성

  처음부터 미러볼 애니메이션을 넣기는 어려워서 2가지 종류의 원을 랜덤한 위치에 생성하는 애니메이션을 먼저 작성해보기로 하였다. 

 

1) 원 디자인

.circle-green{
  position: absolute;
  pointer-events: none;
  background-image: linear-gradient(to right, #2ef27466 0%, #54d88066 100%);
}

.circle-pink{
  position: absolute;
  pointer-events: none;
  background-image: linear-gradient(to right, #ff33f066 0%, #bc98bb66 100%);
}

  나는 원의 디자인을 CSS로 만든 후 JavaScript에서 해당 클래스이름을 가진 Span을 생성하기로 하였다. 따라서 위와 같이 먼저 원의 클래스 이름과 디자인을 CSS 파일에 넣어주었다.

 

2) 랜덤으로 두가지 원 만들기

const createCircle = () => {
  const stage = document.querySelector('.home-wrapper'); //원이 놓여질 Element
  const circle = document.createElement('span'); //원
  const circleNames = ['circle-green', 'circle-pink'];
  const circleName = circleNames[getRandomIntInclusive(0, 1)]; //랜덤으로 원 종류 선택

  circle.classList.add(circleName);
  circle.style.borderRadius = 100 + 'px'; //원의 속성
  circle.style.zIndex = 5;

  const size = Math.random() * 50; //랜덤한 사이즈 지정

  circle.style.width = 20 + size + 'px';
  circle.style.height = 20 + size + 'px';

  const {innerWidth, innerHeight} = window; //화면 크기 구하기
  circle.style.left = Math.random() * (innerWidth - 100) + 'px'; //랜덤한 위치
  circle.style.top = Math.random() * (innerHeight - 100) + 'px';

  stage.appendChild(circle); //붙이기

  setTimeout(() => { //5초후 사라짐
    circle.remove();
  }, 5000)
}

setInterval(createCircle, 300); //0.3초마다 생성

  랜덤으로 2 종류의 원을 0.3초마다 만드는 코드이다. 먼저 랜덤으로 두 종류의 원을 만든다. 이를 위해 원의 클래스 이름을 담은 배열 circleNames를 만들고, circleNames의 index중 하나를 랜덤으로 고르는 getRandomIntInclusive()를 사용한다. 해당 함수를 MDN의 Math.random 페이지를 참고하였다. 나의 경우 2가지 종류이므로 getRandomIntInclusive의 결과는 0 혹은 1이될 것이다.

 

  랜덤한 사이즈를 정하기 위해서 Math.random()함수를 사용하였다. Math.random()은 0~1사이의 소수를 리턴하므로 사용에 주의하여야 한다. 나는 원의 크기 범위를 20~70px로 잡았기 때문에 위와 같은 형태로 코드를 작성하였다.

 

  랜덤한 위치를 정하기 위해서도 Math.random()함수를 사용하였다. 다만, 원이 화면 사이즈를 넘어 스크롤이 생성되지 않게 만들어야 하므로 window 객체에서 innerWidth와 innerHeight를 가져와서 위치 범위 지정에 사용하였다.

 

 

2. 웹브라우저에서의 좌표

(left, top)

  웹브라우저상의 좌표평면은 위와 같다. 화면의 왼쪽상단을 (0, 0)으로 잡고 우측방향으로 x가 +, 하단으로 y가 +되는 식이다. 즉 (화면 왼쪽 끝에서 얼마나 떨어져있는가, 화면 최상단에서 얼마나 떨어져있는가) 가 좌표가 된다.

 

(right, top)

  내가 생각하는 애니메이션의 경우 왼쪽 상단에서 시작하는 것이 아니라 우측 상단에서 시작한다. 따라서 편의상 화면의 우측 끝을 기준으로 좌표를 잡았다. 즉, 내가생각하는 좌표평면은 위의 그림과 동일하다. 이 그림에서 보듯이 나는 원을 (0%, 0%) 좌표에서 (100%, 100%)좌표 방향으로 이동시켜야하는 것이다. 

 

 

3. 하나의 경로에서 움직이기

 

하나의 라인에서 움직이는 원

  나는 처음부터 많은 원을 생성하기 보단 하나의 경로에서 움직이는 생성하고자 하였다. 막상 코드로 치니 top이 100%일 때는 원 자체의 크기 때문에 스크롤이 생성되므로 목적지 좌표를 (100%, 90%)로 줄이기로 하였다. 코드는 다음과 같다.

 

const createCircleVer1 = () => {
  const stage = document.querySelector('.home-wrapper');
  const circle = document.createElement('span');
  const circleNames = ['circle-green', 'circle-pink'];
  const circleName = circleNames[getRandomIntInclusive(0, 1)];

  circle.classList.add(circleName);
  circle.style.borderRadius = 100 + 'px';
  circle.style.width = 45 + 'px';
  circle.style.height = 45 + 'px';
  circle.style.zIndex = 5;

  circle.animate([
    { right: '0%', top: '0%', },
    { right: '100%', top: '90%' },
  ], 10000);

  stage?.appendChild(circle);
  setTimeout(() => {
    circle.remove();
  }, 10000);
};

  

 

4. 여러 경로에서 움직이기

여러경로 아이디어

  원을 여러 경로에서 생성하고 움직이게하기 위해선 어떻게 해야할까? 나는 시작점을 같은 비율로 이동시키면서 기울기는 동일한 경로를 for문을 통해 여러개를 만들어야 겠다고 생각했다. 해당 아이디어를 그림으로 나타내면 위와 같다.

 

const createCricleVer2 = (i) => {
  //원 생성 코드생략
  circle.animate([
    { right: `${(17 * i)}%`, top: '0%', },
    { right: '100%', top: `${90 - (i * 17)}%` },
  ], 10000);

  setTimeout(() => {
    circle.remove();
  }, 10000);
};

const createCricleManyAll = () => {
  for (let i = 0; i < 6; i++) {
    createCircleVer2(i);
  }
};

  위 아이디어를 구현한 코드는 위와 같다. 나는 시작점을 17%씩 이동시키기로 하였고, 17* 5 = 85이므로 for문의 i를 0~5까지 반복시켜 17%와 곱했다. 도착점의 경우 직선의 기울기는 'y 증가량 / x 증가량' 이므로 두 직선의 시작점이 17% 차이난다면 도착점도 17%차이난다고 생각하였다.

 

아이디어 실행결과

  코드를 동작시키면 위와같이 동작한다. 대체적으로 올바른 경로로 가고 있지만, 경로 중 가장 상단의 경로가 살짝 이탈해있는 문제점이 있다. 그 이유는 top 좌표의 경우 아래(90%)지점부터 17%씩 감소시켰기 때문에 화면의 가장 위에 보이는 경로는 최상단에서 5%밖에 떨어져있지 못하기 때문이다. 해당 경로도 예쁘게 나오기 위해서는 해당 경로의 도착점이 (100%, 17%)가 되어야 한다.

 

 

5. 여러 경로에서 움직이기 개선

여러 경로 아이디어2

  최상단의 경로가 올바르게 작동하지 않는 문제를 해결하기 위해 이번에는 처음부터 높이와 너비를 6등분한 후 경로를 생성하기로 하였다. 

 

const createCircleVer2 = (i, n) => {
  //circle 생성코드 생략..
  const { innerWidth, innerHeight } = window;
  const interval_w = (innerWidth / n)
  const interval_h = (innerHeight * 0.9 / n);

  const range_w = interval_w * i;
  const range_h = interval_h * i;

  circle.animate([
    { right: `${(range_w)}px`, top: '0%', },
    { right: '100%', top: `calc(90% - ${(range_h)}px)`, },
  ], 10000);
  //stage append, remove 코드 생략
}

const createCricleManyAll = () => {
  for (let i = 0; i < 6; i++) {
    createCircleVer2(i, 6);
  }
};

  높이와 너비 6등분은 window 객체의 innerWidth, innerHeight 속성을 활용하여 작성하였다. 단, Height의 경우 화면 전체를 쓰는 것이 아닌 90%만 사용하므로 0.9를 곱한 후 6으로 나누어주었다. 이렇게 작성하면 i에 따라서 화면 우측 끝지점으로부터 0/6, 1/6, 2/6, 3/6, 4/6, 5/6 지점의 width, 화면 하단 90%으로부터 0/6, 1/6, 2/6, 3/6, 4/6, 5/6 지점의 Height를 px단위로 구할 수 있다.

 

  애니메이션의 좌표를 작성할 때 window를 직접 6등분하여 좌표를 px단위로 구했기 때문에 단위를 변경해주었다. 또한 도착점의 경우 90%지점에서 계산한 부분만큼 빼주기 위해 calc를 사용하였다.

 

아이디어2 구현 결과

  코드를 동작시킨 결과 원이 올바른 경로로 잘 가고 있는 것이 보인다. 가장 좌측 경로의 원이 겹쳐보이는 것은 다른 경로들에 비해 너무 짧은 경로에 같은 시간간격으로 원을 생성하였기 때문이다. 내 생각에는 미러볼의 경우 미러볼에 가까울 수록 원 사이의 간격이 좁기 때문에 이렇게 두어도 상관없다고 생각하여 이대로 두었다. 원의 간격이 모든 경로에서 동일해야 한다면 해당 부분을 생각하여 추가적인 코드를 작성하면 된다.

 

  또한 가장 좌측 경로의 시작점이 다른 경로에 비해 약간 떨어져 보이는 것은 원의 크기때문에 발생하는 착시?인 것 같다. 저 부분이 예쁘게 보이기 위해서는 가장 좌측 경로에 한해 top이 -인 시작점을 주어야할 것이다. 착시현상?이기 때문에 해당 부분의 처리는 하지 않았지만 혹시 개선이 필요하다면 해당 부분에 대한 추가적인 코드를 작성하면 된다.

 

 

6. 나머지 경로 추가하기

나머지 경로 아이디어

  나머지 경로는 어떻게 만들 수 있을까? 이전 과정에서 경로를 +방향으로 움직인 것처럼, 이번에는 -방향으로 움직인다면 동일한 기울기의 경로를 구할 수 있을 것이다.

 

const createCricleManyAll = () => {
  for (let i = -6; i < 6; i++) {
    createCircleVer2(i, 6);
  }
};

  -방향으로 움직이기는 위와 같이 for문의 범위를 수정하는 것 만으로도 간단히 구현할 수 있다. 그러나 이 방법에는 치명적인 단점이 있다. 바로, 시작점이 화면을 벗어나기 때문에 스크롤이 발생한다는 점이다. 

 

나머지 경로 아이디어 개선

  이를 해결하기 위해선 경로의 시작점을 right좌표가 0인 지점으로 잡아야 한다. 내가 만들고 싶은 경로는 다른 경로들과 기울기는 동일하다. 또한 위에서 i에 -범위를 추가하는 것으로 해당 경로가 지나는 하나의 점을 구했었다. 즉, 내가 만들고 싶은 경로의 직선의 방정식(y = ax + b)을 구할 수 있다는 의미이다. 구해진 직선의 방정식에 right = 0을 대입한다면 시작점의 위치를 구할 수 있다.

 

  경로의 직선의 기울기는 (0%, 0%) ~ (100%, 90%) 경로의 기울기와 동일하다. 직선의 기울기는 'y증가량 / x 증가량' 이므로 'innerWidth / Height*0.9' 와 동일하다.

 

const createCircleVer3 = (i, n) => {
  //circle 생성코드 생략..
  const { innerWidth, innerHeight } = window;
  const interval_w = (innerWidth / n);
  const interval_h = (innerHeight * 0.9 / n);

  const range_w = interval_w * i;
  const range_h = interval_h * i;

  if (i < 0) {
    const a = interval_h / interval_w;
    const dot_w = range_w; //(dot_w, 0)을 지나는 직선. i때문에 이미 음수이다.
    const b = -(a * dot_w);

    const startHeight = b; //y = ax + b인데 x가 0인 경우
    const endWidth = ((innerHeight * 0.9) - b) / a; //x = y - b / a 인데 height가 90%

    circle.animate([
      { right: '0px', top: `${startHeight}px`, },
      { right: `${endWidth}px`, top: '90%', },
    ], 10000);

  } else {
    circle.animate([
      { right: `${(range_w)}px`, top: '0%', },
      { right: '100%', top: `calc(90% - ${(range_h)}px)`, },
    ], 10000);
    const x = 0;
  }
  //append, remove 코드 생략
};
  
  const createCricleManyAll = () => {
  for (let i =-6; i < 6; i++) {
    createCircleVer3(i, 6);
  }
};

  i가 음수인 경우의 분기하여 따로 처리하게 작성하였다. i가 음수인 경우 먼저 직선의 기울기를 구한다. 그 후 내가 만들고 싶은 경로의 한 점 dot_w를 구한다. +방향 경로와 대칭되므로 +방향 경로들의 간격인 range_w를 음수로 반전시킨 값일 것이다. 이 때 i가 -6~+6 범위 이므로 이미 음수로 반전되어있기 때문에 다시 음수로 반전시킬 필요는 없다. 마지막으로 y = ax + b에서 b를 구한다. 항을 이항시키면 b = y - ax가 나오는데, 직선의 방정식을 구하기 위해 사용한 좌표인 (dot_w, 0)은 y가 0이므로 b는 -ax가 되며 이는 -(a * dot_w)와 동일하다.

 

  구한 직선에 x(width) = 0을 대입하면 해당 직선에서 x가 0일 때의 y(top) 값을 구할 수 있다. 이는 startHeight가 된다. 반대로 y = 90%일 때의 x 값은 도착점이 될 것 이다. 90%는 innerHeight * 0.9 와 동일하므로 이를 이용하여 endWidth를 계산할 수 있다.

 

구현 결과

  결과를 보면 내가 원하는 대로 같은 기울기를 갖는 경로들을 지나는 원들의 애니메이션이 보인다! 내가 볼때 화면에 원이 너무 많아보이므로 경로간의 간격을 조금 조정해보겠다.

 

const createCricleManyAll = () => {
  for (let i = -4; i < 5; i++) {
    createCircleVer3(i, 5);
  }
};

  for문의 범위와 n의 값을 변경하는 것으로 간격조정을 할 수 있다. 

 

애니메이션 최종 결과

 

 

7. Remove Event

 

너무 많이 생성된 원

   다른 페이지에 갔다가 홈페이지로 돌아오니 원이 처음보다 더 많이 생성된 것을 발견하였다. 그 이유는 원을 생성하는 이벤트를 다른 페이지에 갈 때 제거하지 않아서, 다시 홈 페이지로 돌아오면 원을 생성하는 이벤트가 기존의 이벤트 까지 2개가 실행되고 있기 때문이다. 이를 해결하기 위해서는 홈 페이지에서 벗어나면 해당 이벤트를 제거하는 로직이 필요하다.

 

  useEffect(() => {
    const circleAnimationAllId = setInterval(createCricleManyAll, 900);
    return () => {
      clearInterval(circleAnimationAllId);
    };
  }, []);

  리액트 생명주기에서 컴포넌트 생성 시 작업은 componentDidMount(), 제거는 componentWillUnmount()라는 함수에 추가하면 된다. 나는 ReactHooks를 사용하고 있기 때문에 위 코드처럼 이벤트 생성, 제거 작업을 useEffect()에 넣어주었다.

 

최종 결과


  사실 미러볼이라 원으로 경로를 그렸어야 하는데 원으로 그리기위해선 추가적인 수학적 공부가 필요하므로 프로젝트 기간 상 직선으로 구현하는 것으로 만들게 되었다. 화면 전체를 다루는 애니메이션을 자유자재로 다루기 위해서는 수학 특히 그래프 지식이 필요해보인다. 중고등학생 때는 수학 배워서 뭐에다 쓰지 했는데 이렇게 쓰여지다니.. 역시 필요없는 공부는 없는 것 같다.


전체코드
import React, { useEffect } from 'react';

import './Home.css';

const Home = (props) => {
  const getRandomIntInclusive = (min, max) => {
    min = Math.ceil(min);
    max = Math.floor(max);
    return Math.floor(Math.random() * (max - min + 1)) + min; //최댓값도 포함, 최솟값도 포함
  };

  const createCircleVer2 = (i, n) => {
    const {innerWidth, innerHeight} = window;
    const stage = document.querySelector('.home-wrapper');
    const circle = document.createElement('span');
    const circleNamse = ['circle-green', 'circle-pink'];
    const circleName = circleNamse[getRandomIntInclusive(0, 1)];

    const interval_w = (innerWidth / n);
    const interval_h = (innerHeight * 0.9 / n);

    circle.classList.add(circleName);
    circle.style.borderRadius = 100 + 'px';
    circle.style.width = 45 + 'px';
    circle.style.height = 45 + 'px';
    circle.style.zIndex = 5;

    const range_w = interval_w * i;
    const range_h = interval_h * i;

    if (i < 0) {
      const a = interval_h / interval_w;
      const dot_w = range_w; //(dot_w, 0)을 지나는 직선.
      const b = -(a * dot_w);
  
      const startHeight = b; //y = ax + b인데 x가 0인 경우
      const EndWidth = ((innerHeight * 0.9) - b) / a; //x = y - b / a 인데 height가 90%

      circle.animate([
        { right: '0px', top: `${startHeight}px`, },
        { right: `${EndWidth}px`, top: '90%', },
      ], 10000);
  
    } else {
      circle.animate([
        { right: `${(range_w)}px`, top: '0%', },
        { right: '100%', top: `calc(90% - ${(range_h)}px)`, },
      ], 10000);
      const x = 0;
    }

    stage?.appendChild(circle);
    setTimeout(() => {
      circle.remove();
    }, 10000);
  };

  const createCricleManyAll = () => {
    for (let i = -4; i < 5; i++) {
      createCircleVer2(i, 5);
    }
  };

  useEffect(() => {
    const circleAnimationAllId = setInterval(createCricleManyAll, 900);
    return () => {
      clearInterval(circleAnimationAllId);
    };
  }, []);

  return (
    <div className="home-wrapper">
      <div className="home-title-container">
        <p className="neon">
          Create Your <span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span>
          <br></br>
          <span>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</span> Song List</p>
      </div>
    </div>
  );
};

export default Home;

 

'Web > [JS] FrontEnd' 카테고리의 다른 글

SVG  (0) 2021.04.26
Modal 만들기  (0) 2021.03.27
Redux  (0) 2021.02.22
이벤트 버블링과 이벤트 캡처  (0) 2021.01.17
innerHTML, innerText, textContent  (0) 2020.12.30
Comments