[React] 좌우 스크롤 특정 위치로 옮기기

intersoom·2023년 8월 2일
3

리액트

목록 보기
4/6
post-thumbnail

🔥 카드 컴포넌트 수정 계기

원래는 다음과 같은 디자인이여서 이 상태로만 만들어뒀었는데..

만들면서 생각해보니까 해당 디자인이면 보여지는 스택에 제한을 둔다던지 해야할 것 같았다. (만약 그렇다해도 길이가 넘치면 어떻게 해야할지..?)

디자이너님께 그래서 해당 사항에 대해서 문의를 드렸다.

논의 끝에 그라데이션 + 버튼을 넣고 스크롤이 가능하게끔 하였다.
위에 제시한 레퍼런스와 유사한 방식으로!
그래서 도착한 수정된 피그마는 다음과 같다

✅ 요구 사항

내가 만족시켜야할 사항들은 다음과 같았다:

먼저,
✅ overflow인지 아닌지 체크
overflow라면,
✅ 그라데이션 + 버튼 추가
✅ default로 오른쪽에 버튼 추가 / 스크롤이 절반 넘었으면, 왼쪽에 버튼 추가
✅ 좌우 스크롤 가능
✅ 버튼을 누르면 양끝으로 이동

위와 같은 사항들을 만족시키기 위해서 어떻게 했는지 정리해보겠다!

🔭 개발기

overflow 여부 확인

overflow인지 아닌지 확인하기 위해서는 스크롤이 가능한 영역의 width와 실제 width를 비교해보면 된다.

이를 위해서 확인해봐야하는 프로퍼티는 다음과 같다:

  • scrollWidth
  • clientWidth (or offsetWidth)

clientWidth / offsetWidth 차이점

  • clientWidth: 요소의 가로 값을 마진/보더 불포함으로 가져옵니다.
  • offsetWidth: 요소의 가로 값을 보더/패딩 포함으로 가져옵니다.

공식 문서에는 offsetWidth를 사용해서 작성되어있으니 참고해서 사용하면 될 것 같다.

그래서 이 둘을 비교하기만 하면 끝이다!
매우 간단해서 리액트에서는 그냥 다음과 같이 코드를 작성하여 커스텀 훅을 만들어서 필요한 곳에서 사용해주면 된다.

useLayoutEffect(() => {
    const { current } = ref;
    if (current) {
      const hasOverflow = current.scrollWidth > current.clientWidth;

      setIsOverflow(hasOverflow);
    }
  }, [ref]);

useLayoutEffect 사용 이유

  • useLayoutEffect는 동기적으로 실행됨
  • useLayoutEffect는 해당 콜백이 모두 실행된 후에 화면이 paint됨

따라서 콜백 함수 내에 DOM에 영향을 주는 내용이 포함되어있어도 repaint할 필요가 없기 때문에 화면이 깜빡이지 않는다.
(반면, useEffect는 동기적으로 + paint된 후에 실행되므로 화면 DOM에 영향을 주는 내용이 있으면 화면 깜빡임 있음)

위와 같은 특징들 때문에 직접적으로 DOM 자체를 조작할 때, 최적화가 되어있는 Hook이기 때문에 사용한다.

다만, 현재 나는 Next 프로젝트를 진행하고 있기 때문에, 이의 사용이 제한되어있다.
useLayoutEffect는 CSR에는 권장되지만, SSR에는 권장되지 않기 때문이다. 그래서 Next.js에서 그냥 이를 사용하면 다음과 같은 에러 문구를 마주한다.

Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer’s output format. This will lead to a mismatch between the initial, non-hydrated UI and the intended UI. To avoid this, useLayoutEffect should only be used in components that render exclusively on the client

이를 방지해주기 위해서 나는 새로운 커스텀 훅을 추가적으로 만들었다.

import { useEffect, useLayoutEffect } from 'react';

const useIsomorphicEffect = () => {
  return typeof window !== 'undefined' ? useLayoutEffect : useEffect;
};

export default useIsomorphicEffect;

렌더링이 되어서 window 객체가 생겼는지 여부를 확인하고 이에 따라서 useEffect를 사용할지 useLayoutEffect를 사용할지 결정해주면 된다.

해당 hook은 다음과 같이 사용하면 된다.

isomorphicEffect(() => {
    const { current } = ref;
    if (current) {
      const hasOverflow = current.scrollWidth > current.clientWidth;

      setIsOverflow(hasOverflow);
    }
  }, [ref]);

Next.js에서는 위에 설명한 리액트에서 사용할 수 있는 코드에서 useLayoutEffect 부분만 isomorphicEffect으로 대체해주면 된다.

 const isomorphicEffect = useIsomorphicEffect();

  isomorphicEffect(() => {
    const { current } = ref;
    if (current) {
      const hasOverflow = current.scrollWidth > current.clientWidth;

      setIsOverflow(hasOverflow);
    }
  }, [ref]);

추가적으로 styled-component를 사용하면, div 자체가 더 이상 div가 아니라 리액트의 컴포넌트로서 인식된다.
그렇기 때문에, 평소에 div 태그에 ref를 사용하는 것처럼 아무 처리 없이 사용할 수 없다.

⭕️ 사용 가능 ⭕️

<div ref={ref} />

❌ 아무 처리 없이 사용할 수 없음 ❌

<StackTagsWrap ref={ref} />

문제를 해결하기 위해서는 forwardRef라는 것을 사용해주어야한다.

const StackTagsWrap = forwardRef(
  ({ children }: { children: ReactNode }, ref: ForwardedRef<HTMLDivElement>) => {
    return <StyledStackTagsWrap ref={ref}>{children}</StyledStackTagsWrap>;
  },
);

요렇게 사용해주면 된다. 자세한 내용은 참고한 링크 첨부하고 생략하겠다.

