완벽한 캐러셀(carousel) 제작하기: 2부
() translation by (you can also view the original English article)
완벽한 캐러셀 제작하기 튜토리얼에 다신 오신 것을 환영합니다. 자바스크립트와 Popmotion의 물리(physics), tween, 입력 추적(input tracking) 기능을 사용해 접근 가능하고 매력적인 캐러셀을 만들어 보겠습니다.
튜토리얼 1부에서는 아마존과 넷플릭스가 캐러셀을 어떤 방식으로 만들었는지 살펴보았고, 그 방식들의 장단점을 평가했습니다. 그렇게 알게 된 내용으로 캐러셀 전략을 정하고, 물리(physics)를 이용해 터치 스크롤을 구현했습니다.
2부에서는 수평으로 이동하는 마우스 스크롤을 구현하겠습니다. 보편적인 페이지네이션 기술도 살펴보고 구현할 것입니다. 마지막에는 사용자가 캐러셀을 얼마나 멀리 보냈는지 알게 해주는 진행 바(progress bar)를 연결해 보려 합니다.
이 CodePen을 열어서 저장 위치(save point)를 복구하면 됩니다. 그 지점은 멈춘 지점을 알려 줍니다.
수평이동 마우스 스크롤
자바스크립트 캐러셀에서 수평 이동 마우스 스크롤이 가능한 것은 드뭅니다. 유감스럽죠. 모멘텀에 기반한 수평 이동 스크롤을 실행하는 랩톱과 마우스에서 캐러셀을 돌아다니는데 단연코 가장 빠른 방식이거든요. 터치 사용자들에게 스와이프(swipe)보다 버튼으로 돌아다니라고 강요하는 것만큼 좋지 않습니다.
운 좋게도 코드 한두 줄이면 구현할 수 있습니다. carousel
함수의 끝에 새로운 이벤트 리스너를 추가해 주세요.
1 |
container.addEventListener('wheel', onWheel); |
startTouchScroll
이벤트 아래 onWheel
로 명명한 stub 함수를 넣으세요.
1 |
function onWheel(e) { |
2 |
console.log(e.deltaX) |
3 |
}
|
이제 캐러셀 위에서 휠로 스크롤을 하고 콘솔 패널을 확인해 보면, x축 출력값(output)에서 휠의 거리를 보게 될 것입니다.
터치를 이용하더라도 휠이 대부분 수직 방향으로 움직이면, 페이지가 평소대로 스크롤 되어야겠지요. 우리는 수평 방향으로 움직이면, 휠 마우스의 움직임을 캡처해서 캐러셀에 적용하길 바랍니다. 고로, onWheel
에서 console.log
를 아래 코드로 교체해 주세요.
1 |
const angle = calc.angle({ |
2 |
x: e.deltaX, |
3 |
y: e.deltaY |
4 |
});
|
5 |
|
6 |
if (angleIsVertical(angle)) return; |
7 |
|
8 |
e.stopPropagation(); |
9 |
e.preventDefault(); |
위의 코드 블록은 스크롤이 수평으로 이동하면, 페이지 스크롤을 멈추게 합니다. 슬라이더의 x offset을 업데이트하려면 이제 이벤트의 deltaX
속성을 취하고 그것을 현재 sliderX
값에 추가하기만 하면 됩니다.
1 |
const newX = clampXOffset( |
2 |
sliderX.get() + - e.deltaX |
3 |
);
|
4 |
sliderX.set(newX); |
이 계산을 감싸고(wrap) 캐러셀이 측정된 경계 밖으로 스크롤 되지 않도록 이전의 clampXOffset
함수를 재사용하겠습니다.
별도로 본 스크롤 이벤트 속도 조절
입력(input) 이벤트를 처리하는 좋은 튜토리얼은 그러한 이벤트들의 속도를 조절(throttle)하는 것이 얼마나 중요한지를 설명해 줍니다. 이는 스크롤과 마우스, 터치 이벤트들이 디바이스의 프레임 속도보다 빠르게 발사되기 때문입니다.
한 프레임에 캐러셀이 두 번이나 렌더링 되는 것처럼 불필요하게 리소스에 집중된 작업을 수행하는 것을 바라지 않을 것입니다. 리소스를 낭비하고, 더딘 느낌을 주는 인터페이스를 만드는 빠른 방법이니까요.
이 튜토리얼에서는 그렇게 처리하지 않습니다. Popmotion에서 제공하는 렌더러는 아주 작은 프레임에 맞춰진 작업 스케줄러인 Framesync를 실행하기 때문이죠. 이 말인즉슨, 여러분은 한 줄에 (v) => sliderRenderer.set('x', v)
를 여러 번 호출할 수 있으며, 비경제적인 렌더링은 단 한 번, 다음 프레임에서 실행됩니다.
페이지네이션
스크롤링이 끝났습니다. 이제 지금까지 사랑받지 못한 내비게이션 버튼에 생명을 불어넣어야 합니다.
이 튜토리얼은 인터랙션에 관한 것이니 맘 편히 바라는 대로 이런 버튼을 디자인해 보세요. 저는 개인적으로 방향을 가리키는 화살표가 좀 더 직관적(이고 기본적으로 꽤 전 세계적인!) 것을 알게 되었습니다.
페이지네이션은 어떻게 동작하나요?
캐러셀에서 페이지네이션 할 때 여러분이 취할 수 있는 2가지 명확한 전략이 있습니다. 개별적이거나 감춰진 첫 번째 아이템입니다. 옳은 전략은 단 하나이지만, 다른 하나가 흔히 구현되는 것을 보아왔기에, 저는 그것이 왜 옳지 않은지를 설명할 가치가 있다고 봅니다.
1. 개별적으로

