React Music Progressbar (2)

김 주현·2023년 8월 27일
0

UI Component 개발

목록 보기
3/11

저번 포스팅에서는 전체적으로 어떤 부분이 포인트가 됐는지를 짚었다면, 이번 포스팅에서는 실제 코드를 보면서 짚어보자.

물론,, 지금 만든 코드가 그렇게 만족스럽지는 않다. 나는 이 컴포넌트를 uncontrolled component로 만들고 싶었지만 뭔가 그렇게 만들 수 없는... 구조를 가지고 있달까. 그래서 일단 controlled component로 구현한 상태이다. 왜 그런지는 후술.

코드

/** React */
import React, { useEffect, useState } from 'react';

/** Styled Components */
import {
  Container,
  HoverTimeContainer,
  HoverTimeLabel,
  ProgressBackground,
  ProgressContainer,
  ProgressCurrent,
  ProgressIndicator,
  TimeLabel,
  TimeLabelGroup,
} from './MusicProgressbar.styled';

/** Libs */
import useMeasure from 'react-use-measure';

/** Animation */
import { MotionConfig, useMotionValue, useTransform, type PanInfo } from 'framer-motion';

type MusicProgressbarProp = {
  musicDuration?: number;
  initialValue?: number;

  value?: number;
  onChange?: (value: number) => void;

  initialHeight?: number;
  expandedHeight?: number;
  heightBuffer?: number;
};

const MusicProgressbar = ({
  musicDuration = 2,

  // initialValue = 1,
  // value,
  // onChange,

  initialHeight = 2,
  expandedHeight = 4,
  heightBuffer = 12,
}: MusicProgressbarProp) => {
  // const musicDuration = 200;

  // const initialHeight = 2;
  // const expandedHeight = 4;
  // const heightBuffer = 12;

  const [containerRef, { left: containerLeft, width: containerWidth }] = useMeasure();
  const [hoverTimeRef, { width: hoverTimeWidth }] = useMeasure();

  /** Hover Time Label */
  const hoverProgress = useMotionValue(0.5);
  const hoverLeft = useTransform(hoverProgress, (v) => {
    const calcuatedPercent = v - hoverTimeWidth / 2 / containerWidth;
    const maxPercent = (containerWidth - hoverTimeWidth) / containerWidth;

    return `${clamp(calcuatedPercent, 0, maxPercent) * 100}%`;
  });

  const roundedHoverProgress = useTransform(hoverProgress, (v) => roundTo(v, 3));
  const [hoverTimeProgress, setHoverTimeProgress] = useState(roundedHoverProgress.get());

  /** Progress */
  const progress = useMotionValue(0.5);
  const currentWidth = useTransform(progress, (v) => `${v * 100}%`);
  const indicatorLeft = useTransform(progress, (v) => `calc(${v * 100}% - 6px)`);

  const roundedProgress = useTransform(progress, (v) => roundTo(v, 3));
  const [currentProgress, setCurrentProgress] = useState(roundedProgress.get());

  const [isPanning, setIsPanning] = useState(false);
  const [isHovered, setIsHovered] = useState(false);

  /** Animation State */
  let animateState = isPanning ? 'panning' : isHovered ? 'hovered' : 'idle';

  useEffect(() => {
    roundedProgress.on('change', (v) => setCurrentProgress(v));
    roundedHoverProgress.on('change', (v) => setHoverTimeProgress(v));
  }, [roundedProgress, roundedHoverProgress]);

  /** Get Percent by event */
  const getProgressWithEvent = (pageX: number, containerLeft: number, containerWidth: number) => {
    const newPercent = (pageX - containerLeft) / containerWidth;
    return newPercent;
  };

  const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
    const newPercent = getProgressWithEvent(e.pageX, containerLeft, containerWidth);

    progress.set(newPercent);
    hoverProgress.set(newPercent);

    setIsPanning(true);
  };

  const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
    if (isPanning) return;

    const newPercent = getProgressWithEvent(e.pageX, containerLeft, containerWidth);

    hoverProgress.set(newPercent);
  };

  const handlePan = (event: PointerEvent, info: PanInfo) => {
    const deltaInPercent = info.delta.x / containerWidth;
    const newPercent = clamp(progress.get() + deltaInPercent, 0, 1);

    progress.set(newPercent);
    hoverProgress.set(newPercent);
  };

  const handlePanEnd = () => {
    setIsPanning(false);
    // if (onChange) onChange(progress.get() * musicDuration);
  };

  return (
    <MotionConfig transition={motionTransition}>
      <Container>
        <ProgressContainer
          animate={animateState}
          onPointerDown={handlePointerDown}
          onPointerUp={() => setIsPanning(false)}
          onPointerEnter={() => setIsHovered(true)}
          onPointerLeave={() => setIsHovered(false)}
          onPointerMove={handlePointerMove}
          onPanEnd={handlePanEnd}
          onPan={handlePan}
          style={{ height: initialHeight + heightBuffer }}
          ref={containerRef}
        >
          <ProgressBackground
            style={{ height: initialHeight }}
            variants={{
              hovered: { height: expandedHeight },
              panning: { height: expandedHeight },
            }}
          >
            <ProgressCurrent style={{ width: currentWidth }} />
          </ProgressBackground>

          <ProgressIndicator style={{ left: indicatorLeft }} variants={indicatorVariants} />

          <HoverTimeContainer
            style={{ left: hoverLeft }}
            variants={hoverTimeVariants}
            ref={hoverTimeRef}
          >
            <HoverTimeLabel>{formattedTime(musicDuration * hoverTimeProgress)}</HoverTimeLabel>
          </HoverTimeContainer>
        </ProgressContainer>

        <TimeLabelGroup animate={animateState} variants={timeLabelGroupVariants}>
          <TimeLabel>{formattedTime(musicDuration * currentProgress)}</TimeLabel>
          <TimeLabel>{formattedTime(musicDuration)}</TimeLabel>
        </TimeLabelGroup>
      </Container>
    </MotionConfig>
  );
};