이렇게 overflow 여부는 확인할 수 있게 되었다.

그라데이션 + 버튼 추가 + 좌우 스크롤 가능

원래는 빨간색 영역만 존재했는데 추가적으로 파란색 영역을 양쪽으로 넣기 위해서 마젠타 영역을 추가해주었다.

마젠타 영역을

  • width을 카드 컴포넌트 전체의 width 값과 같게 함으로서 양쪽에 파란색 영역을 넣을 수 있게 해주었다.
const StackTagsArea = styled.div`
  position: relative;
  width: 280px;
  margin-left: -32px;
`;
  • 상단 영역의 flex 속성에 대응하기 위해서 margin을 이용해줬다

빨간색 영역에다가

  • overflow: scroll 속성을 추가해주어서 해당 영역 내에서 스크롤이 가능하게끔 해주었다.
  • webkit-scrollbar 속성을 display: none으로 해주어서 스크롤바를 숨겨주었다.
  • 마찬가지로 상단 영역의 flex 속성에 대응하기 위해서 margin을 이용해줬다.
const StyledStackTagsWrap = styled.div`
  display: flex;
  width: 216px;
  margin-left: 32px;
  flex-direction: row;
  overflow-x: scroll;
  scroll-behavior: smooth;
  -webkit-scrollbar: no-button;
  &::-webkit-scrollbar {
    display: none;
  }
`;

파란색 영역을

  • 원하는 위치에 두기 위해서 absolute를 사용해주었다.
  • 그라데이션은 svg를 이용해줬다.
const OverflowBoxRight = styled.div`
  position: absolute;
  top: 0;
  right: 0;
  display: flex;
  justify-content: center;
  align-items: center;
  width: 68px;
  height: 24px;
  background-image: url('gradientRight.svg');

이제 overflow인 경우에 해당 OverflowBoxRight를 보여주게하면 반 정도는 완성!

{useIsOverflow({ ref }) && (
	<OverflowBoxRight>
    	<ButtonRight/>	
    </OverflowBoxRight>
)}

오른쪽 / 왼쪽 조건부 처리

const [scrollState, setScrollState] = useState<'left' | 'right'>('right');

버튼이 왼쪽에 있어야하는지, 오른쪽에 있어야하는지는 useState를 통해서 관리했다.

그래서 위에 오버플로우만 체크하던 식을 두개로 늘려서 다음과 같이 작성해주었다.

{useIsOverflow({ ref }) && scrollState === 'right' && (
	<OverflowBoxRight>
		<ButtonRight onClick={() => moveRight(ref)} />
	</OverflowBoxRight>
)}
{useIsOverflow({ ref }) && scrollState === 'left' && (
	<OverflowBoxLeft>
		<ButtonLeft onClick={() => moveLeft(ref)} />
	</OverflowBoxLeft>
)}

그렇다면 왼쪽인지 오른쪽인지 어떻게 인식하느냐가 문제였다.
나는 eventListener를 사용해주었다.

const handleScroll = (moveRef: MutableRefObject<HTMLDivElement | null>) => {
    const { current } = moveRef;

    if (current) {
      if (current.scrollLeft <= (current.scrollWidth - current.clientWidth) / 2) {
        setScrollState('right');
      } else {
        setScrollState('left');
      }
    }
};

useEffect(() => {
    const { current } = ref;

    if (current) {
      current.addEventListener('scroll', () => handleScroll(ref));
    }
    return () => {
      if (current) {
        window.removeEventListener('scroll', () => handleScroll(ref));
      } // clean up
    };
}, []);

태그 컴포넌트들을 감싸고 있는 컴포넌트에서 마우스 이벤트가 발생했을 때, handleScroll이라는 함수를 실행시켜서 스크롤 위치가 절반을 넘었는지 안 넘었는지 확인할 수 있게 해주었다.

current.scrollLeft <= (current.scrollWidth - current.clientWidth) / 2

스크롤 가능 영역의 width에서 overflow가 되는 기준 width(감싸는 컴포넌트의 width)를 빼주면 overflow된 영역의 width가 나오고 이는 scroll이 얼마까지 움직일 수 있는지를 알려준다.

scrollLeft는 왼쪽을 기준으로 얼마만큼 scroll되었는지를 알려주는 속성이기 때문에 위에서 구한 값을 나누기 2 하면 스크롤 영역의 절반을 기준 잡을 수 있게 된다.

따라서 이를 기준으로 왼쪽 오른쪽을 판단하여서 오른쪽에 그라디언트 + 버튼이 나타날지, 왼쪽에 나타날지 state 값을 통해서 알려주었다.

버튼을 누르면 양끝 이동

  // onClick
  const moveRight = (moveRef: MutableRefObject<HTMLDivElement | null>) => {
    const { current } = moveRef;

    if (current) {
      current.scrollLeft = current.scrollWidth - current.clientWidth;
    }
  };

  const moveLeft = (moveRef: MutableRefObject<HTMLDivElement | null>) => {
    const { current } = moveRef;

    if (current) {
      current.scrollLeft = 0;
    }
  };

이거는 간단하다.
왼쪽 오른쪽 조건에 따라서 scrollLeft 값에 0을 넣든지 최대값인 current.scrollWidth - current.clientWidth 넣든지 하면 된다!

(사실 최대값을 계산하지 않고 그 이상의 값만 넣어도 끝으로는 이동한다)

이렇게 모든 요구사항을 충족시켜주면 완성~!

🎉 결과

3개의 댓글

comment-user-thumbnail
2023년 8월 2일

감사합니다. 이런 정보를 나눠주셔서 좋아요.

답글 달기
comment-user-thumbnail
2023년 11월 3일

감사합니다 참고가 많이 됐습니다.

1개의 답글