OTT 검색 서비스: Wherewatch - (5)Coverflow(or Carousel?) 구현기

김 주현·2023년 7월 28일
0

Wherewatch 개발일지

목록 보기
5/5

화면 설계할 때 나름 포인트로 잡았던 게 인물 페이지의 프로필 사진 슬라이드였다. 뭐.. 어떻게든 만들겠지! 하고 대책없이 했던 건데, 막상 구현하려고 하니 과거의 나를 때리고 싶었다...

일단 완성본은 다음과 같다.

내가 만든 Carousel

사랑해요 김다미

얼추 관련해서 검색해봤을 때 나와있는 포스팅들이 많아서 금방 만들겠다 생각했는데, 은근 내가 원하는 효과는 없었다. 내가 원했던 효과는 다음과 같다.

(1) 드래그 기능
: 버튼을 눌러서 넘기는 형식이 아니라 드래그를 통해 사진을 넘길 수 있어야 했다.

(2) 한 스와이프당 하나씩만
: 마우스가 드래그하는 만큼 움직이는 것이 아니라 드래그 한 번당 스와이프 한 번이 이루어져야 했다.

(3) 해당 번째 사진뿐만 아니라 양 옆의 사진들도 보이기
: 현재 인덱스에 맞는 사진만 크게 보이고 양 옆의 사진들은 작고 반투명하게 보여지고 싶었다.

(4) 네비게이션 기능
: 아래에 네비게이션이 달려서 해당 인덱스를 선택하면 그 사진이 보이게 하고 싶었다.

그런데 이런 게 Carousel과 가까운지, 아니면 Coverflow에 가까운지 모르겠다. 단적으론 Carousel은 한 화면에 하나만 보여지는 게 많던데, 그러면 Coverflow에 좀 비슷하지 않으려나~ 싶은 생각.

또, Coverflow를 검색했을 때 Swiper라는 라이브러리가 있더랬다. 그런데 꽤나 설치할 게 많기도 했고, Framer Motion으로도 충분히 구현할 수 있을 것 같아서 쓰지 않았다. 걍 쓸걸

첫 번째 시도

구조를 어떻게 잡아야 할지 긴가민가해서, 일단 첫 번째로 시도해본 건 useMotionValue과 useTransform를 사용하는 거였다.

왜 이걸 생각했냐면, 드래그를 하게 되면 x값이 연속적으로 변하게 되는데, 이 x값에 따라서 사진들의 scale과 opacity가 변해야 한다고 생각했다. 따라서 x에 useMotionValue를 주고, scale과 opacity에 x에 따른 값을 넣어주면, 자연스럽게 변한다고 생각함!

구조 설계

구조는 다음과 같이 잡았다. 이미지를 감싸는 Wrapper가 있고, 이미지들의 총넓이를 감싸는 Container가 있고, 그 Container를 움직이게 하는 DragContainer가 있다.

// App.tsx
import { useState } from "react";

import { motion, useMotionValue, useTransform } from "framer-motion";

const urls = [
  "https://mblogthumb-phinf.pstatic.net/MjAyMDAzMDRfMyAg/MDAxNTgzMjk5NjUxNTUw.XOqQB876ShDXXyAliMi9VQCnw1LWw-6L0tvoi-npyvog.y8otXN1JHqwJH1KH_hmV1KCcwfrM3QHp4nU_u6dKzAEg.JPEG.boy906555/SE-67e64e84-ac55-42ab-b683-6302be3117cb.jpg?type=w800",
  "https://mblogthumb-phinf.pstatic.net/MjAyMDAzMDRfMyAg/MDAxNTgzMjk5NjUxNTUw.XOqQB876ShDXXyAliMi9VQCnw1LWw-6L0tvoi-npyvog.y8otXN1JHqwJH1KH_hmV1KCcwfrM3QHp4nU_u6dKzAEg.JPEG.boy906555/SE-67e64e84-ac55-42ab-b683-6302be3117cb.jpg?type=w800",
  "https://mblogthumb-phinf.pstatic.net/MjAyMDAzMDRfMyAg/MDAxNTgzMjk5NjUxNTUw.XOqQB876ShDXXyAliMi9VQCnw1LWw-6L0tvoi-npyvog.y8otXN1JHqwJH1KH_hmV1KCcwfrM3QHp4nU_u6dKzAEg.JPEG.boy906555/SE-67e64e84-ac55-42ab-b683-6302be3117cb.jpg?type=w800",
  "https://mblogthumb-phinf.pstatic.net/MjAyMDAzMDRfMyAg/MDAxNTgzMjk5NjUxNTUw.XOqQB876ShDXXyAliMi9VQCnw1LWw-6L0tvoi-npyvog.y8otXN1JHqwJH1KH_hmV1KCcwfrM3QHp4nU_u6dKzAEg.JPEG.boy906555/SE-67e64e84-ac55-42ab-b683-6302be3117cb.jpg?type=w800",
];

