iOS6의 iPod 앱에서는 음악 앨범을 Coverflow로 볼 수 있었다. Coverflow는 앨범 아트를 가로로 나열한 뒤, 중앙에 크게 표시하고, 좌우로 슬라이드하면 다음 앨범을 볼 수 있는 UI이다.
Human Interface Guidelines의 엄격한 규칙에 따라 개발되었으며, 실제와 같은 물리법칙을 통해 사용자에게 직관적인 경험을 제공한다.
이 글에서는 React.js로 바닥부터 Coverflow를 만드는 과정을 소개한다.
문제 쪼개기
하나의 문제를 해결하는 가장 쉬운 방법은 그 문제를 작은 문제로 쪼개는 것이다. Coverflow를 만들기 위해서는 다음과 같은 작업이 필요하다.
- 이미지를 가로방향으로, 입체적으로 나열한다.
- 이미지를 드래그했을때, 이미지가 물리법칙에 따라 움직이도록 한다.
- 이미지를 클릭했을 때, 해당 이미지가 중앙으로 오도록 한다.
- 재사용할 수 있도록, 컴포넌트 외부에서 값을 수정할 수 있도록 한다.
아직은 각 문제가 크다. 이를 더 작은 문제로 쪼개보자.
- 이미지를 가로방향으로, 입체적으로 나열한다.
- 이미지를 가로방향으로 나열
- 이미지를 가로방향으로 나열할 때, 이미지 간의 간격을 조절
- 이미지를 가로방향으로 나열할 때, 이미지의 각도를 조절
- 이미지를 가로방향으로 나열할 때, 이미지의 크기를 조절
- 이미지를 드래그했을때, 이미지가 물리법칙에 따라 움직이도록 한다.
- 이미지를 드래그
- 이미지를 드래그했을 때, 이미지가 물리법칙에 따라 움직이기
- 여러개의 이미지를 함께 움직이기
- 이미지를 클릭했을 때, 해당 이미지가 중앙으로 오도록 한다.
- 이미지가 선택되면, 해당 이미지가 중앙으로 오도록 하기
- 이미지를 클릭했을 때, 선택되도록 하기
- 재사용할 수 있도록, 컴포넌트 외부에서 값을 수정할 수 있도록 한다.
- 이미지의 크기를 조절하기
- 이미지의 크기가 조절되었을 때, 1번 문제에 영향을 주지 않도록 하기
이제 문제가 파악하기 쉽고, 해결가능한 문제가 되었다. 각 문제를 해결하기 위한 합리적인 방법을 찾아서 구현하면 된다.
정의한 문제 해결하기
1. 이미지를 가로방향으로, 입체적으로 나열한다.
html 요소를 가로방향으로 나열하는 방법은 여러가지가 있다.
가장 쉬운 방법은 display: flex
를 사용하는 것이다. display: flex
를 사용하면, 자식 요소를 가로방향으로 나열할 수 있다. 하지만 Coverflow에서는 요소간 간격이 일정하지 않고, 요소의 순서와 위치에 따라 x값이 변해야 한다.
여기에서는 위치를 완전히 제어하기 위해 position: absolute
를 사용한다.
html 요소의 위치를 이동시킬 때, left 와 같은 속성이 아닌, translate를 사용해야한다. 브라우저 렌더링 엔진의 동작을 이해한다고 가정한다.
우선은 중앙을 기준으로 오른쪽의 요소들을 구현한다. 좌측의 요소들은 나중에 구현할 수 있다.
coverflow를 유심히 살펴보면, 중앙에 있는 요소와 두번째 요소의 간격이 제일 크고, 그 다음은 조금씩 작아지다가 유지되는 것을 알 수 있다. 이를 구현하기 위해, 각 요소의 x값을 계산해야 한다. 일단은 각 요소의 크기를 400*400 으로 설정하고, 디자인을 보면서 흉내낸다. 각도와 크기도 같은 방법으로 임의의 값을 구할 수 있다.

