가로 스크롤 카드 배너 만들기

ChoiYongHyeun·2024년 1월 16일
0

망가뜨린 장난감들

목록 보기
6/19
post-thumbnail

이번 실습은 해당 연습을 꼼꼼하게 본 후 , 기억을 더듬으며 실습해나간 일지입니다.
자바스크립트로 11분만에 카드 스크롤 애니메이션 마스터하기

html & css

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <div class="main-content">
      <div class="before-content"></div>
      <div class="sticky-section">
        <div class="sticky-content">
          <div class="card-wrapper">
            <div class="card">
              <div class="front">
              </div>
              <div class="back"></div>
            </div>
            <div class="card">
              <div class="front">
              </div>
              <div class="back"></div>
            </div>
            <div class="card">
              <div class="front">
              </div>
              <div class="back"></div>
            </div>
            <div class="card">
              <div class="front">
              </div>
              <div class="back"></div>
            </div>
          </div>
        </div>
      </div>
      <div class="after-content"></div>
    </div>
  </body>
  <script src="scrip.js"></script>
</html>

기본 구조는 다음과 같다.

무슨 꾸불꾸불 div 용 같이 생겼다.

가로 스크롤을 이용한 카드 배너의 주 아이디어는 position : sticky 를 이용하는 것이다.

전체적인 아이디어는 위에 올려둔 동영상에 잘 나와있다. html , css 도 해당 영상을 참조하길 바란다

css 에 대한 부분은 글 맨 하단에 올려두겠다.

Javascript

class FlipCard {
  constructor(sticky) {
    this.sticky = sticky;
    this.wrapper = sticky.firstElementChild;
    this.cards = this.wrapper.children;
    this.cardsNum = this.cards.length;
    this.section = this.sticky.parentNode;
    this.start = 0;
    this.end = 0;
  }

  init() {
    this.start = document.querySelector('.before-content').offsetHeight;
    this.end = this.section.offsetHeight;
    this.stepLength = this.end / 8;
    this.rotateStart = this.stepLength * 4;
    this.rotateRange = this.end - this.rotateStart;
  }

  move(currentScroll) {
    const { start, sticky, end } = this;
    const viewHeight = sticky.offsetHeight;
    let moveRatio;
    if (currentScroll < start) {
      moveRatio = ((currentScroll + viewHeight - start) / end) * 100;
    } else {
      moveRatio = (currentScroll / end) * 100;
    }
    const offset = Math.min(moveRatio, 100);
    this.wrapper.style.transform = `translateX(${100 - offset}%)`;
  }

  rotate(currentScroll) {
    const { cards, cardsNum, rotateStart, end, stepLength, rotateRange } = this;

    if (currentScroll < rotateStart || currentScroll > end) return;
    const scrollScaled = ((currentScroll - rotateStart) / rotateRange) * 100;
    const index = Math.floor(scrollScaled / (100 / cardsNum));
    const targetCard = cards[index];
    const stepScaled = currentScroll - rotateStart - stepLength * index;
    const rotateDegree = (180 * stepScaled) / stepLength;

    targetCard.style.transform = `rotateY(${rotateDegree}deg)`;
  }
}

const $sticky = document.querySelector('.sticky-content');
const flipCard = new FlipCard($sticky);
flipCard.init();

window.addEventListener('scroll', () => {
  console.log(window.scrollY);

  flipCard.move(window.scrollY);
  flipCard.rotate(window.scrollY);
});

window.addEventListener('resize', () => {
  flipCard.init();
});

가로 스크롤을 유지할 FlipCard 라는 클래스를 생성해준다.

카드 배너를 만들기 위해 두 가지 이벤트 핸들러 콜백 함수가 필요하다.

하나는 카드를 천천히 나타나게 하는 함수와

하나는 카드가 천천히 돌아가게 만드는 함수이다.

초기 설정

