[#MZ2MO] 원형 프로그레시브 바 구현

RookieAND·2023년 7월 18일
91

Project

목록 보기
2/3
post-thumbnail

1. 개요

현재 개발 중인 MZ2MO 프로젝트에서는 재생 중인 음악의 타임라인과 현재 재생 시간을 보여주는 요구 사항이 존재했다. 우리의 UX 디자이너 주니가 예쁘게 만들어준 시안을 토대로 작업을 들어가보려 했는데.. 프로그레시브 바가 원형이라는 점이 문제였다.

시안은 정말 예쁘고 좋은데 원형으로 된 프로그레시브 바는 처음으로 구현해본 터라 여러 방법을 강구했고, 그 중에서 SVG로 해결하는 것이 가장 이상적이라는 결론을 내리고 작업에 착수했다.

처음에는 <div> 태그를 두 개 겹쳐서 해결하는 게 좋다고 생각했다. 하지만 생각해보니 곡이 재생된 시간을 백분율로 환산해서 원의 일부를 그려야 하는데, <div> 태그로는 css의 polygon을 사용해서 부채꼴을 그리는 방법 뿐이라고 생각했다. 근데 이건 배보다 배꼽이 더 커질게 분명하니.. SVG로 선회했다.

2. 원형 프로그레시브 바 구현

항상 기능을 구현하기 전에 유즈 케이스를 설계하고 이에 맞는 로직을 설계하는 것이 시간을 훨씬 단축시킨다. 이번에도 어김없이 요구사항을 정리하고 어떻게 이를 체크할 수 있을지를 먼저 설계했다.

사진을 보면 알 수 있듯이, 프로그레시브 바는 항상 원의 가장 위에서 시작되며 곡을 재생할 경우 시계 방향으로 영역을 확장하는 구조를 가졌다.

따라서 SVG 의 <circle> 태그를 사용하여 영역을 그리기로 하였으며, 해당 태그의 두 가지 속성을 활용해서 원의 일부 혹은 전체를 그릴 수 있었다.

  • stroke-dasharray : SVG 영역의 border에 dash 를 생성합니다. 숫자가 클수록 dash 사이에 넓은 공간을 만듭니다.
  • stroke-dashoffset : stroke가 시작되는 위치를 변경하는데 사용됩니다. 숫자가 클수록 시작점으로부터 더 많이 띄워준 다음 시작을 합니다.

정리하자면 stroke-dasharray 속성은 dash 의 길이와 각 dash 간의 길이를 설정하는 속성이다. 그렇다면 해당 값을 태그의 둘레만큼 설정한다면? 하나의 dash가 태그를 모두 감싸게 되므로 border가 한 줄로 이어진다.

이후 stroke-dashoffset 속성을 통해 stroke를 어디서부터 시작할지를 지정할 수 있다. 0인 경우 원래의 시작점에서부터 dash가 시작되지만 만약 값이 커진다면? 해당 값만큼의 길이를 제외한 나머지만 선이 그려진다.

그렇게 값이 커지다 요소의 둘레 길이와 같아지게 되면 dash가 사라지게 된다. 왜냐하면 요소의 둘레 길이만큼 dash를 시작하지 않게 되므로 자연스럽게 요소의 border 또한 비게 된다.

아래의 이미지는 반지름이 50인 <circle> 태그에 stroke-dashoffset 속성을 순차적으로 100, 200, 300 씩 준 상태이다. 사진으로도 알 수 있듯이 속성 값이 커질수록 보여지는 영역이 감소함을 알 수 있다.

이를 기반으로 현재 곡이 몇 초만큼 재생되었는지와 곡의 전체 길이를 알아내서 stroke-dashoffset 값을 동적으로 할당하는 로직을 아래와 같이 작성했다.

  const circleProgressRef = useRef<SVGCircleElement | null>(null);
  const [circleDashOffset, setCircleDashOffset] = useState(CIRCUMFERENCE);

  useEffect(() => {
    if (!playerInstance || !circleProgressRef.current) return;

    const maxDuration = playerInstance.getDuration();
    const currentProgress = (1 - currentDuration) / maxDuration;
    setCircleDashOffset(currentProgress * CIRCUMFERENCE);
    }
       
  }, [playerInstance, currentDuration]);

전체 곡의 길이에서 현재까지 재생된 시간을 뺀 나머지를 백분율로 치환하고, 이를 원의 둘레 (CIRCUMFERENCE) 에 곱해 적용할 strokeDashOffset state 값을 산출하였다.

