리액트 Slider 직접 구현해보자! with twin.macro

Song-Minhyung·2023년 3월 16일
0

React

목록 보기
8/10

  • 최종적으로 위같은 슬라이더를 구현할것이다.

0. 서론

나는 프로젝트를 진행하면서 가능한 라이브러리를 사용하지 않고, 기능들을 직접 구현하는 것을 좋아한다. 이렇게 하면 스킬이 향상되는 것은 물론, 구현 과정이 재밌기 때문이다.
그래서 최근에 팀원들과 함께 프로젝트를 진행하면서 Slider를 직접 구현해보기로 결정했다.
이 과정에서 예상치 못한 이슈들이 많이 발생했지만, 결국에는 만들고 나서 잘 작동하는 것을 확인할 수 있어서 뿌듯하고 기분이 좋았다.

1. 사전지식

Slider를 구현하기 위해선 Element.getBoundingClientRect() 메서드를 알아야 한다.
해당 메서드는 현재 엘레먼트의 크기와 뷰포트에서의 상대적인 위치정보를 반환해주는 함수이다.
아래 그림을 보면 한번에 이해가 된다.

  • left: x 좌표 0부터 현재 엘레먼트 왼쪽의 거리를 나타낸다.
  • right: x 좌표 0부터 현재 엘레먼트 오른쪽의 거리를 나타낸다.
    • 예를들어 엘레먼트의 width가 200, left가 10이라면 right는 이 둘을 더한 210일 것이다.
  • top: y 좌표 0부터 현재 엘레먼트 상단의 거리를 나타낸다.
  • bottom: y 좌표 0부터 현재 엘레먼트 하단의 거리를 나타낸다.

여기서 원래 처음에는 left, right 값을 가지고 css의 left 속성을 변경하며 위치를 바꿔줬는데 그렇게 하면 최적화 문제가 생길 수 있기에 직접 left값을 바꾸는 대신 state에 그 값을 저장해 transform: translateX(px)로 위치를 수정해 주었다.

2. 카운터 구현

오른쪽 위에 숫자가 변경되는 부분부터 구현했다.
정말 매우 매우매우매우 간단하다.
그냥 얘는 props로 넘어오는 값을 표시해주기만 한다.
그리고 이미지가 2개 이상일때만 표시해주게 해놨다.

interface ImageCounterProps {
  now: number;
  max: number;
}

const ImageCounter = ({ now, max }: ImageCounterProps) => {
  return max > 1 ? (
    <div>
      {now} / {max}
    </div>
  ) : null;
};

3. Slider 구현

slider 구현에서 inner 부분을 구현하는데 조금 애먹었다.
width를 구해야 하는데 방법이 떠오르지 않았기 때문이다.
해결한 방법은 grid를 사용해서 img 개수만큼 100%를 곱해주었다.

3-1. useBoundingClientRect()

ref로 지정해준 엘레먼트의 rect를 반환해주는 커스텀 훅이다.
처음에는 Slider 파일 내부에 모두 적어놨는데 가독성이 점점 나빠져서 따로 분리시켰다.

import { useEffect, useRef, useState } from 'react';

const useBoundingClientRect = () => {
  const [rect, setRect] = useState<DOMRect | null>(null);
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const { current } = ref;
    if (current) {
      const rect = current.getBoundingClientRect();
      setRect(rect);
    }
  }, []);
  return { ref, rect };
};

export default useBoundingClientRect;

3-2. handleMouseDown 함수 구현

마우스를 눌렀을 때 마우스를 누르기 시작한 좌표를 설정하고, isClick을 true로 바꾸기만 하면 된다.

const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
  setState(prev => ({ ...prev, startX: e.clientX, isClick: true }));
};

3-3. handleMouseMove 함수 구현

이 함수는 left를 변경해준다.
실시간으로 바뀌는 clientX - startX + beforeLeft로 구하면 된다.
여기서 beforeLeft는 마우스를 놨을 때 변경된 x좌표이다.
왜냐하면 마우스를 떼었을 때 beforeLeft를 현재 변경된 x로 변경하기 때문이다.

const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
  if (!state.isClick) return;

  setState(prev => ({
    ...prev,
    left: e.clientX - prev.startX + prev.beforeLeft,
  }));
};

3-4. handleMouseUp 함수 구현

마지막 함수다.
마우스를 떼었을 때 왼쪽이나 오른쪽을 벗어났을 때 올바른 위치로 돌아오게 처리를 하거나
다음 혹은 이전 이미지로 이동되었을 때 props로 입력된 함수를 실행하는 함수다.

 const handleMouseUp = (e: React.MouseEvent<HTMLDivElement>) => {
   if (!state.isClick) return;

   setState(prev => ({ ...prev, isClick: false }));
   const moved = e.clientX - state.startX;

   // 실제로 드래그하고 움직인 거리가 0이라면 아무것도 하지 않음
   if (moved === 0) return;

   // moved가 음수라면 오른쪽으로 움직인 것이고, 양수라면 왼쪽으로 움직인 것
   const outerWidth = moved < 0 ? -outerRect?.width! : outerRect?.width!;
   let nextLeft = state.beforeLeft + outerWidth;

   // 아래 if문은 left가 끝까지 갔을 때 다시 원래대로 돌아오게 하는 코드
   // 왼쪽을 벗어나면
   if (nextLeft > 0) {
   	nextLeft = 0;
   }
   // 오른쪽을 벗어나면
   else if (-nextLeft >= innerRect?.width!) {
            nextLeft = state.beforeLeft;
            }

   // 실제로 left가 바뀌었다면 이벤트를 실행
   if (nextLeft !== state.beforeLeft) {
     if (moved < 0) onMovedRight?.();
     else onMovedLeft?.();
   }

   setState(prev => ({
     ...prev,
     left: nextLeft,
     beforeLeft: nextLeft,
   }));
 };