class FlipCard {
  constructor(sticky) {
    this.sticky = sticky;
    this.wrapper = sticky.firstElementChild;
    this.cards = this.wrapper.children;
    this.cardsNum = this.cards.length;
    this.section = this.sticky.parentNode;
    this.start = 0;
    this.end = 0;
  }

  init() {
    this.start = document.querySelector('.before-content').offsetHeight;
    this.end = this.section.offsetHeight;
    this.stepLength = this.end / 8;
    this.rotateStart = this.stepLength * 4;
    this.rotateRange = this.end - this.rotateStart;
  }

초기 설정에서 start , end 부분은 카드가 나타나기 시작할 부분을 의미한다.

start 부분은 sticky 태그가 시작하는 부분으로 하기 위해 sticky 태그 위에 있는 태그의 높이로 설정해주고 end 부분은 sticky 태그가 애니메이션을 할 영역으로 지정해주었다.

사실 end 부분을 start + sticky 영역 으로 해야하나 고민했었는데
window.scrollY 는 브라우저의 최상단을 기준으로 계산되기 때문에 start 부분을 더해주지 않았다.

이를 통해 내 viewport 가 밑으로 쭉쭉 내려가다가 end 부분이 내 viewport 하단에 닿는 순간 애니메이션이 중단 되게 하였다.

그리고 카드가 회전 할 수 있도록 stepLength , rotateStart , rotateRange 를 만들어주었다.

rotate 에 대한 내용도 위의 유튜브 참조하길 바란다.

조금만 가볍게 설명하면 전체 카드의 이벤트들이 이뤄지는 길이를 NN 이라고 뒀을 때

내가 임의로 KK 개의 step 들로 나눈다.

이 때 각 step 들의 길이는 하나의 카드가 180도 모두 도는 것으로 기준을 삼는다.

나는 스텝들을 8개로 잡아주었으며 첫 번째 카드가 회전을 시작하는 것은 5번째 스텝부터 회전을 시작하도록 하였다.

그렇기 때문에 stepLength전체 이벤트가 일어나는 길이 / 8 , 카드 회전이 최초로 일어날 rotateStart전체 이벤트가 일어난 최초의 포인트 + 스텝 * 4 로 설정해주었다.

카드를 스크롤에 따라 왼쪽으로 이동시키기

CSS 에 대한 설명을 하지 않았지만 현재 카드들을 감고 있는 card-wrapper 는 눈에 보이는 영역으로부터 transform : translateX(100%) 되어 있는 상태이다.

move(currentScroll) {
    const { start, sticky, end } = this;
    const viewHeight = sticky.offsetHeight;
    let moveRatio;
    if (currentScroll < start) {
      moveRatio = ((currentScroll + viewHeight - start) / end) * 100;
    } else {
      moveRatio = (currentScroll / end) * 100;
    }
    const offset = Math.min(moveRatio, 100);
    this.wrapper.style.transform = `translateX(${100 - offset}%)`;
  }

최대한 if/else 문을 안쓰고 어떻게 할 수 있을까 고민했지만

리팩토링은 기능 구현을 모두 한 후에 하기로 했다.

두 가지 조건이 필요하다.

하나는 내 뷰포트의 상단바가 start 보다 위에 있을 경우고, 하나는 아래에 있는 경우이다.

아래에 있는 경우는 단순하게 구현 할 수 있다.

그저 moveRatio = (currentScroll / end) * 100 를 통해 얼만큼 이동했는지를 구해주면 된다.

이 때 이벤트 핸들러는 내 스크롤이 이벤트 기간을 지났을 때에도 스크롤을 확인하기 때문에

