React Music Progressbar (1)

김 주현·2023년 8월 25일
1

UI Component 개발

목록 보기
2/11
post-thumbnail

리액트에서 음악 재생바를 만들 일이 있어서 만들어 보았다. 은근 신경써야 할 부분들이 많아서 재밌었던 개발!

일단 슥슥 생각나는 대로 작성한 거라 코드가 꽤나 많이 복잡하다. 코드는 정리해서 따로 올려보도록 해야겠다.

개발 포인트

보기엔 쉬워보였는데 디테일한 UX들이 많아서 꽤나 생각할 게 많았다.

Buffer를 포함한 터치 포인트

자세히 보면 ProgressBar 자체가 아니라 위 아래로 buffer만큼 선택 범위가 확장된 것을 볼 수 있다.

ProgressBar 크기 자체가 워낙 작다 보니까 정확하게 그 지점을 포인팅하기란 쉽지 않다. 그러므로 크기가 작은 UI를 개발할 때는 이렇게 buffer를 넣어서 그 근처 범위까지 터치 포인트로 만들기! (손가락은 정확하지 않은 포인팅 기기이다)

Hovered, Panning 상태 기반 동작

이제서야 framer motion을 어떻게 써야 깔끔하게 동작하는지에 대해서 좀 이해했다. framer-motion은 상태를 기반으로 해야 깔끔한 동작을 할 수 있다!

이 동작을 보면 각각의 상태에 대해서 다음과 같은 애니메이션이 나타난다.

  • Hover: Hover Time Label Opacity/Position, Progressbar Height, Indicator Opactiy, Time Label Opactiy
  • Panning: Indicator Scale

상태 기반이 아니였다면,, 하나하나 따로 설정을 해주었어야 해서 굉장히 가독성이 안 좋았을 거고, 한 상태에 대해서 어떤 애니메이션이 일어나는지 쉽게 파악하지 못했을 것이다.

또, 지금 보면 Panning이 되고 나면 Hover 상태를 벗어났는데도 Hover의 효과가 유지되는 것을 확인할 수 있다. UX적으로 그냥 우리가 당연하다고 인지하는 건데, 이게 또 실제 구현을 해보면 알겠지만 이는 다른 상태이기 때문에 유지가 되는 게 이상한 상태다!

만약 이를 하나하나 설정해준다면 다음과 같은 코드가 나올 것이다.

<ProgressContainer animate={{ height: isHover || isPanning ? '4px' : '1px' }} />
<ProgressIndicator animate={{ opacity: isHover ? 1 : 0, scale: isPanning ? 1.25 : 1 }} />
<TimeLabel animate={{ opacity: isHover ? 1 : 0 }} />

실제로 저렇게 쓰는 건 아니고, 대충 상황만 따온 것. 애니메이션을 먹일 속성이 많아질수록 조건 분기가 많아지므로 코드를 보는 게 꽤나... 고통스러울 것이다.

그럼 이 상황을 어떻게 타개하냐! 그것은 바로 framer-motion의 variant 속성을 이용하는 것. 상태에 따른 애니메이션이 일어나는 과정을 정리해보면 다음과 같다.

Framer Motion의 상태 기반 애니메이션 과정

  1. Hover State, Panning State를 나타내는 Flag를 만든다. (isHovered, isPanning)
    const [isHovered, setIsHovered] = useState(false);
    const [isPanning, setIsPanning] = useState(false);
  2. Animation State를 나타내는 Flag를 만든다. (animateState)
  3. Hover, Panning State에 따라 어떤 Animation State를 적용시킬지 분기한다. (물론 우선순위를 따져서 분기해야 한다. Panning이 맥락에서 더 가까운 상태니 Panning부터 체크한다)
    let animateState = isPanning ? "panning" : isHovered ? "hovered" : "idle"
  4. Hover, Panning State를 일으키는 이벤트에 update dispatch를 준다.
    <motion.div
      onPointerEnter={() => setIsHovered(true)}
      onPointerLeave={() => setIsHovered(false)}
      onPointerDown={() => setIsPanning(true)}
      onPointerUp={() => setIsPanning(false)}
      onPanEnd={() => setIsPanning(false)}
    />
  5. 애니메이션이 들어갈 구조의 최상위 컨테이너의 animate에 state를 넣고, 자식들은 variants로 각 상태마다 어떤 애니메이션을 할지 지정해준다.
    let animateState = isPanning ? "panning" : isHovered ? "hovered" : "idle"
    <motion.div animate={animateState}>
      <motion.div
    	style={{ height: "1px" }}
        variants={{
          panning: { height: "4px" },
          hovered: { height: "4px" },
        }}
      />
      <motion.div
        variants={{
          panning: { opacity: 1, scale: 1.25 },
          hovered: { opacity: 0, scale: 1 },
        }}
      />
    </motion.div>