function App() {
  return (
    <div className="draggable-container">
      <div
        className="images-container"
        style={{
          display: "flex",
          flexDirection: "row",
          gap: "12px",
          marginLeft: "25%",
        }}
      >
        {urls.map((url, index) => (
          <div
            className="image-wrapper"
            style={{
              minWidth: "250px",
              maxWidth: "250px",
              borderRadius: "4px",
              overflow: "hidden",
            }}
            key={index}
          >
            <img
              style={{
                display: "block",
                width: "100%",
                aspectRatio: "3/4",
                objectFit: "cover",
              }}
              src={url}
            />
          </div>
        ))}
      </div>
    </div>
  );
}

export default App;

그러면 이런 화면이 완성된다.

이제 여기에 motion component를 달아서 draggable하게 만들면~

// App.js

// ...
<motion.div drag="x" className="draggable-container">
// ...

슝다미

선택한 효과 설정

이제 선택한 아이디만 크게 보이고, 나머지는 작게 하게 해보자.

// App.tsx

function App() {
  const [selectedId, setSelectedId] = useState(0);

  return (
    <>
      <div style={{ margin: "24px auto", width: "fit-content" }}>
        <button onClick={() => setSelectedId(selectedId - 1)}>왼쪽</button>
        현재 Id: {selectedId}
        <button onClick={() => setSelectedId(selectedId + 1)}>오른쪽</button>
      </div>

//...

            <motion.div
              className="image-wrapper"
              style={{
                minWidth: "250px",
                maxWidth: "250px",
                borderRadius: "4px",
                overflow: "hidden",
              }}
              animate={{
                scale: selectedId === index ? 1 : 0.9,
                opacity: selectedId === index ? 1 : 0.5,
              }}
              key={index}
            >
                
    </>
  )
}
// ...

드래그에 따라 효과 적용

그러나! 위처럼 코딩을 하게 되면 드래그에 따라 효과를 적용하기 어렵다. 왜냐면 지금 상태는 연속적인 값이 아닌 selectedId라는 State에 의해서 animate가 적용이 되기 때문. 따라서 드래그를 했을 때 변하게 되는 x의 값에 따라 scale와 opacity가 적용되어야 한다.

그래서~ x에 useMotionValue를 넣고, scale과 opacity에 useTransform을 넣는다.


// ...
  const containerRef = useRef<HTMLDivElement>(null);

  const x = useMotionValue(0);

  const scale = useTransform(
    x,
    [
      containerRef.current ? -containerRef.current.offsetWidth / 2 : -1024,
      0,
      containerRef.current ? -containerRef.current.offsetWidth / 2 : 1024,
    ],
    [0.9, 1, 0.9]
  );

  const opacity = useTransform(
    x,
    [
      containerRef.current ? -containerRef.current.offsetWidth : -1024,
      0,
      containerRef.current ? -containerRef.current.offsetWidth : 1024,
    ],
    [0.5, 1, 0.5]
  );

