[Javascript] 스크롤에 따라 부드러운 애니메이션 구현하기 (Feat. iPhone SE)
들어가기 전에
영감을 받은 사이트는 애플의 iPhone SE 를 소개하는 페이지 였습니다. 앗, 지금은 또 새로운 버전의 페이지로 바뀌었네요, 우선 아래 데모에서 그 부드러운 느낌을 한번 살펴보세요!
이 글을 쓰게 된 계기는, 이렇게 정확한 스크롤 위치에 따른 부드러운 애니메이션을 구현하는 라이브러리는 찾기가 힘들었지요.
스크롤 애니메이션과 관련된 것들은 스크롤 하면서 콘텐츠가 화면상에 나타날 때 부드럽게 서서히 나타나도록 하는 애니메이션(AOS) 이 주를 이루었지만 그게 원하는 것은 아니었지요. 그래서 직접 만들기로 했습니다.
소스는 https://github.com/echoja/javascript-smooth-animation 에서 확인하실 수 있으니 참고 부탁드립니다~!
기본 컨셉
우선 생각들을 좀 정리해봅시다.
애니메이션이 잘 동작한다는 건, 다른 말로 우리가 적절한 시점에 요소를 적절하게 변화시킨다는 뜻입니다. 여기서 시점이란, 스크롤 위치가 바뀔 때마다 애니메이션이 진행되어야 하므로 브라우저의 scroll 이벤트를 활용합니다. 그리고 적절한 변화란, 스타일을 의미합니다. 그래서 DOM 객체로서 요소에 접근하여 스타일을 직접 계산하여 적용해야 합니다.
scroll 이벤트와 관련해서 한가지 더 생각해봐야 할 점은, 이 이벤트는 짧은 시간에 아주 많이 발생되는 이벤트이므로, 성능 이슈가 발생할 가능성이 높습니다.
애니메이션 요소들은 현재 배치된 상대적인 위치가 아니라 절대적으로 정해진 위치, 즉 스크롤 높이에 따라서 애니메이션이 시작되고 진행되고 멈춰야 합니다. 왜냐하면 애니메이션 요소 간 서로 독립적이기 때문입니다.
특정 요소가 화면 상에 나타나는지의 여부는 전혀 중요하지 않았으므로 Intersection Observer API 와 같은 기술은 전혀 고려사항이 아니었습니다. 앞서 이야기한 AOS 같은 라이브러리도 고려 대상이 아니었죠.
애니메이션 요소들을 절대 위치값으로 위치를 지정한다는 말은, 일반적으로 DOM 구조를 쌓아나가는 것과는 개념이 전혀 다르다는 걸 의미합니다. 즉 앞쪽에서 애니메이션의 길이를 추가한다면, 그 뒤에 위치하는 모든 애니메이션의 길이 또한 그만큼 더해줘야 하는, 상당히 번거로운 상황이 생길 수 있습니다. 코드 설계를 잘 해서 어떻게든 잘 해결해나갈 수 있으면 좋겠지만, 이번 글에서는 그런 큰 시도를 하지 않았습니다.
마지막으로, 사실상 제일 중요한 것은, 애니메이션이 부드럽게 보여야 했습니다. 사실 이를 위해 직접 코드를 짜겠다 발벗고 나선 거니까요.
정리하면, 다음과 같습니다.
- scroll 이벤트에서 스타일을 적절하게 적용함으로써 애니메이션을 구현합니다.
- 애니메이션 요소들의 위치는 절대값으로 설정합니다.
- 애니메이션을 부드럽게 보이기 위한 장치를 도입합니다.
부드러운 애니메이션의 설계
어떤 요소가 나타나거나 없어질 때에는 빠르게 움직이다가 애니메이션의 중간 단계에서는 천천히 움직이는 등의 속도 조절이 필요합니다. 그런데 속도 조절이란 건 정확히 어떤 걸 의미하는 걸까요?
스크롤을 내리는 동안 특정 엘리먼트의 애니메이션이 동작한다고 가정합시다. 그렇다면 스크롤 높이에 따라 진행률이 계산될 것입니다. 예를 들어, 애니메이션이 scrollY
(최상단으로부터 거리)가 1,000 ~ 2,000 동안 애니메이션을 진행한다고 가정하고, 우리의 스크롤이 1,600이라면 애니메이션의 진행률은 60%일 것입니다.
그 진행률에 우리의 애니메이션을 그대로 적용한다면, 애니메이션은 딱딱해보일 것입니다. 진행률이 얼마이든 상관없이, 스크롤을 조금 내리면 애니메이션이 조금 진행하고, 많이 내리면 애니메이션이 많이 진행될 것입니다. 즉 변화하는 속도는 항상 일정하다(linear)는 겁니다! 하지만 우리는 애니메이션이 좀 부드러워지길 바라지요.
그렇게 할 수 있도록 하는 장치는 바로 Easing Function 입니다. 이는 진행률 자체를 변형시킵니다. 왜요? 부드럽게 보이기 위해서요! 우선 아래 표를 한번 봅시다. 예제에서 실제로 적용된 bezierEasing(0, 0.7, 1, 0.3);
함수가 0(0%) ~ 1(100%) 사이에서 그 결과값이 어떻게 변하는지에 관한 표와 그림입니다.
스크롤 높이 | 진행률 | 변형된 진행률 |
---|---|---|
1,000 | 0% | 0% |
1,100 | 10% | 30.1% |
1,200 | 20% | 38.3% |
1,300 | 30% | 43.3% |
1,400 | 40% | 47.0% |
1,500 | 50% | 50.0% |
1,600 | 60% | 53.0% |
1,700 | 70% | 56.7% |
1,800 | 80% | 61.7% |
1,900 | 90% | 70.0% |
2,000 | 100% | 100% |
뭐 표를 봐도 감이 잘 안오시죠? 실제 애니메이션 (Preview & compare - Go 를 클릭하면 애니메이션을 확인할 수 있습니다)을 보면서 어떤지 감을 익히시면 좋을 것 같습니다.
우리는 변형된 진행률에는 50%에서 부드럽게 움직이고, 0%와 100%에서 급격하게 움직인다는 것을 확인할 수 있습니다. 즉, 우리가 일정한 속도로 스크롤을 하고 있다면, 애니메이션의 시작 부분에서는 빠르게 움직였다가, 중간 부분에서는 느려졌다가, 다시 끝 부분에서는 빠르게 움직이도록 애니메이션을 구현할 수 있다는 뜻입니다.
Easing Function 은 다양합니다. 우리가 CSS 애니메이션에서 일상적으로 사용하는 ease
, ease-in
, ease-out
도 특정한 Easing Function 의 특별한 이름일 뿐입니다. 이에 관한 더 자세한 이야기는 K. Joon Cho 님의 Easing Function 글 을 보면 더 좋을 것 같네요.
성능 이슈
앞서 말했듯이 scroll
이벤트를 사용하겠다 하면 성능 이슈를 신경써야 하지만, 본 글에서는 깊게 다루지 않습니다. 애니메이션을 구현하는 데 좀 더 집중했고, scroll 이벤트와 관련된 최적화는 다른 글에서 많이 찾아볼 수 있을 것입니다.
그래도 기본적인 성능을 챙기긴 해야 하니, 여러가지 간단한 조치는 했습니다.
- 변경이 자주 일어나는 요소에
position: absolute
적용 ( 웹 퍼포먼스 - Reflow / Layout Shift 최소화하기 글(영문) ) - 애니메이션이 활성화(enabled)된 요소와 그렇지 않은 요소를 구별하여, 활성화되지 않은 요소에게
display: none
처리 - jQuery 와 같은 DOM 라이브러리를 지양
- Map 사용 ( JavaScript ES6 Map vs Object Performance 비교 글 참조)
만약, 성능을 더더욱 신경쓰겠다 하면, 쓰로틀링과 디바운싱 도 해볼 만 합니다. 이 글에서는 시도하지 않았습니다.
애니메이션 원리 - DOM 구조
우선 애니메이션을 정리하기 전에 CSS 의 position: sticky
속성을 주목합시다. sticky란, 커다란 컨테이너 내부에서만 fixed 처럼 동작하도록 하는 포지셔닝 기법입니다. 여기서는 sticky
에 대한 자세한 설명은 하지 않을게요. 레진 기술 블로그 를 참조해도 좋을 것 같습니다.
sticky container
가장 바깥쪽에 위치합니다. 이 요소의 역할은 자식으로 가질 sticky 요소가 자유롭게 뛰어 놀 공간을 부여해주는 겁니다.
이 요소에 대해서는 height 를 수동으로 설정해줘야 합니다. 왜냐하면 내부 요소들은 일반적인 DOM Element 처럼 쌓이면서 높이를 결정하는 게 아니라, 임의로 절대적인(absolute) 위치값을 활용하기 때문입니다. 스크롤을 가장 아래로 내렸을 때에 모든 애니메이션이 딱 맞게 완료되도록 height
를 충분한 값으로 설정해줍니다.
sticky 요소를 끝내고 새로운 sticky 요소가 시작되려고 하면 이 sticky container 를 여러 개 만들어 사용하면 됩니다.
sticky
position
속성은 sticky
값을 갖습니다. 화면에 딱 맞게 하기 위해 height
는 100vh
로 설정하고, top
오프셋을 0
으로 설정합니다.
sticky 요소는 top
등으로 기준점을 잡아줘야 제대로 동작합니다.
slide container
자식으로 가질 slide 요소들의 정렬을 맡습니다. 여기서는 display: flex
를 통해서 자식들의 위치를 조정합니다.
slide
position
속성은 absolute
값을 갖습니다. 애니메이션의 가장 핵심인 요소입니다. 활성화되거나 비활성화될 때마다 enabled
, disabled
클래스 추가/제거를 통해 display
속성을 제어하며, 스크롤 위치에 따라 transform: translate(...)
속성이 변경되어 실제 애니메이션을 보여주는 역할을 가지고 있습니다.
sticky container 하나에 sticky 하나, slide contianer 하나로 구조를 잡아놨는데 이 slide 는 position
이 abslute
이므로 여러 개를 두어도 모두 화면 가운데에 위치합니다. 보여주는 순서만 잘 설정하여 slide 를 여러 개 만들어봅시다.
애니메이션 원리 - 동작
그렇다면 자바스크립트에서 어떻게 애니메이션을 실시간으로 갱신하도록 할까요?
먼저 데이터를 정의합니다. 앞서 이야기한 것처럼 각 애니메이션 요소들은 절대 높이값을 가지고 있어야 합니다. 이것들을 어디에다가 저장할 지는 자유이고, 필자는 def
변수에다가 모두 몰아넣었습니다. 높이값 뿐만 아니라 애니메이션에 관한 각종 정보를 선언합니다.
이 정보들을 바탕으로 sticky container 의 높이를 설정합니다. 모든 애니메이션이 담길 만큼 충분한 스크롤 높이가 필요합니다. 애니메이션을 만들 때마다 수동으로 설정해줄 수도 있고 애니메이션의 양에 따라 동적으로 변화시킬 수도 있겠습니다.
이제 스크롤 될 때마다 계산을 수행합니다. 우선 현재 높이를 계산하여 특정 요소(Element)가 활성화(enabled)될지 말지를 판단합니다. 이를 위해서 특정 요소마다 상한선과 하한선이 필요합니다. 이는 초기 데이터를 세팅할 때 설정하도록 합니다. 활성화 영역에서 벗어날 때, 위쪽으로 벗어나면 시작 상태로 고정시키고, 아래쪽으로 벗어나면 끝 상태로 고정시킵니다.
활성화된 요소에 한해 현재 높이에서 특정 애니메이션이 진행중인지 아닌지를 판단합니다. 하나의 요소 안에도 여러 가지의 애니메이션이 들어갈 수 있기 때문에 각 애니메이션마다 상/하한선도 따로 설정해줬습니다. 이것도 마찬가지로 초기 데이터를 세팅할 때 설정합니다. 위와 비슷하게, 애니메이션이 끝나야 하는 상황이라면, 그 위쪽으로 벗어났을 땐 시작점으로 되돌려주고 아래쪽에서 벗어났을 땐 끝나는 값으로 고정시켜줍니다.
그 다음 현재 진행 중인 애니메이션에 한해, 스크롤 높이 값과 애니메이션 상/하한선을 이용해 현재 진행중인 애니메이션의 진행률(rate)을 계산합니다.
애니메이션을 부드럽게 만들기 위해 이 진행률을 Easing Function 에 보내서 변형된 진행률 값을 계산합니다.
최종적으로 스타일 객체에 접근하여 애니메이션을 적용합니다.
실제 코딩 설명 - HTML
HTML 파일을 먼저 살펴봅시다.
<main class="sticky-container" id="sticky-container"> <div class="sticky"> <div class="slide-container"> <div class="slide" id="slide1"> <div class="slide-big-text"> <p>안녕하세요.</p> </div> </div> <div id="scroll-down"> <div class="scroll-down-text">아래로 스크롤하세요.</div> </div> <div class="slide" id="slide2"> <div class="slide-big-text"> <p>처음 뵙겠습니다.</p> </div> </div> <div class="slide" id="slide3"> <div class="slide-big-text"> <p>동쪽에서 새로운 해가</p> <p>이윽고 떠올랐습니다.</p> </div> </div> <!-- 기타 슬라이드 --> </div> </div></main>
앞서 HTML 구조에 대해 설명할 때 4개의 큰 요소로 나눈 것처럼 sticky-container
, sticky
, slide-container
, slide
4개의 클래스로 요소로 나누었습니다.
실제 코딩 설명 - CSS
이제 CSS를 살펴봅시다.
.sticky { position: sticky; top: 0; width: 100%; height: 100vh;}.slide-container { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%;}.slide { position: absolute; display: none; z-index: 0;}.slide.enabled { display: block;}
.sticky-contianer
는 특별히 적용할 스타일이 없습니다. 높이는 자바스크립트에서 동적으로 설정됩니다.
.sticky
요소에 대해서 position: sticky
를 적용했습니다. top
과 height
도 화면에 꽉차도록 설정했습니다.
.slide-container
는 하위 항목들을 absolute
로 유지시킬 수 있도록 position: relative
로 설정했습니다. 그리고 flex-box
및 기본 정렬을 설정하여 하위 자식들이 좀 더 편하게 배치하도록 했습니다. 이 요소는 화면에 꽉차있어야 하므로 width: 100%
, height: 100%
, 로 설정했습니다.
.slide
는 기본적으로 애니메이션의 기본 요소이며, 모든 .slide
는 서로의 영향을 무시한 채 정해진 높이와 오프셋만으로 움직이기 때문에 position
속성을 absolute
로 두었습니다. .slide
는 기본적으로 보이지 않으며 .slide.enabled
여야 화면에 나타납니다. (display: none
→ display: block
) .slide
간 화면에 나타나는 순서를 조절하기 위해 z-index
속성을 활용할 수도 있습니다.
실제 코딩 설명 - Javascript
이제 가장 복잡한 자바스크립트 내용을 체크해봅시다. 우선 세팅 값에 관한 것입니다. 이 값을 담는 변수를 def
로 정하고 작성해봤습니다.
const def = new Map([ [ "slide1", // element 이름 { id: "slide1", top: 500, // element의 시작점 bottom: 1900, // element의 끝점 topStyle: { // element의 시작 스타일 opacity: 0, translateY: -60, }, bottomStyle: { // element의 끝 스타일 opacity: 0, translateY: 60, }, animations: [ { enabled: false, // 애니메이션 활성화 여부 top: 500, // 애니메이션 시작점 bottom: 1900, // 애니메이션 끝점 easing: midSlow, // Easing Function styles: [ // 애니메이션 시 변경될 스타일 { name: "translateY", // 스타일 이름 topValue: 60, // 진행률 0% 일 때의 스타일 bottomValue: -60, // 진행률 100% 일 때의 스타일 }, ], }, { enabled: false, top: 500, bottom: 800, easing: ease, styles: [ { name: "opacity", topValue: 0, bottomValue: 1, }, ], }, { enabled: false, top: 1400, bottom: 1900, easing: easeIn, styles: [ { name: "opacity", topValue: 1, bottomValue: 0, }, ], }, ], }, ], // 중략]);
이제 초기화하는 부분을 알아봅시다.
/** @type {{[key: string]: any}} */const elements = { "sticky-container": document.getElementById("sticky-container"), "scroll-down": document.getElementById("scroll-down"), slide1: document.getElementById("slide1"), slide2: document.getElementById("slide2"), slide3: document.getElementById("slide3"), "moving-background": document.getElementById("moving-background"), slide4: document.getElementById("slide4"), slide5: document.getElementById("slide5"),};function initAnimation() { // Sticky Conainer 의 높이를 설정함. elements["sticky-container"].style.height = `7100px`; // 모든 요소를 disabled 에 넣음. def.forEach((obj, id) => { disabled.set(id, obj); }); // 초기 스타일 적용 disabled.forEach((obj, id) => { Object.keys(obj.topStyle).forEach((styleName) => { const pushValue = obj.topStyle[styleName]; applyStyle(elements[id], styleName, pushValue); }); }); // 이미 요소의 범위 및 애니메이션의 범위에 있는 것들을 렌더링하기 위해 // 임의로 스크롤 이벤트 핸들러를 한 번 실행시킴. onScroll();}initAnimation();window.addEventListener("scroll", onScroll);
초기화는 처음에 해줍니다. initAnimation
함수는 초기화를 합니다. 직접 스타일을 수정하기 위하여 getElementById
를 이용해 DOM을 가져옵니다. scroll
이벤트 리스너도 추가합니다.
그 다음 쉬운 헬퍼 함수들을 먼저 만들어줍니다.
/** * DOM 요소에 스타일을 적용하는 함수 * @param {HTMLElement} element * @param {*} styleName * @param {number} value */function applyStyle(element, styleName, value) { if (styleName === "translateY") { element.style.transform = `translateY(${value}px)`; return; } if (styleName === "translateX") { element.style.transform = `translateX(${value}px)`; return; } element.style[styleName] = `${value}`;}
이 applyStyle
함수는 스타일을 적용하기 쉽도록 도와주는 함수입니다. element
요소의 styleName
스타일에 대해 value
를 적용시키는 간단한 함수입니다.
translateX
혹은 translateY
는 실제로는 스타일 속성의 이름이 아니라 transform
함수 안에 적절히 함수처럼 들어가야 하는 아이이지만, 좀 더 일관적으로 사용하게 하기 위해서 로직에 포함시켰습니다.
이제 이 applyStyle
함수를 호출하는 applyStyles
함수를 확인해봅시다.
/** * 스타일에 대해 실제 값을 계산하여 적용하는 함수 * @param {string} id * @param {any[]} styles * @param {number} rate */function applyStyles(id, styles, rate) { styles.forEach((style) => { const { name, topValue, bottomValue } = style; const value = getPoint(topValue, bottomValue, rate); applyStyle(elements[id], name, value); });}
내용은 간단합니다. 모든 스타일마다 진행률에 해당하는 실제 값을 간단히 계산하고 applyStyle
함수로 넘기는 역할입니다.
이제 onScroll
에서 실제로 호출하는 applyAnimations
함수를 살펴보겠습니다.
/** * 현재 스크롤 높이를 기준으로 특정 Element의 애니메이션을 모두 적용시킵니다. * @param {number} currentPos * @param {string} id */function applyAnimations(currentPos, id) { const animations = def.get(id)?.animations; if (!animations) { return; } animations.forEach((animation) => { const { top: a_top, bottom: a_bottom, easing, styles } = animation; const isIn = isAmong(currentPos, a_top, a_bottom); // 만약 애니메이션이 새롭게 들어갈 때 혹은 나갈때 enabled 설정 if (isIn && !animation.enabled) { animation.enabled = true; } // 만약 애니메이션 범위 밖에 있다면 enabled 해제하면서 스타일 초기화 else if (!isIn && animation.enabled) { if (currentPos <= a_top) { applyStyles(id, styles, 0); } else if (currentPos >= a_bottom) { applyStyles(id, styles, 1); } animation.enabled = false; } // 애니메이션이 enabled 라면, 애니메이션 적용. if (animation.enabled) { const rate = easing((currentPos - a_top) / (a_bottom - a_top)); applyStyles(id, styles, rate); } });}
우선 해당 애니메이션이 실제로 적용할 애니메이션인지 아닌지를 판단합니다. 현재 스크롤의 위치가 담겨있는 currentPos
변수를 기준으로 합니다.
만약 애니메이션이 활성 상태였는데, 범위를 벗어나 막 비활성 상태로 바뀔려고 한다면 스타일을 초기화해주어야 할 것입니다. 시작점으로부터 벗어났다면 0% 로 초기화해주고, 끝점으로부터 더 나아갔다면 100%로 초기화히줍니다.
마지막으로 onScroll
을 확인해봅시다.
function onScroll() { // 현재 스크롤 위치 파악 const scrollTop = window.scrollY || window.pageYOffset; const currentPos = scrollTop + window.innerHeight / 2; // disabled 순회하며 활성화할 요소 찾기. disabled.forEach((obj, id) => { // 만약 칸에 있다면 해당 요소 활성화 if (isAmong(currentPos, obj.top, obj.bottom)) { enabled.set(id, obj); elements[id].classList.remove("disabled"); elements[id].classList.add("enabled"); disabled.delete(id); } }); // enabled 순회하면서 헤제할 요소를 체크 enabled.forEach((obj, id) => { const { top, bottom, topStyle, bottomStyle } = obj; // 범위 밖에 있다면 if (!isAmong(currentPos, top, bottom)) { // 위로 나갔다면 시작하는 스타일 적용 if (currentPos <= top) { Object.entries(topStyle).forEach(([styleName, value]) => { applyStyle(elements[id], styleName, value); }); } // 아래로 나갔다면 끝나는 스타일적용 else if (currentPos >= bottom) { Object.entries(bottomStyle).forEach(([styleName, value]) => { applyStyle(elements[id], styleName, value); }); } // 리스트에서 삭제하고 disabled로 옮김. disabled.set(id, obj); elements[id].classList.remove("enabled"); elements[id].classList.add("disabled"); enabled.delete(id); } // enable 순회중, 범위 내부에 제대로 있다면 각 애니메이션 적용시키기. else { applyAnimations(currentPos, id); } });}
여기서 enabled
와 disabled
는 어떤 요소(Element) 가 활성화된 상태인지를 판단하기 위해 있습니다. 현재 스크롤 높이에 따라서 enabled
인지 disabled
인지 구분해주고 해당 요소의 classList
에 접근하여 클래스를 수정함으로써 현재 상태를 DOM 에 알려줍니다. (이로써 나중에 css 로 커스터마이징할 수도 있습니다.)
일러두기
앞서 언급한 것처럼, 절대 높이값을 다룬다는 특성상 애니메이션 타이밍 수정이 편리하지 않습니다. 이는 변수 등을 잘 이용해서 구현을 잘 하면 해결될 것 같습니다.
또한 디바이스 화면의 크기에 따라 애니메이션이 다르게 보일 수 있습니다. 현재 작성된 코드에서 애니메이션 진행률을 계산할 때의 기준점은 화면의 가운데를 잡기 때문에, 화면 자체가 크다면 애니메이션이 이미 진행중으로 계산될 것입니다.
마치며
코드가 조금 난잡할 순 있지만, 부드럽고 고급진 애니메이션을 만들고 싶으신 분들께 조금이나마의 영감과 도움이 되었으면 하는 마음에서 글을 작성하게 되었네요. 큰 줄기만 알아도 큰 도움이 되시리라 믿습니다. 모두 즐거운 코딩합시다!