위 사진은 표로 정리하고 그래프로 그린 모습이다. 이렇게 시각화하면 직관적으로 애니메이션을 이해할 수 있다.
위의 그래프를 코드로 구현하면 다음과 같다. score 0 부터 시작하는 각 요소의 index이다.
const FIRST_GAP = 160;
const GAP = 80;
const getX = (score: number) => {
if (score < 1) {
return score * FIRST_GAP;
}
return FIRST_GAP + GAP * score;
};
const getRotateY = (score: number) => {
if (score < 1) {
return score * -40;
}
return -40;
};
const getScale = (score: number) => {
if (score < 1) {
return 1 - 0.2 * score;
}
if (score < 2) {
return 0.8 - 0.05 * (score - 1);
}
return 0.75;
};
이제 각 요소의 x값, 각도, 크기를 계산할 수 있다. 이를 이용해 각 요소를 배치하면 다음과 같다.

rotateY를 적용했을 때, 요소가 입체적으로 회전하는 것으로 보이기 위해서는 perspective 속성을 부모 요소에 적용해야한다. 잘 위치하는 것을 확인했으니, 0을 기준으로 x 축에 대칭인 그래프를 그린다고 생각하고, 위의 함수들을 수정한다.
// x
const FIRST_GAP = 220;
const GAP = 80;
// scale
const FISRST_SCALE = -0.2;
const SCALE = -0.05;
const getX = (score: number) => {
if (score < -1) {
return -FIRST_GAP + GAP * (score + 1);
}
if (score < 1) {
return score * FIRST_GAP;
}
return FIRST_GAP + GAP * (score - 1);
};
const getRotateY = (score: number) => {
if (score < -1) {
return 40;
}
if (score < 1) {
return score * -40;
}
return -40;
};
const getScale = (score: number) => {
if (score < -2) {
return 1 + FISRST_SCALE + SCALE;
}
if (score < -1) {
return 1 + FISRST_SCALE + SCALE * (score + 1);
}
if (score < 0) {
return 1 - FISRST_SCALE * score;
}
if (score < 1) {
return 1 + FISRST_SCALE * score;
}
if (score < 2) {
return 1 + FISRST_SCALE + SCALE * (score - 1);
}
return 1 + FISRST_SCALE + SCALE;
};

