Gila - 좌우 스크롤 버튼 구현 , throttle 구현

박상준·2024년 8월 27일

Gila

목록 보기
17/25
post-thumbnail

좌우 스크롤? 그거 왜 필요한데


내가 지금까지 만들었던 슬라이드의 모습이다. 좌우 스크롤이 되도록 설계했다. 우리가 모바일을 우선으로 만들었기 때문에 이는 문제가 없을 수도 있다. 하지만 누군가는 데스크탑으로 접속할텐데 터치패드가 없다면 이걸 스크롤할 수가 없다. 그래서 스크롤 버튼을 만드는게 맞다고 생각했다.

물론 스크롤바를 그냥 두면 좋지만...안이쁘다...

이번에도 디자인 레퍼런스는 애플이다.

여러개의 슬라이드 카드가 있고 좌우로 클릭이 가능한 버튼들이 있다. 양끝에 닿으면 방향에 해당하는 버튼은 사라진다.

동작 방식 구상

물론 슬라이드는 이미 잘 나온 라이브러리가 많다. 하지만 이젠 프로젝트에 여러 라이브러리가 추가된 시점에서 더 많은 의존성을 부여하는 것은 부적절하다고 생각했다. 그리고 복잡한 로직이 아니고 단순한 이벤트 핸들링이기 때문에 직접 구현했다.

예전에 막 코딩을 배울때 슬라이드를 라이브러리를 사용하지 않고 css로만 구현하는 방법을 찾아서 해봤었다. 그래서 크게 어렵지 않게 어떤 방식으로 슬라이드가 동작되는지 알고 있다.


간단하게 그림으로 만들어 보자면 화면에 보이는건 한두개의 슬라이드 카드이지만 실제로는 가로로 길게 나열된 리스트를 만들것이다. 그래서 버튼을 누르면 내가 지정한 거리만큼 이동하는 것이다.

슬라이드구현

  const [slideScrollDistance, setSlideScrollDistance] = useState(0);
  const [isPrevDisabled, setIsPrevDisabled] = useState(true);
  const [isNextDisabled, setIsNextDisabled] = useState(false);
  const slideRef = useRef<HTMLDivElement>(null);

총 4개의 값들을 맨먼저 설정해줬다. slideScrollDistance은 내가 스크롤할 거리이다. isPrevDisabledisNextDisabled은 슬라이드가 양끝에 닫았때 여부이다. slideRef은 ref를 통해 DOM에 직접 접근하기 위함이다.

슬라이드 거리

  useEffect(() => {
    if (slideRef.current) {
      if (slideRef.current.firstElementChild) {
        setSlideScrollDistance(
          slideRef.current.firstElementChild.scrollWidth / recommendList.length,
        );
      }
    }
  }, [recommendList]);

복잡하게 구현하지 않고 간단하게 계산하도록 만들어줬다. 우선 ref를 통해 firstChild를 가져온다. 내 코드에서 firstChild는 슬라이드 카드를 나열한 ul태그이다.

