본문 바로가기
dev/javascript

[javascript] 자바스크립트 스크롤 캔버스 애니메이션 (애플 홈페이지)

by 최연탄 2023. 3. 15.
728x90
반응형

참고: https://css-tricks.com/lets-make-one-of-those-fancy-scrolling-animations-used-on-apple-product-pages/

애플은 제품 페이지에 적용한 매끈한 애니메이션으로 잘 알려져 있습니다. 예를 들어, 화면에서 스크롤하면 MacBook이 열리고 아이폰이 회전하면서 하드웨어를 보여주고 소프트웨어를 시연하며 제품이 어떻게 사용되는지에 대한 대화형 이야기를 들려줍니다.

여러분이 보는 대부분의 효과들은 HTML과 CSS로만 만들어지지 않습니다. 그럼 뭘까요? 알아내기 조금 어려울 수도 있습니다. 브라우저의 DevTools를 사용하더라도 <canvas> 요소뿐이기에 답을 찾기 힘듭니다.

이 효과 중 하나를 자세히 살펴보고 어떻게 만들어졌는지 알아보겠습니다. 그러면 이 마법같은 효과를 우리의 프로젝트에서 재현할 수 있습니다. 구체적으로 AirPods Pro 제품 페이지의 변화하는 조명 효과를 복제해 보겠습니다.

기본 컨셉

핵심 아이디어는 빠르게 바꿔치기하는 일련의 이미지로 애니메이션을 만드는 것입니다. 플립북처럼 말이죠. 복잡한 WebGL scene이나 고급 JavaScript 라이브러리가 필요하지 않습니다.

이미지의 각 프레임을 사용자의 스크롤 위치에 동기화하여 사용자가 페이지를 아래로 스크롤(또는 위로 스크롤)할 때 애니메이션을 재생할 수 있습니다.

Markup과 Style로 시작하기

이 효과에 대한 HTML 및 CSS는 매우 쉽습니다. 왜냐하면 JavaScript로 제어하는 <canvas> 요소 내부에서만 마법이 일어나기 때문입니다.

html:

<canvas id="hero-lightpass" />

CSS에서는 문서의 높이를 100vh로 지정하고 <body>를 그보다 5배 더 크게 만들어 이 작업을 수행하는데 필요한 스크롤 공간을 만듭니다. 또한 document의 배경색을 이미지의 배경색에 맞춥니다.

마지막으로 <canvas>를 중앙에 배치하고 viewport의 크기를 넘어가지 않도록 max-width와 height를 지정합니다.

css:

html {
  height: 100vh;
}

body {
  background: #000;
  height: 500vh;
}

canvas {
  position: fixed;
  left: 50%;
  top: 50%;
  max-height: 100vh;
  max-width: 100vw;
  transform: translate(-50%, -50%);
}

이제 페이지를 아래로 스크롤할 수 있으며(콘텐츠가 뷰포트 높이보다 작더라도) 현재 <canvas>는 뷰포트 맨 위에 있습니다. 이게 우리에게 필요한 HTML과 CSS 전부입니다.

이제 이미지 로딩 부분으로 이동하겠습니다.

올바른 이미지 가져오기

우리는 이미지 시퀀스로 작업할 것이기 때문에 이미지 디렉토리에 파일 이름이 오름차순(예: 0001.jpg, 0002.jpg, 0003.jpg 등)으로 순차적으로 번호가 매겨져 있다고 가정하겠습니다.

이를 위해 사용자의 스크롤 위치에 따라 원하는 이미지 파일의 번호와 함께 파일 경로를 리턴하는 함수를 작성하겠습니다.

const currentFrame = index => (
  `https://www.apple.com/105/media/us/airpods-pro/2019/1299e2f5_9206_4470_b28e_08307a42f19b/anim/sequence/large/01-hero-lightpass/${index.toString().padStart(4, '0')}.jpg`
)

이미지 이름과 일치하는 4자리 숫자를 만들기 위해 정수인 이미지 번호를 문자열로 변환하고 padStart(4, '0')를 사용하여 인덱스 앞에 0을 추가합니다. 예를 들어 이 함수에 1을 전달하면 0001로 만듭니다.

이로서 이미지 경로를 처리할 수 있게 되었고 다음의 코드는 <canvas> 요소에 그려진 첫 번째 이미지를 보여줍니다.

See the Pen scroll animation apple 1 by shinyks (@shinyks) on CodePen.

보이는 바와 같이 첫 번째 이미지가 페이지에 있습니다. 지금은 정적 파일일 뿐입니다. 우리가 원하는 것은 사용자의 스크롤 위치에 따라 업데이트하는 것입니다. 하지만 단순히 하나의 이미지 파일을 로드한 다음 다른 이미지 파일을 로드하여 교체하는 것을 원치는 않습니다. 대신 <canvas>에 이미지를 그리고 시퀀스의 다음 이미지로 그림을 업데이트하려고 합니다.

이미 전달된 숫자를 기준으로 이미지 파일 경로를 생성하는 기능을 만들었기 때문에 이제 해야 할 일은 사용자의 스크롤 위치를 추적하고 해당 스크롤 위치에 해당하는 이미지 프레임을 결정하는 것입니다.