이 과정 중에서 5번이 제일 핵심이다. framer motion의 orchestration 특징을 이용한 코드인데, 이렇게 부모의 animate에 애니메이션 속성({ opacity: 1 })이 아니라 "hovered" 같은 label을 넣어주게 되면, 자식에게 그대로 해당 animate 속성이 넘겨지게 된다. 한 마디로 말해서 상태의 상속이 된다. 그렇게 되면 자식에서는 해당 variants에 따라 어떤 효과를 먹일지 지정만 해주면 되는 것! 멋져부러

만약 저기에서 더 깔끔하게 작성하고 싶다면 해당 variants 부분을 객체로 선언해주어도 된다.

let animateState = isPanning ? "panning" : isHovered ? "hovered" : "idle"

const backgroundInitial = { height: "1px" }

const backgroundVariants = {
  panning: { height: "4px" },
  hovered: { height: "4px" }
}

const indicatorVariants = {
  panning: { opacity: 1, scale: 1.25 },
  hovered: { opacity: 0, scale: 1},
}

<motion.div animate={animateState}>
  <motion.div
	style={backgroundInitial}
    variants={backgroundVariants}
  />
  <motion.div
    variants={indicatorVariants}
  />
</motion.div>

여기에서 더~ 나가면 따로 애니메이션을 담당하는 파일로 분리시키면 관심사도 분리시킬 수 있다.

const { backgroundInitial, backgroundVariants, indicatorVariants } from './animationVariants.tsx"

let animateState = isPanning ? "panning" : isHovered ? "hovered" : "idle"

<motion.div animate={animateState}>
  <motion.div
	style={backgroundInitial}
    variants={backgroundVariants}
  />
  <motion.div
    variants={indicatorVariants}
  />
</motion.div>

variants 부분을 인라인으로 넘겨주게 되면 랜더를 할 때마다 객체를 생성해서 넘겨주게 되는데, 이렇게 따로 객체로 만들어주면 새로 생성할 필요가 없어서 성능상에 이점을 가지게 된다.

머... 물론 매번 이렇게 따로 객체 선언을 해주어야 하는 건 아니다. 렌더링이 자주 되지 않는 상태라든가, 인라인으로 넘겨줘야 하는 상황이라든가 한다면 굳이 할 필요는 없다. 중요한 건 언제나 맥락~!

경우에 따라서 다른 객체들에게도 동일한 효과(ex. FadeIn)를 주는 것이 많다면 확실히 따로 객체로 선언해주는 것이 낫겠다.

Hover Time Label Position Clamp

Hover를 했을 때 나타는 Time Indicator Label의 위치를 자세히 봐보자.

기본적으로 마우스 포인터의 위치를 기준으로 라벨이 가운데정렬을 하고 있음을 알 수 있다. 하지만 양끝단으로 가게되면 더이상 움직이지 않고 정지한다.

가운데 정렬로 따라다니는 객체의 경우, 화면의 끝단으로 가게 된다면 객체가 짤릴 수도 있고, 또는 레이아웃이 망가질수도 있다. 내 노션 위젯의 공감 팝업 모달이 딱 그랬다

그래서 이 경우를 방지하고자 특정 포지션에 다다르면 더이상 움직이지 않고 정지하게 만드는 것이 좋다. 개인적으론 이 부분이 사람들이 가장 많이 놓치는 포인트가 아닐까 싶다. 설령 생각했어도 그냥 넘겨~ 하지 않을까? 구현하는 걸 생각만해도 귀찮기도 하고(...)

아무튼 이걸 구현하고자 한다면 생각보다 쉽지 않다는 걸 알 수 있다. 물론 나는 예전에 Resizing에 대해서 엄청난 삽질을 했던 경험들이 있어서😇 그때 얻은 노하우로 적절하게 구현할 수 있었다.

으음... 이걸 설명하려니까 어디서부터 설명을 해야할지 모르겠는데, 일단 기본 구조부터 설명해보겠다.

백분율 기반 동작

이 Progressbar는 백분율을 기준으로 동작한다. 처음이 0, 끝이 1로 지정해두고 해당 마우스 포인터의 위치를 백분율로 가져온다. 그리고,, 그걸 전체 크기로 곱하면 해당 포지션의 픽셀값을 알 수 있다.

예를 들어 0.5의 값을 가지고 있었고, Progressbar의 Width가 100px이었다면, 50px이겠다.

이 픽셀값은 결국 마우스 포인터의 위치이다. 이때, 이걸 그대로 쓰면 가운데 정렬이 아니라 왼쪽 정렬이 돼서 따라다닌다.

그래서,, hover time label의 width를 절반 나눈 만큼 해당 포인터 위치에서 빼줘야 한다. 만약 width가 50px이라면, 절반인 25px를 50px에서 빼준 값이 hover time label의 left값이다. 그래야 가운데 정렬이 된다.