    const offset = Math.min(moveRatio, 100);
    this.wrapper.style.transform = `translateX(${100 - offset}%)`;

Math.min 으로 100이 넘어가는 경우엔 100으로 설정해주었다.

조금 골치 아팠던 부분은 스크롤바가 start 보다 위에 있는 경우였다.

해당 이미지에서 파란선이 그어진 영역이 start 가 시작하는 부분이다.

내 스크롤이 start 보다 위에 있을 경우엔 내 뷰포트의 하단부위를 기준으로 start 와의 거리를 기준으로 잡아주었다.

뷰포트의 하단부위가 start 보다 높이 있을 때엔 어차피 카드의 애니메이션이 보이지 않기 때문에 얼마나 멀리있든 상관없다.

뷰포트의 하단부위가 start 보다 낮게 있을 때는 카드의 애니메이션이 보여야 한다.
이 때 start 부터 뷰포트의 하단 부위가 애니메이션이 진행된 길이이기 때문에 기준을 뷰포트의 하단부위로 하였다.

되게 초등학생 수준의 간단한 내용인데 생각이 안나서 30분 내내 펜을 잡고 있었다. :(

카드 180도 회전 시키기

  rotate(currentScroll) {
    const { cards, cardsNum, rotateStart, end, stepLength, rotateRange } = this;

    if (currentScroll < rotateStart || currentScroll > end) return;
    const scrollScaled = ((currentScroll - rotateStart) / rotateRange) * 100;
    const index = Math.floor(scrollScaled / (100 / cardsNum));
    const targetCard = cards[index];
    const stepScaled = currentScroll - rotateStart - stepLength * index;
    const rotateDegree = (180 * stepScaled) / stepLength;

    targetCard.style.transform = `rotateY(${rotateDegree}deg)`;
  }

으악 코드가 너무 지저분하다.
나중에 꼭 리팩토링 해야지

카드가 180도 회전 하기 위해선 우선 2가지 조건이 있어야 한다.
1. 현재 스크롤이 rotateScroll 보단 아래에 있어야 함
2. 현재 스크롤이 end 보단 위에 있어야 함

그래서 해당 조건을 만족하지 않으면 early return 시켜주었다.

그 다음 카드의 회전을 구하기 위해선, 현재 스크롤의 위치를 정규화 시켜줘야했다.

전체 이벤트를 8개의 스텝으로 나누면 이런 느낌이다.

빨간 벽돌은 각 카드가 회전이 일어나는 액션이다.

뭐 현재 커서의 값을 if/else 값이나 swicth case 문을 이용해서 할 수 도 있었지만

공식처럼 딱 나눠 떨어뜨릴 수 있기 때문에 계산해봤다.

    const scrollScaled = ((currentScroll - rotateStart) / rotateRange) * 100;
    const index = Math.floor(scrollScaled / (100 / cardsNum));

현재 커서 위치에서 rotateStart 값을 빼준다면 rotateStart 에서부터 커서가 이동한 거리를 구할 수 있다.

그러면 해당 값을 rotateRange 로 나눠주면 전체 범위 중 현재 몇 퍼센트나 진행 됐는지를 확인 할 수 있다.

이 때 진행률을 카드 개수로 나눈 몫을 구하면 그것이 회전이 일어나야 할 카드의 인덱스가 된다.

    const stepScaled = currentScroll - rotateStart - stepLength * index;
    const rotateDegree = (180 * stepScaled) / stepLength;

    targetCard.style.transform = `rotateY(${rotateDegree}deg)`;

이후 다시 해당 커서 값을 step 위에서 다시 정규화 시켜주면 각 스크롤 마다 얼만큼씩 회전 해야 하는지를 구할 수 있다.

window.addEventListener('scroll', () => {
  console.log(window.scrollY);

  flipCard.move(window.scrollY);
  flipCard.rotate(window.scrollY);
});

window.addEventListener('resize', () => {
  flipCard.init();
});

이후 해당 메소드들을 이벤트 핸들러에 등록해주면 된다.


업데이트가 필요한 부분

스크롤을 정상적으로 내리면 카드가 잘 돌아가지만

스크롤을 매~우 빠르게 내리면 스크롤을 탐지하는 이벤트 핸들러가 스크롤을 모두 인식하지 못해 카드가 전부 돌아가지 못하는 경우가 발생한다.

그리고 카드들이 모두 딱 180 도에 맞게 돌아가는 것이 아니기 때문에 카드가 돌아가고 나서 크기가 모두 제각각이다.

흠 .. 어떻게 해결할지 내일 아침에 일어나서 고민해봐야겠다.

그리고 리팩토링도 같이 해야지

기능 업데이트 (카드의 크기 고정하기)

이전에 스크롤을 너무 빨리 돌리면 카드가 모두 회전하지 못하는 이유가 발생했다.

그 이유는 스크롤을 너무 빨리 돌렸을 때 이벤트 핸들러의 함수가 돌아갈 시간보다 빠르게 이벤트 핸들러가 재호출되어 카드가 돌아가지 못했기 때문이다.

그래서 카드가 뒤집어 질 때 이전 카드는 180도로 모두 돌려놓고 이후 카드는 0도로 초기화 시켜두도록 하였다.

  rotate(currentScroll) {
    const { cards, cardsNum, rotateStart, end, stepLength, rotateRange } = this;

    if (currentScroll < rotateStart) {
      // 스크롤이 시작 지점보다 위에 있을 땐 첫 번째 카드 초기화
      cards[0].style.transform = 'rotateY(0deg)';
      return;
    }
    if (currentScroll > end) {
      // 스크롤이 종료 지점보다 아래에 있을 땐 마지막 카드 모두 뒤집어두기
      cards[cardsNum - 1].style.transform = 'rotateY(180deg)';
      return;
    }
    const scrollScaled = ((currentScroll - rotateStart) / rotateRange) * 100;
    const index = Math.floor(scrollScaled / (100 / cardsNum));
    const targetCard = cards[index];
    const stepScaled = currentScroll - rotateStart - stepLength * index;
    const rotateDegree = (180 * stepScaled) / stepLength;

    targetCard.style.transform = `rotateY(${rotateDegree}deg)`;

    if (index !== 0) {
      // 현재 뒤집어지고 있는 카드의 이전은 180도로 모두 돌려두기
      cards[index - 1].style.transform = 'rotateY(180deg)';
    }
    if (index !== cardsNum - 1) {
      // 현재 뒤집어지고 있는 카드의 다음 카드는 0도로 돌려두기
      cards[index + 1].style.transform = 'rotateY(0deg)';
    }
  }

구우웃 ~


CSS

@import url('https://fonts.googleapis.com/css2?family=Hahmlet:wght@300&display=swap');

* {
  scroll-behavior: smooth;
}

html,
body {
  padding: 0px;
  margin: 0px;
  font-family: 'Hahmlet', serif;
  background-color: #1e3a4075;
}

.main-content {
  margin: 0 auto;
  width: 90vh;
  height: 100vh;
  background-color: #1e3a40;
}

.sticky-section {
  width: 90vh;
  height: 300vh;
}

.before-content,
.after-content {
  width: 90vh;
  height: 100vh;
  background-color: #a6855d;
  display: flex;
  justify-content: center;
  align-items: center;
  font-size: 100px;
  color: white;
  font-weight: 700;
}

.after-content {
  align-items: flex-start;
}

.sticky-content {
  position: sticky;
  top: 0px;
  background-color: #a6855d;
  width: 100%;
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  overflow-x: hidden;
}

.card-wrapper {
  position: absolute;
  width: 100%;
  height: 20vw;
  display: flex;
  transform: translateX(100%);
  justify-content: space-between;
  align-items: center;
}

.card {
  width: 24%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  position: relative;
  transform-style: preserve-3d;
  backface-visibility: hidden;
  transform: perspective(100vw);
}

.front,
.back {
  width: 100%;
  height: 100%;
  border-radius: 1vw;
  display: flex;
  justify-content: center;
  align-items: center;
  position: absolute;
}

/* 기본적으로 카드들을 뒤집은 채로 차곡차곡 샇아야 함 */

.front {
  background-color: #1e3a40;
  transform: rotateY(180deg);
  color: white;
  font-size: 1rem;
  font-weight: 700;
}

.back {
  background-color: #a64833;
}
profile
빨리 가는 유일한 방법은 제대로 가는 것이다

0개의 댓글