스크롤 위치에 이미지 연결하기

시퀀스에서 전달해야 할 번호와 로드해야 할 이미지를 알려면 사용자의 스크롤 위치를 계산해야 합니다. 이벤트 리스너가 이를 추적하고 연산하여 로드할 이미지를 계산하도록 하겠습니다.

알아야 할 데이터:

  • 스크롤의 시작과 끝
  • 스크롤의 위치
  • 스크롤 위치에 상응하는 이미지

scrollTop을 사용하여 요소의 수직 스크롤 위치를 가져옵니다. 이는 현재 문서의 맨 위에 해당됩니다. 이 값이 시작점 값으로 사용합니다. document의 스크롤 높이에서 창 높이를 빼면 끝(또는 최대) 값을 얻을 수 있습니다. 여기서 scrollTop 값을 사용자가 아래로 스크롤할 수 있는 최대값으로 나누면 사용자의 스크롤 진행률을 알 수 있습니다.

그런 다음 스크롤 진행률을 이미지 번호 지정 시퀀스에 해당하는 인덱스 번호로 변환하여 해당 위치에 맞는 올바른 이미지를 가져와야 합니다. 이는 진행률에 우리가 가지고 있는 프레임(이미지)의 수를 곱해서 알 수 있습니다. Math.floor()를 사용하여 해당 숫자를 반올림하고 최대 프레임 수가 총 프레임 수를 초과하지 않도록 Math.min()로 감쌉니다.

window.addEventListener('scroll', () => {  
  const scrollTop = html.scrollTop;
  const maxScrollTop = html.scrollHeight - window.innerHeight;
  const scrollFraction = scrollTop / maxScrollTop;
  const frameIndex = Math.min(
    frameCount - 1,
    Math.floor(scrollFraction * frameCount)
  );
});

<canvas>에 해당 이미지를 업데이트하기

이제 사용자의 스크롤 진행률이 바뀌면 어떤 이미지를 그려야 하는지 알 수 있습니다. 여기서 <canvas>의 마법이 발휘됩니다. 이는 게임이나 애니메이션에서부터 디자인 mockup 생성기 등을 만들 수 있는 멋진 기능이 많습니다.

캔버스를 업데이트 하는 기능 중 하나는 requestAnimationFrame 메소드를 사용하는 것으로, 이는 하나로 길게 연결된 이미지로 작업할 때에는 할 수 없었던 방법으로 <canvas>를 업데이트합니다. 그래서 <img> 요소나 배경 이미지가 있는 <div> 대신 <canvas> 접근 방식을 사용했습니다.

requestAnimationFrame은 브라우저의 새로 고침 빈도와 매치되어 WebGL을 사용하여 장치의 비디오 카드 또는 통합 그래픽을 사용하여 렌더링하여 하드웨어 가속을 활성화합니다. 즉, 이미지가 깜빡이지 않게 프레임 전환을 매우 원활하게 할 수 있습니다.

스크롤 이벤트 리스너에서 이 기능을 호출하여 사용자가 페이지를 스크롤 할 때 이미지를 교체하겠습니다. requestAnimationFrame은 콜백함수를 매개변수로 받으므로 이미지를 업데이트하고 <canvas>에 새 이미지를 그리는 함수를 전달하겠습니다.

requestAnimationFrame(() => updateImage(frameIndex + 1))

이미지 시퀀스는 0001.jpg에서 시작하지만 스크롤 진행률 계산은 0에서 시작되기 때문에 frameIndex를 1만큼 더합니다. 이렇게 하면 두 값이 항상 맞아떨어집니다.

이미지를 업데이트하는 콜백 함수는 다음과 같습니다:

const updateImage = index => {
  img.src = currentFrame(index);
  context.drawImage(img, 0, 0);
}

frameIndex를 함수로 전달하면 이미지 소스가 시퀀스의 다음 이미지로 설정되며, 이 이미지는 <canvas> 요소에 그려집니다.

이미지를 미리 로드하기

이 지점에서 기술적으로 작업은 이미 끝났습니다. 하지만 좀더 성능을 좋게 만들 수 있습니다. 예를 들어 빠르게 스크롤하면 현재 이미지 프레임 사이에 약간의 지연이 발생합니다. 이는 모든 새로운 이미지가 새로운 네트워크 요청을 만들고 각각에 대해 새 다운로드가 필요하기 때문입니다.

이를 해결하려면 이미지를 미리 로드해야 합니다. 이렇게 하면 각각의 프레임이 이미 다운로드되어 있기 때문에 이미지 업데이트가 훨씬 빨라지고 애니메이션이 훨씬 부드러워집니다. 다음과 같이 이미지 시퀀스를 모두 다운로드합니다:

const frameCount = 148;

const preloadImages = () => {
  for (let i = 1; i < frameCount; i++) {
    const img = new Image();
    img.src = currentFrame(i);
  }
};

preloadImages();

Demo

See the Pen scroll animation apple 2 by shinyks (@shinyks) on CodePen.

관련 글

자바스크립트 requestAnimationFrame

자바스크립트 스크롤 애니메이션

자바스크립트 스프라이트 애니메이션

반응형

댓글