// ..

  return (
    // ...
      <motion.div drag="x" style={{ x }} className="draggable-container">
        <motion.div
          ref={containerRef}
          className="images-container"

// ...

            <motion.div
              className="image-wrapper"
              style={{
                minWidth: "250px",
                maxWidth: "250px",
                borderRadius: "4px",
                overflow: "hidden",
                scale,
                opacity,
              }}
              // animate={{
              //   scale: selectedId === index ? 1 : 0.9,
              //   opacity: selectedId === index ? 1 : 0.5,
              // }}
              key={index}
            >
//..

그러면.. 이렇게 된다.

모든 아이템이 다 x의 영향을 받게 되어서 다 같이 효과를 얻는다.😇 그래서 x를 계속 받아오면서도 각각 아이템들에 적절한 효과를 받게 할 순 없을까~ 생각해 봤다.

안 되는 것 같다. 왜냐면 동적으로 바꿔주려고 해도, scale과 opacity는 Hook으로 동작하기 때문이고, 하나의 값으로 모든 아이템에 맵핑이 되기 때문이다.

또한, selectedId로 네비게이션을 받으려면 이와 같은 방법으론 효과가 적용되지 않을 터였다.

이 말은? 다른 방향으로 가닥을 잡아야 한다! 삽질의 시작

두 번째 방법

어쨌든 네비게이션을 위해 효과가 selectedId를 중심으로 이루어져야 한다는 판단이 세워졌다. 그리고 효과를 동시에 적용하는 것보다, 나누어서 생각을 해야겠다는 느낌이 왔다.

(1) selectedId에 따라 강조 효과
(2) 드래그에 따라 selectedId 변경

x의 값을 리니어하게 따라가며 효과를 적용하긴 어려울 것 같으니, 드래그의 범위를 한 화면으로 설정하고, 드래그를 하고 나면 selectedId가 변경되는 식으로 접근해보고자 했다.

그러려면.. 결국엔 드래그가 되는 척 보여야 했다. 왜냐면 드래그는 실제로 효과를 먹게하는 직접적인 요소가 아니라, selectedId를 바꿔주는 트리거가 되어야 했으니까. 따라서 dragContraints와 dragElastic, 그리고 onDragEnd 이벤트를 이용했다.

구조 설계

const handleDragEnd: DragHandlers["onDragEnd"] = (event, info) => {
  // 뚜잇썸띵
}

// ...
      <motion.div
        drag="x"
        dragConstraints={{ left: 0, right: 0 }}
        dragElastic={0.1}
        onDragEnd={handleDragEnd}
        className="draggable-container"
      >
          
          // ...
          
          
            <motion.div
              className="image-wrapper"
              style={{
                minWidth: "250px",
                maxWidth: "250px",
                borderRadius: "4px",
                overflow: "hidden",
              }}
              animate={{
                scale: selectedId === index ? 1 : 0.9,
                opacity: selectedId === index ? 1 : 0.5,
              }}
              key={index}
            >
                
//...

그러면 다음과 같이 인터렉션이 발생한다.

제약을 걸어놓은 덕분에 x는 0을 유지하게 되고, 돌아오는 느낌은 dragElastic으로 주었다.

onDragEnd

이제 어떻게 해야 하느냐, 바로 드래그가 끝났을 때 마우스가 처음 눌렸을 때보다 얼마만큼 이동했는지, 어디로 이동했는지, 가속도는 어떤지를 알아낸 다음 그것에 따라 selectedId를 설정해주면 되는 것이다.

그걸 알아내는 방법은 간단하다. onDragEnd에 넘어오는 인자가 바로 그거다(ㅋㅋ)

offset

지금 보면 왼쪽으로 땡기면 offset의 값이 음수가 나오고, 오른쪽으로 땡기면 양수가 나온다. 또, 해당 값은 마우스 이동한 양이다.

velocity

벨로시티는 가속도의 값이다. 역시 왼쪽은 음수이며 오른쪽은 양수이다. 벨로시티의 값도 알아야 하는 이유는, offset의 값이 작아도 velocity의 모먼텀이 크다면, 사용자가 넘기려는 의도가 있었다고 판단하려고 그렇다. 말이 왜이래

handleDragEnd

그래서~ 어떻게 코딩할 거냐면 이렇게 할 거다.

  const slideToRight = () => {
    if (selectedId <= 0) return;

    setSelectedId(selectedId - 1);
  };

  const slideToLeft = () => {
    if (selectedId >= endSize) return;

    setSelectedId(selectedId + 1);
  };

  const handleDragEnd: DragHandlers["onDragEnd"] = (event, info) => {
    const offset = info.offset.x; // 이동한 양
    const velocity = info.velocity.x; // 가속도

    if (!viewportRef.current) return;

    // To Right
    if (
      offset >= viewportRef.current.clientWidth / 3 || velocity >= 500
    ) {
      slideToRight();
    }

    // To Left
    if (
      offset <= -(viewportRef.current.clientWidth / 3) || velocity <= -500
    ) {
      slideToLeft();
    }
  };

//...


      <motion.div
        ref={viewportRef}
        drag="x"
        dragConstraints={{ left: 0, right: 0 }}
        dragElastic={0.1}
        onDragEnd={handleDragEnd}
        className="draggable-container"
      >
          
          //...

사진을 넘기려는 의도는 다음과 같이 판단하였다.

(1) 화면의 1/3만큼 드래그 했는가?
(2) 아니면 벨로시티가 임계값을 넘길 만큼 강했는가?

(1)을 위해 viewportRef를 설정해서 화면의 크기를 알아내었다. 그런 다음 왼쪽인지 오른쪽인지 알아낸 다음 각각에 맞게 selectedId를 설정해주었다.

그러면 이제 selectedId에 따라 애니메이션을 작동시키면 된다.

  const imgRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!scope.current) return;

    animate(
      scope.current,
      {
        x: imgRef.current ? selectedId * -imgRef.current.clientWidth : 0,
      },
      { type: "spring", damping: 100, stiffness: 500 }
    );
  }, [selectedId]);


// ... 

            <motion.div
              ref={imgRef}
              className="image-wrapper"

selectedId가 바뀌면, scope를 대상으로 현재 selectedId와 이미지 크기를 곱해준 만큼 x를 이동시킨다.

완성

그럼 다음과 같은 인터렉션이 완성!

후후후후...

보완해야 할 점

근데 지금 사진으로 드래그하면 이렇게 된다.

ㅋㅋ

그래서 아마 div background-image로 넣어야 하지 않을까 싶다.


후기

은근 삽질을 많이 한 UI Component. 물론 내가 framer motion에 대한 지식도 얕고, 구현 방향을 잘못 잡은 것도 있지만!

만들어 놓으니까 넘 예쁘고 뿌듯해서 아예 npm으로 함 배포해볼까? 싶은 생각도 든다. 오늘의 삽질 끄읕-!


profile
FE개발자 가보자고🥳

1개의 댓글

comment-user-thumbnail
2023년 7월 28일

좋은 글이네요. 공유해주셔서 감사합니다.

답글 달기