[react] 디바운스를 이용해 스낵바 구현하기 (Next.js + Typescript)

1. 구현할 목표

쿠팡이츠의 스낵바는 스크롤을 내리면 사라지고, 스크롤을 중단하면 다시 나타난다.

이와 똑같이 동작하는 스낵바를 만들어볼 것이다.
스크롤이 바뀌는 순간 스낵바가 사라지고, 스크롤이 500ms간 멈춤이 지속되면 스낵바를 띄우는 형태로 디바운스를 사용해야 한다.

2. 스낵바 컴포넌트 생성 & 스타일링

function SnackBar() {
  return (
    <SnackBarRoot className="body2">
      <CloseButton src="/close_black.svg" alt="close" />
      <LeadText>이것도 이용해보세요 :)</LeadText>
    </SnackBarRoot>
  );
}

const SnackBarRoot = styled.div`
  position: fixed;
  left: 0;
  right: 0;
  bottom: 20px;
  height: 48px;
  width: calc(100% - 20px);
  max-width: calc(${GLOBAL_MAX_WIDTH}px - 20px);
  margin: 0 auto;
  border: 1px solid black;
  border-radius: 5px;
  background: white;
  box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.2);
  cursor: pointer;
`;

const CloseButton = styled(Icon)`
  position: absolute;
  right: 0;
  padding: 13px 12px;
`;

const LeadText = styled.div`
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
`;

export default SnackBar;

디자인은 자유롭게 해주면 된다.

스타일링의 결과!

3. 나타나고, 사라지는 애니메이션

처음에는 언마운트될 때 애니메이션을 생각했으나, 디바운스를 사용하는 와중 언마운트까지 setTimeout 을 이용해 시간을 조정하니 코드가 복잡해졌다. 따라서 언마운트를 시키지 않고 opacity: 0transform: translateY(50px) 로 그냥 화면에서 없애는 방식을 택했다.

function SnackBar() {
  const [isStop, setIsStop] = useState<boolean>(true);

  return (
    <SnackBarRoot className={`body2 ${isStop ? "appear" : "disappear"}`}>
      <CloseButton src="/close_black.svg" alt="close" />
      <LeadText>이것도 이용해보세요 :)</LeadText>
    </SnackBarRoot>
  );
}

const SnackBarRoot = styled.div`
  ...

  transition: 500ms;
  &.disappear {
    opacity: 0;
    transform: translateY(50px);
  }
  &.appear {
    opacity: 1;
    transform: translateY(0);
  }
`;

export default SnackBar;

스크롤의 멈춤을 나타내는 state인 isStop 을 생성하고, isStop 에 따라 스낵바의 classNamedisappear 또는 appear 로 변경한다.
transition 을 설정해주면 disappearappear 사이를 부드럽게 전환한다.

4. 스크롤 이벤트리스너 등록

useEffect(() => {
  window.addEventListener("scroll", handleScroll, { passive: true });
  return () => {
    window.removeEventListener("scroll", handleScroll);
  };
}, []);

handleScroll 함수와 useEffect 를 이용해 스크롤에 대한 이벤트리스너를 등록한다.
스크롤의 위치가 변경될 때마다 해당 Y 좌표가 scrollPosition state 에 반영된다.
{ passive:true } 를 설정해주어야 성능이 향상된다고 하는데, 자세한 이유는 추후에 공부해봐야겠다.

5. 디바운스

디바운스는 "연이어 호출되는 함수들 중 마지막 함수(또는 제일 처음)만 호출하도록 하는 것"이다.
먼저 scrollPosition 을 구독하며 변경이 있을 때마다 500ms 뒤에 isStoptrue 로 변경해야 한다.
그러나 500ms가 지나기 전 새로 변경이 일어나면 이전 timer를 제거해주어야 한다.

useEffect(() => {
  const timerId = setTimeout(() => {
    setIsStop(true);
  }, 500);
  
   return () => {
    setIsStop(false);
    clearTimeout(timerId);
  };
}, [scrollPosition]);

useEffect의 클린업 함수를 이용하면 간단하게 구현할 수 있다.

최종 코드

import { GLOBAL_MAX_WIDTH } from "@/constants/components";
import styled from "@emotion/styled";
import { useEffect, useState } from "react";
import Icon from "../Icons/Icon";

function SnackBar() {
  const [isStop, setIsStop] = useState<boolean>(true);
  const [scrollPosition, setScrollPosition] = useState<number>(0);

  const handleScroll = () => {
    setScrollPosition(window.pageYOffset);
  };

  useEffect(() => {
    window.addEventListener("scroll", handleScroll, { passive: true });
    return () => {
      window.removeEventListener("scroll", handleScroll);
    };
  }, []);

  useEffect(() => {
    const timerId = setTimeout(() => {
      setIsStop(true);
    }, 500);

    return () => {
      setIsStop(false);
      clearTimeout(timerId);
    };
  }, [scrollPosition]);

  return (
    <SnackBarRoot className={`body2 ${isStop ? "appear" : "disappear"}`}>
      <CloseButton src="/close_black.svg" alt="close" />
      <LeadText>이것도 이용해보세요 :)</LeadText>
    </SnackBarRoot>
  );
}

const SnackBarRoot = styled.div`
  position: fixed;
  left: 0;
  right: 0;
  bottom: 20px;
  height: 48px;
  width: calc(100% - 20px);
  max-width: calc(${GLOBAL_MAX_WIDTH}px - 20px);
  margin: 0 auto;
  border: 1px solid black;
  border-radius: 5px;
  background: white;
  box-shadow: 0px 4px 8px rgba(0, 0, 0, 0.2);
  cursor: pointer;

  transition: 500ms;
  &.disappear {
    opacity: 0;
    transform: translateY(50px);
  }
  &.appear {
    opacity: 1;
    transform: translateY(0);
  }
`;

const CloseButton = styled(Icon)`
  position: absolute;
  right: 0;
  padding: 13px 12px;
`;

const LeadText = styled.div`
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
`;

export default SnackBar;

isVisible 과 같은 state를 추가로 등록해 close 버튼의 동작을 추가해주어도 좋다.

결과물

좋은 웹페이지 즐겨찾기