4. 전체코드

import { useState } from 'react';

import tw, { css } from 'twin.macro';

import useBoundingClientRect from '@src/hooks/useBoundingClientRect';

interface DraggableProps {
  itemNum: number;
  children: React.ReactNode;
  className?: string;
  onMovedLeft?: () => void;
  onMovedRight?: () => void;
}
interface State {
  left: number;
  startX: number;
  beforeLeft: number;
  isClick: boolean;
}

const Slider = ({
  itemNum,
  children,
  className,
  onMovedLeft,
  onMovedRight,
}: DraggableProps) => {
  const { ref: outerRef, rect: outerRect } = useBoundingClientRect();
  const { ref: innerRef, rect: innerRect } = useBoundingClientRect();

  const [state, setState] = useState<State>({
    left: 0,
    startX: 0,
    beforeLeft: 0,
    isClick: false,
  });

  const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
    setState(prev => ({ ...prev, startX: e.clientX, isClick: true }));
  };

  const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
    if (!state.isClick) return;

    setState(prev => ({
      ...prev,
      left: e.clientX - prev.startX + prev.beforeLeft,
    }));
  };

  const handleMouseUp = (e: React.MouseEvent<HTMLDivElement>) => {
    if (!state.isClick) return;

    setState(prev => ({ ...prev, isClick: false }));
    const moved = e.clientX - state.startX;

    // 실제로 드래그하고 움직인 거리가 0이라면 아무것도 하지 않음
    if (moved === 0) return;

    // moved가 음수라면 오른쪽으로 움직인 것이고, 양수라면 왼쪽으로 움직인 것
    const outerWidth = moved < 0 ? -outerRect?.width! : outerRect?.width!;
    let nextLeft = state.beforeLeft + outerWidth;

    // 아래 if문은 left가 끝까지 갔을 때 다시 원래대로 돌아오게 하는 코드
    // 왼쪽을 벗어나면
    if (nextLeft > 0) {
      nextLeft = 0;
    }
    // 오른쪽을 벗어나면
    else if (-nextLeft >= innerRect?.width!) {
      nextLeft = state.beforeLeft;
    }

    // 실제로 left가 바뀌었다면 이벤트를 실행
    if (nextLeft !== state.beforeLeft) {
      if (moved < 0) onMovedRight?.();
      else onMovedLeft?.();
    }

    setState(prev => ({
      ...prev,
      left: nextLeft,
      beforeLeft: nextLeft,
    }));
  };

  return (
    <div
      ref={outerRef}
      css={tw`relative w-h-full overflow-x-hidden`}
      onMouseDown={handleMouseDown}
      onMouseMove={handleMouseMove}
      onMouseUp={handleMouseUp}
      onMouseLeave={handleMouseUp}
      className={className}
    >
      <div
        ref={innerRef}
        css={[
          tw`absolute grid pointer-events-none`,
          !state.isClick && tw`transition-transform duration-500`,
          css`
            transform: translateX(${state.left}px);
            grid-template-columns: repeat(${itemNum}, 1fr);
            width: ${itemNum * 100}%;
          `,
        ]}
      >
        {children}
      </div>
    </div>
  );
};
export default Slider;

5. Slider 컴포넌트 사용하기

사용방법은 간단하다. 이미지를 가로로 넣어주고, 왼쪽 오른쪽으로 드래그 됐을때 실행할 함수를 넣어주면 된다.

import { useState } from 'react';

import tw from 'twin.macro';

import Row from '../common/FlexBox/Row';
import Slider from '../common/Slider';
import ImageCounter from './ImageCounter';

interface ImageViewerProps {
  images?: string[]; // 이미지 주소 url들
}

const ImageViewer = ({ images }: ImageViewerProps) => {
  const [imageIndex, setImageIndex] = useState({
    now: 1,
    max: images?.length ?? 0,
  });

  const handleSliderDraggingLeft = () => {
    setImageIndex(prev => ({ ...prev, now: prev.now - 1 }));
  };
  const handleSliderDraggingRight = () => {
    setImageIndex(prev => ({ ...prev, now: prev.now + 1 }));
  };

  return (
    <div css={tw`relative w-full pb-[100%] rounded-xl`}>
      <div css={tw`absolute w-full h-full rounded-xl`}>
        {images?.length && images.length > 0 && (
          <>
            <Slider
              itemNum={images?.length ?? 0}
              onMovedLeft={handleSliderDraggingLeft}
              onMovedRight={handleSliderDraggingRight}
            >
              <Row>
                {images?.map(image => (
                  <img
                    key={image}
                    src={image}
                    css={tw`rounded-xl`}
                  />
                ))}
              </Row>
            </Slider>
            <ImageCounter
              now={imageIndex.now}
              max={imageIndex.max}
            />
          </>
        )}
      </div>
    </div>
  );
};

export default ImageViewer;
profile
기록하는 블로그

0개의 댓글