<div className="overflow-x-scroll [&::-webkit-scrollbar]:hidden h-full" ref={slideRef}>
  <ul className="flex gap-4 w-fit">
    {recommendList.map((item, index) => (
    ...

그리고 여기에서 width를 가져왔다. 간단하게 말하면 슬라이드의 전체 길이를 가져온 것이다. 그리고 이 길이를 추천 리스트의 활동 갯수로 나눠준다. 만약 1200px의 슬라이드에 6개의 활동이 있다면 200px이 슬라이드 버튼을 눌렀을때 이동거리가 된다.

슬라이드 버튼 여부 설정

대부분의 서비스에서는 리스트의 끝에 닿으면 버튼이 사라져서 불필요한 이벤트가 발생하지 않도록 설정하고 있다. 나도 이와 같은 기능을 설정해주겠다.

  const settingSlide = useCallback(() => {
    if (slideRef.current) {
      const { scrollLeft, scrollWidth, clientWidth } = slideRef.current;
      setIsPrevDisabled(scrollLeft === 0);
      setIsNextDisabled(scrollLeft + clientWidth >= scrollWidth);
    }
  }, []);

우선 ref에서 3가지 값을 가져온다. scrollLeft은 가로 스크롤로 인해 가려진 영역의 왼쪽기준으로의 길이이다. scrollWidth은 스크롤로 가려진 가로 전체 길이다. clientWidth은 가려지지 않은 화면에 보이는 영역의 길이다.

이제 위의 코드를 해석하자면 왼쪽의 가려진 영역이 없다면 isPrevDisabled가 true가 되어서 버튼이 가려진다. 그리고 가려진 영역의 전체 길이가 왼쪽의 가려진 길이와 현재 화면에 보이는 길이가 같은 경우에는 다음으로 넘어가는 버튼이 사라진다.

예시
전체 길이 : 1200px
가려진 길이 200px + 화면 200px => false
가려진 길이 1000px + 화면 200px => true

이렇게 설정하는 함수를

  useEffect(() => {
    const slide = slideRef.current;
    if (slide) {
      slide.addEventListener('scroll', settingSlide);
    }
    return () => {
      if (slide) {
        slide.removeEventListener('scroll', settingSlide);
      }
    };
  }, [settingSlide]);

useEffect를 통해 addEventListener로 이벤트를 넣어주면 된다. 이제 슬라이드에서 scroll 이벤트가 발생할때마다 해당 함수를 실행해 버튼 여부를 설정할 것이다.

스크롤 이벤트 함수 설정

이제 버튼을 눌렀을때 동작할 함수를 만들어준다.

  const nextSlide = () => {
    if (slideRef.current) {
      slideRef.current.scrollBy({ left: slideScrollDistance, behavior: 'smooth' });
    }
  };

  const prevSlide = () => {
    if (slideRef.current) {
      slideRef.current.scrollBy({ left: -slideScrollDistance, behavior: 'smooth' });
    }
  };

이미 위에서 해당 값들을 전부 만들어놨기 때문에 간단하게 함수 구현이 가능하다.


버튼이 잘동작하고

양끝에서는 버튼이 사라진다!

스크롤 기능 보완

지금 슬라이드의 문제점은 버튼을 누를때마다 스크롤 이벤트가 발생한다는 점이다. 버튼을 눌러서 이벤트가 동작중에 다시 버튼을 누르면 버튼을 누른 위치에서 다시 스크롤 이벤트가 동작하는 것이다. 우리는 연속적인 이벤트를 막아줄 것이다.

throttle

전에 taskuit을 개발할때 만들었던 기능이 있다. 그 프로젝트에도 마우스 이벤트가 있어서 한번 도입을 해봤는데

const throttle = (func: () => void) => {
  let timer
  if (!timer) {
    timer = setTimeout(() => {
      timer = null
      func()
    }, 1000)
  }
}

엄청 간단한 함수이다. 이때는 setTimeout을 통해 일정 시간 이후에 동작하도록 설계를 해놨었다. 물론 throttle의 목적에는 알맞는 함수이다. 하지만 연속적으로 해당 함수를 호출했을때 setTimeout에 의해 시간이 세팅이 된다. 그러면 효율이 떨어질수 밖에 없다.

그래서 새로운 throttle함수를 만들었다.

const useThrottle = ({ callback, limit }: Props) => {
  const lastRun = useRef(Date.now());

  return () => {
    if (Date.now() - lastRun.current >= limit) {
      callback();
      lastRun.current = Date.now();
    }
  };
};

이번에는 시간 세팅을 하지 않고 useRef를 통해 현재 시간 상태를 저장하고 limit으로 지정한 시간이 지나야 callback을 실행하도록 만들었다. 새로운 값을 만들고 비교하는 것이 아니라 상태관리를 통해 이뤄지는 이벤트 관리다보니 효율이 더 좋아질 수 밖에 없다.

  const throttleClickNext = useThrottle({ callback: nextSlide, limit: 300 });

  const throttleClickPrev = useThrottle({ callback: prevSlide, limit: 300 });

그래서 위에서 만든 함수들을 훅에 넣어줘 이벤트 컨트롤을 해준다. limit값이 너무 크면 사용자 입장에서 너무 답답할 수 있으니 스크롤 이벤트가 이뤄지는 대략적인 시간을 넣어줬다.

동작 영상에서는 크게 티가 안나는데 마우스를 엄청 클릭하고 있다. 하지만 이벤트는 스크롤되는 한번씩만 동작한다!

아쉬운점

throttle로 연속 이벤트를 방지한 것은 좋았지만 슬라이드 버튼 영역과 뒤에 카드 영역이 겹쳐서 의도치않은 동작이 발생할 가능성이 있다. 해당 부분의 기획과 디자인을 더 확실하게 했다면 양쪽 간격이나 카드 크기를 조절했을 것 같다. 이 문제는 추후에 돌파할 방법이 있다면 그렇게 해결해보겠다.

마무리

사소한 기능들을 추가하면서 드는 생각은 내가 아무렇지 않게 사용하던 것들이 여러 사람의 많은 고민이 낳은 것이라는 생각이다. 당연하게 이걸 누르면 이 동작을 하고 빠르게 어떤게 나와야하는 것들 말이다. 익숙함에 속아 소중함을 잊지말자는 생각이 드는 요즘이다.

이제 우리 서비스의 최종 조각급인 채팅 기능이다. 2주라는 기간동안 만들기에는 부족했고 솔직히 만들수 있을까 싶었던 기능이라 기획부터 크게 생각하지 않았던 방식이다. 하지만 현재 우리 서비스에서 사용자간 소통이 불가능한 상황이라 소통수단이 추가되어야 서비스의 이유가 생긴다. 그래서 채팅을 구현하기로 했다. 물론 카카오톡이나 인스타그램 다이렉트 메세지 정도를 바라지는 않는다. 하지만 양방향 소통이 가능하도록 할 것이다. 다음 포스트은 채팅이다!

profile
개인 블로그 플렛폼도 운영중입니다(https://blog-park.vercel.app/)

0개의 댓글