/* #region Animation Const */
const motionTransition = { type: 'spring', bounce: 0, duration: 0.5 };

const indicatorVariants = {
  hovered: { opacity: 1 },
  panning: { opacity: 1, scale: 1.5 },
};

const hoverTimeVariants = {
  hovered: { opacity: 1 },
  panning: { opacity: 1 },
};

const timeLabelGroupVariants = {
  hovered: { color: '#FFF' },
  panning: { color: '#FFF' },
};
/* #endregion */

/* #region Utils */
const clamp = (num: number, min: number, max: number) => Math.max(Math.min(num, max), min);

const roundTo = (number: number, decimalPlaces: number) => {
  const multiplier = Math.pow(10, decimalPlaces);

  return Math.round(number * multiplier) / multiplier;
};

const formattedTime = (duration: number) => {
  const min = Math.floor(duration / 60);
  const sec = Math.floor(duration % 60);

  return `${min.toString()}:${sec.toString().padStart(2, '0')}`;
};
/* #endregion */

export default MusicProgressbar;

useMeasure

  const [containerRef, { left: containerLeft, width: containerWidth }] = useMeasure();
  const [hoverTimeRef, { width: hoverTimeWidth }] = useMeasure();

이 Hook은 ref와 bounds를 return한다. 넘겨받은 ref를 크기를 알고자 하는 대상에 ref로 넘겨주면 bounds가 해당 크기를 가지게 된다. 전체적인 크기를 알기 위해 containerRef와, hover 시에 나타나는 타임 라벨의 크기를 알기 위해서 hoverTimeRef를 받아와 각각 크기를 받아왔다.

hoverProgress와 progress

이 부분이 가장 많이 고민했던 부분이다. 처음에 구현할 때는 당연히 하나의 진행상태로도 hover time와 실제 클릭했을 때의 진행 상태를 가리킬 수 있을 거라고 생각했다.

코드도 유사한 부분이 많아서, 유사라기보단 거의 똑같은 코드여서 하나로 합칠 생각을 했었으나 결국엔 따로 두어야겠구나 하는 사실을 깨달았다.

hoverProgress는 hover를 했을 때 해당 위치에 대한 시간을 반환하기 위해 퍼센트를 가리키는 value이다. 이 hoverProgress에 따라 hover time label의 값과 left값이 달라진다.

progress는 panning을 했을 때 해당 위치에 대한 시간을 반환하기 위해 퍼센트를 가리키는 value이다. 이 progress에 따라 currentProgress의 width가 달라진다.

요는 둘 다 해당 위치에 대한 시간을 반환하기 위한 퍼센트이고, hover나 panning에 따라만 다른 것. 그러므로,, hover 시에 진행률을 계산하고 panning이 시작되면 hoverProgress의 값을 넘겨주면 되지 않을까~? 하는 아이디어.

그런데 이게 좀 힘든 게, progress에 따라 계산되는 currentWidth가 useTransform Hook을 쓰고 있다는 점이다.

  const progress = useMotionValue(0.5);
  const currentWidth = useTransform(progress, (v) => `${v * 100}%`);
  const indicatorLeft = useTransform(progress, (v) => `calc(${v * 100}% - 6px)`);

