1. 크기 조절 가능한 창 (ResizableComponent) 구현


Trablock의 일정 상세 페이지에는 크기를 조절할 수 있는 창이 존재합니다. 초기 구현한 컴포넌트는 마우스 드래그 동작을 통해 크기를 조절할 수 있도록 설계되었습니다. 외부에서 최소 크기와 최대 크기를 받아 해당 범위 내에서 컴포넌트의 크기를 조절할 수 있고, initialSize, minSize, maxSize는 유틸 함수 calculateSize() 함수를 통해 변환되어 px, vw, vh, %, rem 단위를 지원합니다. 또한 페이지 레이아웃에 따라 적절히 조절 가능하도록 가로 및 세로 형태를 지원합니다.

interface ResizableComponentProps {
  className?: string;
  isHorizontal: boolean;
  initialSize: string;
  minSize: string;
  maxSize: string;
  children: ReactNode;
}

export default function ResizableComponent({
  className,
  isHorizontal,
  initialSize,
  minSize,
  maxSize,
  children,
}: ResizableComponentProps) {
  const [size, setSize] = useState(0);
  const [startSize, setStartSize] = useState(0);
  const [startPosition, setStartPosition] = useState(0);
  const [minSizePx, setMinSizePx] = useState(0);
  const [maxSizePx, setMaxSizePx] = useState(0);
  const [isDragging, setIsDragging] = useState(false);
  const containerRef = useRef<HTMLDivElement>(null);
  const resizerRef = useRef<HTMLDivElement>(null);

  // 컨테이너 크기에 따른 초기 사이즈 설정
  const updateSizes = useCallback(() => {
    if (!containerRef.current) return;

    const containerSize = isHorizontal ? containerRef.current.clientWidth : containerRef.current.clientHeight;
    const newMinSizePx = calculateSize(minSize, isHorizontal);
    const newMaxSizePx = calculateSize(maxSize, isHorizontal);

    setMinSizePx(newMinSizePx);
    setMaxSizePx(newMaxSizePx);

    // 초기 사이즈 설정 및 비율 저장
    if (ratio === null) {
      const initialSizePx = calculateSize(initialSize, isHorizontal);
      setSize(initialSizePx);
      const initialRatio = initialSizePx / containerSize;
      setRatio(initialRatio);
      return;
    }

    // 현재 크기가 새로운 min/max 범위를 벗어나는 경우 조정
    let newSize = containerSize * ratio;
    if (newSize < newMinSizePx) {
      newSize = newMinSizePx;
      setRatio(newMinSizePx / containerSize);
    } else if (newSize > newMaxSizePx) {
      newSize = newMaxSizePx;
      setRatio(newMaxSizePx / containerSize);
    }

    setSize(newSize);
  }, [ratio, isHorizontal, initialSize, minSize, maxSize]);
  
  const handleDragMove = useCallback(
    (e: MouseEvent) => {
      if (!resizerRef.current || !containerRef.current) return;

      const containerSize = isHorizontal ? containerRef.current.clientWidth : containerRef.current.clientHeight;

      let newSize;
      if (isHorizontal) {
        newSize = startSize + (e.clientX - startPosition);
      } else {
        newSize = startSize - (e.clientY - startPosition);
      }

      const minSizePx = calculateSize(minSize, isHorizontal);
      const maxSizePx = calculateSize(maxSize, isHorizontal);

      if (newSize < minSizePx || newSize > maxSizePx) return;

      setSize(newSize);
    },
    [isHorizontal, minSize, maxSize, startSize, startPosition]
  );

  const handleDragEnd = () => {
    setIsDragging(false);
  };

  useEffect(() => {
    const handleResize = () => {
      updateSizes();
    };

    handleResize();

    window.addEventListener('resize', handleResize);
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, [updateSizes]);

  useEffect(() => {
    if (!isDragging) return;

    document.addEventListener('mousemove', handleDragMove);
    document.addEventListener('mouseup', handleDragEnd);

    return () => {
      document.removeEventListener('mousemove', handleDragMove);
      document.removeEventListener('mouseup', handleDragEnd);
    };
  }, [isDragging, handleDragMove, handleDragEnd]);

  return (
    <div ref={containerRef} className={`relative flex grow ${className}`}>
      <div
        className={`absolute bottom-0 left-0 flex bg-white-01 shadow-modal ${
          !isDragging && 'transition-[width,height]'
        } ${isHorizontal ? `top-0 pr-5` : `right-0 rounded-t-2xl pt-7`}`}
        style={{
          width: isHorizontal ? `${size}px` : 'auto',
          height: isHorizontal ? 'auto' : `${size}px`,
        }}
      >
        <div className="scrollbar flex w-full flex-col overflow-auto">{children}</div>
        <div
          className={`absolute top-0 !cursor-grab ${
            isHorizontal
              ? 'flex-row-center right-0 h-full w-5 cursor-ew-resize'
              : 'flex-col-center top-0 h-5 w-full cursor-ns-resize pt-2'
          } ${isDragging && '!cursor-grabbing'}`}
          onMouseDown={handleDragStart}
          ref={resizerRef}
        />
      </div>
    </div>
  );
}

이후 모바일 지원을 위해 동작을 테스트하면서 터치 동작으로 작동하도록 기능을 추가했습니다.

export default function ResizableComponent({
  className,
  isHorizontal,
  initialSize,
  minSize,
  maxSize,
  children,
}: ResizableComponentProps) {
  // ...

  const handleDragMouseDownStart: MouseEventHandler<HTMLButtonElement> = (e) => {
    e.preventDefault();
    handleDragStart(e.clientX, e.clientY);
  };

  const handleDragTouchStart: TouchEventHandler<HTMLButtonElement> = (e) => {
    const touch = e.touches[0];
    handleDragStart(touch.clientX, touch.clientY);
  };
  
  const handleDragMouseMove = (e: MouseEvent) => {
    e.preventDefault();
    handleDragMove(e.clientX, e.clientY);
  };

  const handleDragTouchMove = (e: TouchEvent) => {
    const touch = e.touches[0];
    handleDragMove(touch.clientX, touch.clientY);
  };
  
  const handleDragMouseEnd = () => {
    setIsDragging(false);
  };

  const handleDragTouchEnd = () => {
    setIsDragging(false);
  };

  useEffect(() => {
    if (!isDragging) return;

    document.addEventListener('mousemove', handleDragMouseMove);
    document.addEventListener('touchmove', handleDragTouchMove);
    document.addEventListener('mouseup', handleDragMouseEnd);
    document.addEventListener('touchend', handleDragTouchEnd);

    return () => {
      document.removeEventListener('mousemove', handleDragMouseMove);
      document.removeEventListener('touchmove', handleDragTouchMove);
      document.removeEventListener('mouseup', handleDragMouseEnd);
      document.removeEventListener('touchend', handleDragTouchEnd);
    };
  }, [isDragging, handleDragMouseMove, handleDragTouchMove, handleDragMouseEnd, handleDragTouchEnd]);

  return (
    <div ref={containerRef} className={`relative flex grow ${className}`}>
      {/* ... */}
      <div
        className={`absolute top-0 !cursor-grab ${
          isHorizontal
            ? 'flex-row-center right-0 h-full w-5 cursor-ew-resize'
            : 'flex-col-center top-0 h-5 w-full cursor-ns-resize pt-2'
        } ${isDragging && '!cursor-grabbing'}`}
        onMouseDown={handleDragMouseDownStart}
        onTouchStart={handleDragTouchStart}
        ref={resizerRef}
      />
    </div>
  );
}

2. 모바일 브라우저에서 드래그 동작 문제


터치 동작 기능을 추가한 뒤, 모바일 브라우저에서 기능을 테스트하면서 문제가 발생했습니다.

첫번째는 모바일 브라우저의 기본 동작인 새로고침과 제스처가 겹치는 문제였습니다. 창을 아래로 드래그해 크기를 축소하는 동작이 있는데, 브라우저의 새로고침 동작도 아래로 드래그하는 것이라 크기를 조절하는 동작에 제한이 생겼습니다.

두번째는 모바일 브라우저의 상단바, 하단바와 관련된 문제였습니다. 모바일 브라우저는 앱에 따라 상단바와 하단바가 존재하는데, 브라우저에서 위아래로 드래그하면 상황에 맞춰 바가 사라졌다가 나타나는 동작이 발생합니다. 작은 디스플레이에서 좀 더 넓은 뷰포트를 제공하기 위한 이 기능이, 뷰포트의 크기를 실시간으로 변경하면서 창 크기를 변경하는 계산식에 영향을 끼쳐 동작에 버벅임이 발생했습니다.

UI/UX 관점에서 비정상적으로 동작하는 기능을 사용자에게 제공하는 것은 부적절하다고 판단했습니다. 따라서 모바일 브라우저에서 위아래 드래그 동작을 제거하고, 클릭으로 창을 최대, 최소 크기로 토글할 수 있도록 기능을 추가했습니다.

export default function ResizableComponent({
  className,
  isHorizontal,
  initialSize,
  minSize,
  maxSize,
  children,
}: ResizableComponentProps) {
  const [ratio, setRatio] = useState<number | null>(null);
  const [dragStartTime, setDragStartTime] = useState(0);

  // ...

  const handleDragStart = (clientX: number, clientY: number) => {
    setIsDragging(true);
    setDragStartTime(Date.now());
    setStartSize(size);
    if (isHorizontal) return setStartPosition(clientX);
    setStartPosition(clientY);
  };

  const handleClickToggleMinMax = () => {
    if (!containerRef.current) return;

    const containerSize = isHorizontal ? containerRef.current.clientWidth : containerRef.current.clientHeight;
    const currentRatio = size / containerSize;
    const newSize = currentRatio < 0.55 ? maxSizePx : minSizePx;

    setSize(newSize);
    setRatio(newSize / containerSize);
  };

  const handleDragMouseEnd = () => {
    setIsDragging(false);
    const dragDuration = Date.now() - dragStartTime;
    if (dragDuration < 100) handleClickToggleMinMax();
  };

  // ...
}