하지만 다시 말하지만 이 Progressbar는 백분율을 기준으로 동작한다. 이런 식으로 픽셀값 기준으로 생각하면 코드가 복잡해진다. 어케 알앗냐면 나도 알고 싶지 않앗음...

그러므로 픽셀값이 아니라 저 값을 백분율로 만든 다음, 0.5에서 빼줘야 한다. 그런 다음 실제 픽셀 값으로 반환하는 거고. 다시 정리하자면, 백분율은 그 자체로 가되, 실제 left 값으로 반환할 때는 해당 백분율에서 hover time label의 Width 절반에 해당하는 퍼센트를 빼주고 반환해야 한다는 것. 아직 정리되진 않았지만 코드로 보면 다음과 같다.

  const hoverProgress = useMotionValue(0.5);
  const hoverLeft = useTransform(
    hoverProgress,
    (v) =>
      `${
        clamp(
          v - hoverTimeBounds.width / 2 / bounds.width,
          0,
          (bounds.width - hoverTimeBounds.width) / bounds.width
        ) * 100
      }%`
  );

hoverTimeBounds는 Hover Time Label의 크기를 의미하고, bounds는 Progressbar의 크기를 의미한다. 가운데 정렬을 위해서 현재 백분율에서 Hover Time Label의 절반에 해당하는 퍼센트(hoverTimeBounds.width / 2 / bounds.width)를 빼주고, 최소값을 0으로 clamp해준다. 또한 최대 역시 전체 크기에서 Hover Time Label의 크기만큼 뺀 값에 해당하는 퍼센트((bounds.width - hoverTimeBounds.width) / bounds.width)를 최대값으로 clamp해준다.

hoverProgress는 Hover Time Label의 크기 생각할 필요 없이 현재 포인터의 위치를 백분율로만 나누는 것에 집중하면 된다.

          onPointerMove={(e) => {
            if (isPanning) return;

            const newPercent = clamp((e.pageX - bounds.left) / bounds.width, 0, 1);
            hoverProgress.set(newPercent);
          }}

이렇게 개념을 정립하니, 따로 실제로 panning할 때 구하는 state가 있었는데 합칠 수 있게 될 것 같다. 아직 코드를 정리가 안 돼서,, 이제 이 부분을 구현해야 한다.

Motion Value에서 React State로 Update

이게 무슨 말이냐면 framer motion은 React와는 다른 랜더링 구조를 가진다. 렌더링 구조? 생명주기? 뭐라고 불러야 하는지 잘 모르겠지만,, Framer Motion이 동작하는 과정은 React가 상태를 업데이트 하는 것과는 별개로 동작한다는 것을 말하고 싶었다. 그래서 Re-rendering이 없이 부드러운 애니메이션을 구현할 수 있는 거고.

하지만 그런 특성 때문에 현재 애니메이션 되고 있는 속성의 값을 알아내려면 따로 이벤트를 추가해야 한다. 다음과 같이는 사용할 수 없다. (없다기보단 변경된 값이 실시간으로 받아와지지 않는다. 내부적으로 useMotionValue는 클로저라, 렉시컬 스코핑 상태로 값을 관리하기 때문에 함수가 변하는 건 아니기 때문.)

  useEffect(() => {
    console.log(progress.get());
  }, [progress]);

사용하고 있는 모션 벨류가 있다는 전제조건 하에서, 전체적인 과정은 다음과 같다.

먼저 모션 벨류를 받을 React State를 선언한다.

const progress = useMotionValue(0.5);
const [progressState, setProgressState] = useState(0);

그 다음 progress에 값이 들어올 때, framer motion에서 제공하는 on 메소드를 통해 이벤트를 지정해준다.

useEffect(() => {
  progress.on("change", (v) => setProgressState(v));
}, [progress]);

그러면 React State로 받아올 수 있게 된다.

Render 최적화

그러나~ 이렇게 바로 state를 적용하게 되면, 수많은 밀리세컨드의 조정에도 re-rendering이 발생한다. 사용자는 밀리세컨 단위가 아닌 초단위로도 충분하기 때문에 일종의 debouncing을 해주어야 한다. 이는 useTransition을 이용해서, 특정 소수점 이하로는 버리거나 반올림하는 식으로 이루어질 수 있다. roundTo는 chatGPT의 도움을 받았다(ㅋㅋ)

const progress = useMotionValue(0.5);
const roundedProgress = useTransition(progress, v => roundTo(v, 2))

const [progressState, setProgressState] = useState(0);

useEffect(() => {
  roundedProgress.on("change", (v) => setProgressState(v));
}, [roundedProgress]);

const roundTo = (number: number, decimalPlaces: number) => {
  const multiplier = Math.pow(10, decimalPlaces);
  return Math.round(number * multiplier) / multiplier;
};

그러면 이제 0.11~ 이하의 자리수 숫자의 변경에는 업데이트가 일어나지 않는다.

profile
FE개발자 가보자고🥳

0개의 댓글