이걸 hoverProgress로 계산하게 되면 빨간색 진행바가 hover시에도 졸졸 따라다닌다. 또,, 현재 선택된 진행률에 따른 시간 역시 hover 시간으로 표시가 된다.

이를 해결하려면 panning 시에만 값이 적용되게 해야하는데, 그러려면 마지막으로 panning을 끝낸 퍼센트를 또 따로 저장해놔야 한다. 그런데 그럴 거면 그냥 애초에 따로 두는 게 더 보기 편한 게 아닌지~ 하는 생각.

으으음 framer motion에서 제공하는 hook 중에서 이와 관련된 머시깽이가 있을 것 같은데 내가 찾아봤을 땐 적절한 것은 없었다. 내가 응용을 못하는 것일 수도 있지만,,, 지금으로선 hover와 progress를 따로 두는 것이 베스트라고 생각했다.

  /** Hover Time Label */
  const hoverProgress = useMotionValue(0.5);
  const hoverLeft = useTransform(hoverProgress, (v) => {
    const calcuatedPercent = v - hoverTimeWidth / 2 / containerWidth;
    const maxPercent = (containerWidth - hoverTimeWidth) / containerWidth;

    return `${clamp(calcuatedPercent, 0, maxPercent) * 100}%`;
  });

  const roundedHoverProgress = useTransform(hoverProgress, (v) => roundTo(v, 3));
  const [hoverTimeProgress, setHoverTimeProgress] = useState(roundedHoverProgress.get());

  /** Progress */
  const progress = useMotionValue(0.5);
  const currentWidth = useTransform(progress, (v) => `${v * 100}%`);
  const indicatorLeft = useTransform(progress, (v) => `calc(${v * 100}% - 6px)`);

  const roundedProgress = useTransform(progress, (v) => roundTo(v, 3));
  const [currentProgress, setCurrentProgress] = useState(roundedProgress.get());

uncontrolled component

처음에 생각했던 컴포넌트는 다음과 같이 동작할 수 있는 형태로 떠올렸었다.

const [currentProgress, setCurrentProgress] = useState(0);

const handleChanged = (value: number) => {
  setCurrentProgress(value);
}

const handleClick = () => {
  setCurrentProgress(prevState => prevState + 10);
}

return (
  <MusicProgressbar value={currentProgress} onChanged={handleChanged} />
  <button onClick={handleClick}>10초 후로</button>
)

그래서,, MusicProgressbar에서는 넘겨받은 value로만 값을 구현시키면 될 것 같았는데! 문제는 useMotionValue와 사용자 터치 이벤트였다.

자연스러운 애니메이션을 위해서는 useMotionValue를 사용해야 하는 상황이었고, 사용자 터치에 대한 반응을 framer-motion에서 제공하는 handler로 처리하고 있었다.

const progress = useMotionValue(0.5);

const handlePan = (event, info) => {
  // Do Something..
  progress.set(value);
}

<ProgressContainer onPan={handlePan} />

그리고 이렇게 progress의 값이 바뀌게 되면 이 progress 값에 종속적인 값을 계산하게 된다.

  const currentWidth = useTransform(progress, (v) => `${v * 100}%`);
  const indicatorLeft = useTransform(progress, (v) => `calc(${v * 100}% - 6px)`);

  const roundedProgress = useTransform(progress, (v) => roundTo(v, 3));
  const [currentProgress, setCurrentProgress] = useState(roundedProgress.get());

종속적인 값을 계산하고 나면, 이 값이 객체로 넘어가게 되어 애니메이션이 발현된다.

<ProgressCurrent style={{ width: currentWidth }} />
<ProgressIndicator style={{ left: indicatorLeft }} />

이런 식으로 연속적인 반응으로 계산이 이루어지는 건데~ 결국엔 이 모든 계산의 시작은 progress인 것. 이걸 외부에서 받게 되면, panning 과정에서 이뤄지는 값 업데이트가 그대로 상위 컴포넌트로 올라갈 텐데, 그러는 과정에서 불필요한 re-rendering이 많이 일어나게 된다. (framer motion이 아니고, react의 prop으로 이뤄지니까)

나는 Panning 애니메이션에 대한 건 내부에서 처리하고 onChanged라든가 하는 식으로 Panning이 끝났을 때의 값을 돌려주고 싶고, 또, 컴포넌트 외부에서 값을 주면 그 값에 대한 계산도 이루어지게 하고 싶은 것. 근데! 이걸! 어떻게! 구현해야 할지 아주 알 수 없는 것...

아무튼 이에 대한 생각은 난 여기까지 인 것 같다. 아직 실력이 넘 부족해!

profile
FE개발자 가보자고🥳

0개의 댓글