이후 해당 state 값을 <circle> 태그에 동적으로 할당시킴으로서 변경된 dashOffset 값을 실시간으로 전달 받도록 로직을 설계하였다.

추가로 SVG 태그 내에 그라디언트를 적용해야 했는데, 이는 <linearGradient> 태그로 쉽게 해결이 가능했다.
<stop> 태그를 통해 그라디언트에 추가할 색상과 offset을 지정할 수 있고, gradientTransform 을 통해 그라디언트의 deg를 부여할 수 있다. 이후 생성된 gradient의 id를 <circle> 태그의 stroke에 부여하면 끝이다.

<svg
  onClick={handleChangeDuration}
  className="absolute z-10 -rotate-90"
  width="360"
  height="360"
  viewBox="0 0 360 360"
  fill="transparent"
>
  <defs>
    <linearGradient id="mz02" gradientTransform="rotate(135deg)">
      <stop offset="0%" stopColor="#1853FF" />
      <stop offset="100%" stopColor="#18FF59" />
    </linearGradient>
  </defs>
  <circle
    ref={circleProgressRef}
    className="mr-auto"
    stroke="url(#mz02)"
    cx="180"
    cy="180"
    r="178"
    strokeWidth="4"
    strokeDasharray={1125}
    strokeDashoffset={circleDashOffset}
  />
</svg>

3. 영역 클릭 시 재생 시간 변경

하지만 더 큰 문제는 프로그레시브 바 영역을 사용자가 눌렀을 경우 곡의 재생 시간을 변경해야 한다는 점이었다. 유투브로 치자면 사용자가 클릭한 타임 라인으로 영상을 이동해 재생시키는 그런.. 요구 사항이었다.

일반적인 막대 바였다면 전체 길이 중 사용자가 클릭한 지점의 offsetX 를 가져와서 비율을 계산한 후에 전체 곡의 길이만큼 곱해주면 되겠지만.. 문제는 구현한 형태가 원이라는 점이었다.

따라서 여러 방법을 생각한 결과, 아래와 같은 로직을 설계하고 구현하기로 결정하였다.

  1. 사용자가 클릭한 지점의 offsetXoffsetY 를 구한다.
  2. 재생 시간이 0% 일때의 점과 원의 중심, 그리고 사용자가 클릭한 지점이 이루는 각을 구한다.
  3. 2번 과정을 통해 도출된 각을 360으로 나누고, 전체 재생 시간을 곱하여 재생 시간을 구한다.

재생 시간이 0% 일때의 점은 쉽게 도출할 수 있다. 원의 중심 좌표로부터 offsetY를 원의 반지름만큼 추가시키면 된다. 또한 원의 중심 또한 offsetX와 offsetY가 원의 반지름만큼 있는 좌표이므로 도출이 가능하다.

따라서 사용자가 클릭한 지점의 offsetX와 offsetY를 계산한 후, 중심각을 구하는 로직을 아래와 같이 계산하여 적용했다.

  1. 사용자가 클릭한 점의 좌표와 재생 시간이 0%인 점의 좌표, 마지막으로 원의 중심을 이은 삼각형은 "이등변 삼각형" 이다. (원의 반지름이 두 변을 이루므로) 또한 중심각 θ 는 삼각형의 꼭지각이다.
  2. 이등변 삼각형의 꼭짓점에서 밑변과 수직이 되는 선을 그으면 두 개의 직각 삼각형이 나오며, 이를 기반으로 중심각의 절반인 θ/2 의 값을 알 수 있다.
  3. 360도에서 θ 을 나누어 비율을 구하고, 이를 전체 시간에 곱해 사용자가 선택한 타임라인을 산출하여 곡을 재생시키도록 한다.

편의상 재생 시간이 0% 인 점을 A, 원의 중심을 O, 사용자가 클릭한 점을 B 라고 정의하겠다. 그리고 선분 AB의 길이를 D, 원의 반지름의 길이를 R 로 정의하겠다.

상단의 그림에 있는 직각 삼각형에 대해서 cos(90 - θ/2) = R / (D / 2) 공식이 성립하므로, 이를 정리해서 θ 만 좌변에 남기면 아래와 같은 공식이 완성된다.

θ = 2 * arcsin((D / 2) / R) 공식을 통해 중심각 θ 를 구할 수 있다.