목록에서 다음 항목의 x offset을 간단히 측정하고 그만큼 칸을 애니메이션 합니다. 저는 사용자 친화성보다 단순함 때문에 선택된 가장 간단한 알고리즘이라고 추측합니다.
대다수 화면에서 한 번에 많은 항목을 보여줄 수 있으며, 사람들이 둘러보려고 하기 전에 그 항목들을 금세 훑어본다는 게 문제입니다.
전면적으로 불만스럽지 않다면 지루하게 느껴지겠죠. 이 방식이 좋은 선택이 될지 모를 단 하나의 상황은 캐러셀에 있는 항목들이 같은 너비이거나 가시적인 영역보다 좀 더 작다는 것을 알 때입니다.
그렇다 해도 여러 개의 항목을 볼 때, 첫 번째 항목을 숨기는 방식을 사용하는 게 낫습니다.
2. 감추어진 첫 번째 아이템



이는 캐러셀을 이동하길 원하는 방향에서 그냥 숨겨진 첫 번째 항목을 찾고, x offset 을 가져와서 거기로 스크롤하는 방식입니다.
그렇게 해서, 사용자가 현재 보인 항목들을 전부 보았다는 가정하에 새로운 항목의 최대 개수를 가져옵니다.
더 많은 항목을 가져오므로 캐러셀은 둘러볼 클릭 수를 최소한으로 요구합니다. 빠르게 둘러보면 관심이 증가하고 사용자가 상품을 더 많이 볼 것입니다.
이벤트 리스너
먼저 이벤트 리스너를 설정해서 페이지네이션으로 이것저것 테스트할 수 있게 해봅시다.
처음에는 이전 버튼과 다음 버튼을 선택해야 합니다. carousel
함수 위에 추가하세요.
1 |
const nextButton = container.querySelector('.next'); |
2 |
const prevButton = container.querySelector('.prev'); |
그런 후에 carousel
함수 아래에 이벤트 리스너를 넣으세요.
1 |
nextButton.addEventListener('click', gotoNext); |
2 |
prevButton.addEventListener('click', gotoPrev); |
마지막으로 이벤트 리스너 블록 바로 위에 실행할 함수를 추가하세요.
1 |
function goto(delta) { |
2 |
}
|
3 |
|
4 |
const gotoNext = () => goto(1); |
5 |
const gotoPrev = () => goto(-1); |
goto
는 페이지네이션에 관한 로직 전부를 처리할 함수입니다. 단순히 페이지로 가고자 하는 이동 방향을 표시한 숫자를 가져옵니다. gotoNext
와 gotoPrev
는 그저 각각 1
이나 -1
을 사용해 이 함수를 호출합니다.
"페이지" 계산하기
사용자는 자유롭게 이 캐러셀을 스크롤하고, n
개의 아이템이 그 안에 있으며, 캐러셀은 크기가 조절될 수 있습니다. 그러니 예전 방식의 페이지 개념은 여기서 적용하지 못합니다. 페이지 수를 세지 않겠습니다.
그 대신, goto
함수가 호출될 때 delta
방향을 보고 부분적으로 숨겨질 첫 번째 아이템을 찾습니다. 바로 그것이 다음 "페이지"에서 첫 번째 아이템이 됩니다.
첫째 단계에서 슬라이더의 현재 x offset 값을 얻고, 스크롤하려는 곳까지 "이상적인" offset을 계산하기 위해 눈에 보이는 슬라이더의 전체 너비를 이용해 그 값을 사용하겠습니다. 이상적인 offset은 슬라이더의 콘텐츠로 생각할만큼 순진할 때까지 스크롤 할 지점입니다. 우리가 첫 번째 아이템을 찾기 시작하게 하는 좋은 지점을 제공해 줍니다.
1 |
const currentX = sliderX.get(); |
2 |
let targetX = currentX + (- sliderVisibleWidth * delta); |
여기에서 지나친 최적화(cheeky optimisation)를 이용하면 됩니다. targetX
를 이전 튜토리얼에서 작성한 clampXOffset
함수에 제공해서 출력값(output)이 targetX
와 다른지 알아볼 수 있습니다. 다르다면, targetX
가 스크롤 가능한 경계 너머로 있다는 얘기이므로 가장 가까이 있는 아이템을 알아볼 필요가 없습니다. 그냥 끝까지 스크롤하는 것이죠.
1 |
const clampedX = clampXOffset(targetX); |
2 |
|
3 |
targetX = (targetX === clampedX) |
4 |
? findClosestItemOffset(targetX, delta) |
5 |
: clampedX; |
가장 근접한 아이템 찾기
다음에 오는 코드가 캐러셀에 있는 아이템 모두 동일한 크기라는 가정 하에 동작한다는 것을 알고 있는 것이 중요합니다. 그 가정 하에, 모든 아이템의 크기를 측정하지 않아도 되는 것처럼 최적화를 해보겠습니다. 만약에 아이템들이 서로 다른 크기라면, 그래도 좋은 출발점이 될 것입니다.
goto
함수 위에 마지막 스니펫에 참조된 findClosestItemOffset
함수를 추가하세요.
1 |
function findClosestItem(targetX, delta) { |
2 |
}
|
우선, 아이템들의 너비가 얼마나 되고 아이템 사이의 공간이 얼마나 되는지를 알아야 합니다. Element.getBoundingClientRect()
메서드에서 필요한 모든 정보를 제공해 줍니다. width
는 간단히 첫 번째 아이템 요소를 측정합니다. 아이템 간의 공간 계산은 첫 번째 아이템의 right
offset과 두 번째 아이템의 left
offset을 재면 됩니다. 그러고 나서 후자에서 전자를 뺍니다.
1 |
const { right, width } = items[0].getBoundingClientRect(); |
2 |
const spacing = items[1].getBoundingClientRect().left - right; |
함수로 전달했던 targetX
와 delta
변수를 이용해 재빨리 하나의 offset에서 스크롤 지점까지 계산해야 하는 모든 데이터를 갖게 됩니다.
그 계산은 targetX
의 절댓값을 width + spacing
으로 나누는 것입니다. 거리 안에서 딱 맞출 수 있는 정확한 아이템 수가 나옵니다.
1 |
const totalItems = Math.abs(targetX) / (width + spacing); |
그러고 나서 페이지네이션 방향(delta
)에 따라 반올림하세요. 맞출 수 있는 정확한 아이템 수가 나옵니다.
1 |
const totalCompleteItems = delta === 1 |
2 |
? Math.floor(totalItems) |
3 |
: Math.ceil(totalItems); |
마지막으로, 전체 아이템이 같은 offset이 되도록 그 수를 width + spacing
으로 곱해 주세요.
1 |
return 0 - totalCompleteItems * (width + spacing); |
페이지네이션 애니메이션 하기
계산된 targetX
가 있으므로 애니메이션 하면 됩니다! 이에 관해 웹 애니메이션의 일꾼인 tween을 사용하겠습니다.
잘 모르시는 분들을 위해 얘기하자면, "tween"은 between을 줄인 단어입니다. tween은 설정된 시간에 걸쳐 하나의 값에서 다른 값으로 변합니다. 만약에 CSS transition을 사용해 봤다면, 그와 같습니다.
tween에 관해 CSS 외에 자바스크립트를 사용하는 것이 장점(과 결점!)이 많습니다. 이 예제에서 물리(physics)와 사용자 입력으로 sliderX
를 애니메이션도 하기 때문에 tween을 적용하는 데 이 워크플로우를 따르는 것이 더 편합니다.
이는 또한 이후에 진행 바를 연결할 수 있고 전체 애니메이션으로 자연스럽게, 무료로 동작하게 된다는 말입니다.
먼저 Popmotion에서 tween
을 import하고 싶네요.
1 |
const { calc, css, easing, physics, pointer, transform, tween, value } = window.popmotion; |
goto
함수의 마지막에 currentX
에서 targetX
로 애니메이션 하는 tween을 추가하면 됩니다.
1 |
tween({ |
2 |
from: currentX, |
3 |
to: targetX, |
4 |
onUpdate: sliderX |
5 |
}).start(); |
Popmotion은 기본적으로 시간
을 300
밀리세컨드로, ease
를 easing.easeOut
으로 설정합니다. 사용자 입력에 대응하는 애니메이션에 즉각적으로 반응하는 느낌을 주도록 특별히 선택된 설정값들입니다. 그래도 맘 편히 테스트하고 여러분의 브랜드 감성에 더 들어맞는 무언가를 발견할 수 있는지 알아보세요.
진행 표시자
사용자들에게 캐러셀에서 그들의 위치를 알리는 표시자가 있다는 것은 유용합니다. 이를 위해 진행 표시자를 연결할 수 있습니다.
진행 바를 다양한 방법으로 꾸밀 수 있습니다. 이 튜토리얼에서 색상 입힌 div와 5px 높이로 만들었으며, 이전 버튼과 다음 버튼 사이에서 동작합니다. 우리가 이것을 코드와 연결했었고, 중요하며 이 튜토리얼의 중심이 되는 바를 애니메이션 하는 방식입니다.
애초에 transform: scaleX(0)
으로 스타일 되지 않아서 아직은 표시자를 보지 못합니다. 진행 바의 너비를 조정하는 데 scale
트랜스폼을 사용하겠습니다. 1부에서 설명했듯이 트랜스폼은 left
나 이 경우에 width
와 같이 변하는 속성보다 성능 기준에 더 적절하기 때문입니다.
게다가 minXOffset
과 maxXOffset
사이의 현재 sliderX
값인 퍼센트(percentage)로 scale을 정하는 코드를 쉽게 작성할 수 있습니다.
previousButton
선택자 다음에 있는 div.progress-bar
를 선택하는 것으로 시작해 봅시다.
1 |
const progressBar = container.querySelector('.progress-bar'); |
sliderRenderer
를 정의하고 나서 progressBar
에 대한 렌더러를 추가하면 됩니다.
1 |
const progressBarRenderer = css(progressBar); |
이제는 진행 바의 scaleX
를 업데이트하는 함수를 정의해 보죠.
getProgressFromValue
라 이름 지은 calc
함수를 사용하겠습니다. 이 함수는 범위(range)와 세 번째 숫자를 가져옵니다. 이 경우에 범위는 min
과 maxXOffset
가 됩니다. 0
과 1
사이의 숫자이며, 주어진 범위 안에 존재하는 세 번째 숫자의 progress 값을 반환합니다.
1 |
function updateProgressBar(x) { |
2 |
const progress = calc.getProgressFromValue(maxXOffset, minXOffset, x); |
3 |
progressBarRenderer.set('scaleX', progress); |
4 |
}
|
여기에서 직관에 의거해 반대로 되어야 할 때, 그 범위를 maxXOffset, minXOffset
로 적었습니다. x
는 음수이고 maxXOffset
도 음수인 데 반해 minXOffset
이 0
이기 때문입니다. 엄밀히 따져 보면 0
은 두 숫자보다 크지만, 최대치 offset을 현실적으로 표현하기에 더 작은 값입니다. 음수들이란 말이지?
진행 바가 sliderX
와 정확한 비율로 업데이트되었으면 합니다. 그래서 이 줄을 변경해 보죠.
1 |
const sliderX = value(0, (x) => sliderRenderer.set('x', x)); |
이 줄로 말입니다.
1 |
const sliderX = value(0, (x) => { |
2 |
updateProgressBar(x); |
3 |
sliderRenderer.set('x', x); |
4 |
});
|
자, sliderX
가 업데이트될 때마다 진행 바도 그렇게 될 것입니다.
마무리 하기
2부가 끝났네요! 가장 최근의 코드를 이 CodePen에서 보실 수 있습니다. 수평이동 휠과 스크롤, 페이지네이션, 진행 바를 성공적으로 도입했습니다.
캐러셀이 지금까지 꽤 근사한 형태가 되었죠! 마지막 튜토리얼에서 한 단계 더 발전시키겠습니다. 누구라도 사용할 수 있는 키보드로 완전히 접근 가능한 캐러셀을 만들어 보겠습니다.
더불어 사용자가 터치 스크롤이나 페이지네이션으로 경계를 지나 캐러셀을 스크롤하려고 시도할 때, 스프링을 활용한 터그(tug)를 사용해서 한두 가지 매력적인 터치를 추가하겠습니다.
그럼 거기서 보아요!