score를 -1부터 9 까지 설정했을 때, 기대한 모습이 나오는 것을 확인할 수 있다.
2. 이미지를 드래그했을때, 이미지가 물리법칙에 따라 움직이도록 한다.
react-use-gesture
를 사용하면, 드래그 이벤트를 쉽게 사용할 수 있다.
그리고 같은 pmndrs의 react에서 spring을 고수준으로 추상화한 react-spring
을 사용하면, 간단하게 물리법칙을 적용할 수 있다.
index와 index 사이에서 요소의 위치를 파악하기 위해, score를 구하는 로직이 필요하다.
score는 0에서 시작하며, 가운데 위치에서 오른쪽 요소의 위치로 갈때마다 1씩 증가한다.
가장 첫 요소를 x만큼 이동한다고 했을때, 이 x의 변화량을 역산하여 score를 구할 수 있다.
getX
의 역함수인 getScore
를 정의한다.
const getScore = (x: number) => {
if (x < -FIRST_GAP) {
return (x + FIRST_GAP) / GAP - 1;
}
if (x < FIRST_GAP) {
return x / FIRST_GAP;
}
return (x - FIRST_GAP) / GAP + 1;
};
그리고 기준 요소의 변화량을 이용해 score를 구하는 로직을 구현한다.
const getTransform = (score: number) => {
return {
scale: getScale(score),
x: getX(score),
rotateY: `${getRotateY(score)}deg`,
};
};
const [baseX, setBaseX] = useState(0); // 기준 요소(가운데 요소)의 상대적 x 위치
const [covers, coversApi] = useSprings(10, (index) => {
const score = getScore(baseX) + index;
return getTransform(score);
});
const handler: Handler<"drag" | "wheel"> = ({ movement: [x] }) => {
return coversApi.start((index) => {
const score = getScore(baseX + x) + index; // 위의 설명과 같이, 기준 요소의 위치를 기준으로 x를 이동했을 때의 score를 구한다. 그리고, 각 요소의 index를 더해서 각 요소의 score를 구한다.
return getTransform(score);
});
};
const bind = useGesture({ onDrag: handler, onWheel: handler });
<div
{...bind()}
style={{
touchAction: "none",
position: "relative",
height: COVER_SIZE,
perspective: "600px",
}}
>
{covers.map((props, index) => (
<animated.div
key={index}
style={{
position: "absolute",
left: 0,
top: 0,
...props,
}}
>
<Cover />
</animated.div>
))}
</div>
위와 같이 구현하면, 드래그를 통해 모든 요소를 이동할 수 있다.
movement가 아닌 offset을 사용하면, 더욱 직관적으로 구현할 수 있지만, 요소들의 너비의 총합이 고정되어있지 않고, 이후 구현에서 api를 사용해 직접 요소들을 이동해야하기 때문에 액션 별 이동거리를 의미하는 movement를 사용했다.
3. 이미지를 클릭했을 때, 해당 이미지가 중앙으로 오도록 한다.
아직은 요소를 선택할 수 없고, 드래그된 마지막 위치에서 요소가 유지된다. 그리고, 맨 처음과 끝 요소를 넘어서 스크롤해도 멈추지 않고 지속된다. 이 시점에서 필요한 요구사항을 아래와 같이 정리할 수 있다.
- 드래그가 멈추면, 가장 가까운 요소를 선택하기
- 요소를 클릭했을 때 선택하기
- 선택된 요소가 중앙으로 오도록 하기
- 맨 처음과 끝 요소를 넘어서면, 가장 가까운 요소를 선택하기
드래그가 멈추었을 때 가장 가까운 요소를 선택하려면, score의 절대값이 0.5보다 작은 요소를 선택하면 된다.
if (Math.abs(score) <= 0.5) {
setCurrnet(index);
}
handler
의 active가 false이면 드래그가 끝난 것이므로, 이때 baseX를 업데이트하고 선택된 요소로 이동하는 로직을 넣을 수 있다.
const handler: Handler<"drag" | "wheel"> = ({ movement: [x], active }) => {
if (active) {
return coversApi.start((index) => {
const score = getScore(baseX + x) + index;
if (Math.abs(score) <= 0.5) { // 0.5보다 작으면 가장 가깝다고 판단
setCurrnet(index);
}
return getTransform(score);
});
}
setBaseX(-getX(current)); // 기준 요소의 x 위치를 업데이트
return coversApi.start((index) => { // 선택된 요소로 이동
return getTransform(index - current);
});
};
요소를 클릭했을 때 선택되게 하기 위해서는 onClick이벤트를 사용하면 될 것 같다. 하지만, 드래그를 하는 시나리오에서는 클릭이 되면 안될 것이다. 그래서, mouseDown 이벤트와 mouseUp 이벤트를 사용해서, mouseDown시 좌표와 mouseUp시 좌표가 같으면 클릭이라고 판단하고, 클릭한 요소를 선택하고 이동하도록 구현했다. 이 로직은 단순하므로 코드는 생략한다.
맨 끝 요소를 넘어서 드래그했을때 더이상 드래그를 어렵게 하기 위해서는 x의 상대적 값을 계산해서, 가능한 범위를 벗어났는지 판단해야 한다.
const getBoundedX = (baseX: number, x: number, size: number) => {
const offset = baseX + x;
const x0 = -getX(0);
const xMax = -getX(size - 1);
if (offset > x0) {
return offset * RUBBER;
}
if (offset < xMax) {
return xMax + (offset - xMax) * RUBBER;
}
return offset;
};
그러면 score를 구하는 로직을 아래와 같이 수정할 수 있다.
const boundedX = getBoundedX(baseX, x, covers.length);
const score = getScore(boundedX) + index;
가장 오른쪽 값이 score가 0일때 x 보다 커지지 못하도록 했고, 가장 왼쪽 값이 score가 size-1일때 x 보다 작아지지 못하도록 했다.
불가능한 상황에서 드래그를 어렵게하기 위해 초과된 편차에 RUBBER
를 곱해서, 약간의 관성은 가능하도록 구현했다.
위의 코드를 보면, 모든 요소에 대해 getScore를 호출하는 것을 알 수 있다. 요소가 많아졌을때 getScore는 부하를 유발할 수 있으므로, 이를 최적화할 필요가 있다. memo를 사용해서 중복된 연산을 줄이도록 구현한다.
const handler: Handler<"drag" | "wheel"> = ({ movement: [x], active }) => {
if (active) {
return coversApi.start((index) => {
if (index === 0) {
const boundedX = getBoundedX(baseX, x, covers.length);
const baseScore = getScore(boundedX);
memo.current.baseScore = baseScore; // 첫번째 요소에서 baseScore를 저장
}
if (Math.abs(memo.current.baseScore + index) <= 0.5) { // baseScore를 사용해서 score를 구한다.
setCurrnet(index);
memo.current.current = index; // batch 업데이트로 인해 값이 정상적으로 업데이트되지 않을 수 있으므로 memo를 사용해서 저장
}
return getTransform(memo.current.baseScore + index);
});
}
const current = memo.current.current; // 최신의 current를 사용하도록 보장한다.
setCurrnet(current);
setBaseX(-getX(current));
return coversApi.start((index) => {
return getTransform(index - current);
});
};
렌더링 하는 쪽에서, 가운데 요소의 z-index를 가장 크게해야 뒤의 요소가 앞의 요소를 가리는 것 처럼 보이지 않는다. 지금은 선택된 요소의 인덱스를 상태로 가지고 있으므로, 쉽게 구현할 수 있다.
<animated.div
key={index}
style={{
position: "absolute",
left: 0,
top: 0,
zIndex: covers.length - Math.abs(current - index),
...props,
}}
>
4. 재사용할 수 있도록, 컴포넌트 외부에서 값을 수정할 수 있도록 한다.
이제 컴포넌트 외부에서 값을 수정할 수 있도록 해야한다. 너비를 처음에는 하드코딩된 400*400으로 설정했지만, 이를 외부에서 수정할 수 있도록 해야한다. 이를 위해 Coverflow 컴포넌트에 prop을 추가하고, 각 요소의 크기와 위치를 계산할 때 이를 사용하도록 수정한다.
props.size가 바뀔때마다 getX, getRotateY, getScale을 다시 계산하도록 해야하므로 Util클래스를 만들어 생성자에서 size를 주입받도록 했다. 이제 아래와 같이 사용할 수 있다.
export class Util {
private firstGap: number;
private gap: number;
private firstScale: number;
private scale: number;
private rubber: number;
constructor(size: number) {
this.firstGap = (size * 220) / 400;
this.gap = (size * 80) / 400;
this.firstScale = -0.2;
this.scale = -0.05;
this.rubber = 0.15;
}
/*구현*/
}
const util = useMemo(() => new Util(size), [size]);
const score = util.getScore(baseX) + index;
return util.getTransform(score);
위와같이 구현하고 약간의 css 추가로 완성할 수 있다.
결과 보기
마우스로 드래그를 하거나, 요소를 선택해서 상호작용할 수 있다. 전체 화면으로 보기
마치며
이 글에서는 React.js로 Coverflow를 만드는 과정을 소개했다. 문제를 쪼개어 해결하는 방법을 설명하고, 각 문제를 해결하는 방법을 소개했다. 처음에는 어려워보였던 문제도, 작은 문제로 쪼개고 하나씩 해결하니 쉽게 해결할 수 있었다. 더 자세한 문제해결 과정이 궁금하다면 github에서 커밋 내역과 코드를 확인해보자. 이 글이 단순히 Coverflow의 구현이 아닌, 새로운 문제를 마주했을 때, 어떻게 접근해야 하는지에 대한 가이드가 되었으면 좋겠다.