하지만 여기서 중요한 점은 프로그레시브 바의 폭이 3px 이라는 점이다. 이 말인 즉 사용자가 클릭한 위치에 따라서 계산해야 할 원의 반지름이 변경될 수 있다는 의미이므로, 사용자가 클릭한 점의 좌표와 원의 중심 간의 거리 (반지름) 또한 동적으로 계산해야 했다.

추가로 점 A 와 점 B 가 이루는 각이 180도를 넘기는 경우를 고려해야 했기 때문에, 이때는 클릭한 점의 offsetY 가 원의 중심보다 작을 경우에는 왼쪽 영역임을 알 수 있으므로 360도에서 산출된 각을 뺀 나머지를 적용하는 로직을 채택하였다.

왜 offsetX 가 아니라 offsetY를 고려해야 했냐면, dashOffset이 0일 때의 시작 위치가 오른쪽이었기 때문에 이를 위쪽으로 옮기기 위해서는 rotate 속성을 반시계 방향으로 90도 만큼 줘야 했다. 따라서 좌표 계산 또한 이에 맞춰 진행해야 했다.

위의 이미지는 rotate를 주지 않은 상태이고, 아래의 이미지는 rotate를 준 상태이다. 시작점이 요구 사항에 맞는 위치로 이동했음을 알 수 있다.

위에서 정리한 내용을 토대로 작성한 코드는 아래와 같다.

  const handleChangeDuration = (e: React.MouseEvent<SVGSVGElement>) => {
    if (!playerInstance || !circleProgressRef.current) return;

    const { offsetX: clickedX, offsetY: clickedY } = e.nativeEvent;
    if (clickedX === 360 && clickedY === 180) return 0;

    // NOTE : 그래프의 폭이 3px 이기 때문에, 클릭한 위치가 중심에서 얼마나 떨어져 있는지를 구해야 함.
    const radius = Math.sqrt(
      (clickedX - 180) ** 2 + (clickedY - 180) ** 2,
    );

    const distance = Math.sqrt(
      (clickedX - (radius + 180)) ** 2 + (clickedY - 180) ** 2,
    );

    // NOTE : arcsin 의 반환값은 radian 이기 때문에, 각도 표기법으로 반환하기 위해 (180 / π) 를 곱해줘야 한다.
    let theta =
      2 *
      (Math.asin(Math.min(distance / 2 / radius, 1)) *
        (180 / Math.PI));

    // NOTE : offsetY 좌표가 180 미만인 경우 중심각이 180도 이상이라는 의미이므로 추가 연산을 진행한다.
    theta = clickedY <= 180 ? 360 - theta : theta;

    const maxDuration = playerInstance.getDuration();
    const changedDuration = Math.round((theta / 360) * maxDuration);
    setCurrentDuration(changedDuration);
    playerInstance.seekTo(changedDuration, true);
  };

지금은 프로그레시브 바의 크기가 360 * 360 이기 때문에 고정된 값을 사용했지만, 만약 크기가 달라진다면 Ref 를 사용해서 동적으로 현재 SVG의 width 와 height 를 받아와 적용해야 한다. 추가로 원의 중심 또한 (180, 180) 이 아니게 되기에 이 또한 동적으로 적용해야 한다.

4. 결과

설계부터 구현까지 3시간 남짓한 시간이 걸렸는데, 생각보다 만족스러운 결과가 나와서 굉장히 기뻤다. 추후에 SVG를 활용한 다른 인터렉션을 구현할 일이 있다면 이번에 정리한 속성을 좀 사용해봐야겠다.

profile
항상 왜 이걸 써야하는지가 궁금한 사람

7개의 댓글

comment-user-thumbnail
2023년 7월 18일

ㄷㄷ 갓루키..

1개의 답글
comment-user-thumbnail
2023년 7월 23일

좋은 글 감사합니다!

답글 달기
comment-user-thumbnail
2023년 7월 25일

캬...

답글 달기
comment-user-thumbnail
2023년 7월 25일

오호... 좋은 글 잘 봤습니다.

답글 달기
comment-user-thumbnail
2023년 7월 25일

역시 개발을 하려면 수학이 필수군요ㅠ

답글 달기
comment-user-thumbnail
2023년 7월 25일

감사합니다. 어떻게 구현해야할지 고민이었는데 좋은 방법인 것 같네요!

답글 달기