닫고 열 때 애니메이션이 있는 React Custom Popper Component 구현하기

kiwon kim·2025년 2월 5일

Frontend

목록 보기
20/30
post-thumbnail

개발 배경과 목표

MUI의 Popper 컴포넌트는 기본적으로 FadeClickAwayListener 컴포넌트를 추가로 필요로 합니다. 이는 다음과 같은 문제점을 야기합니다:

  1. 복잡한 컴포넌트 구조로 인한 오버헤드
  2. 불필요한 번들 사이즈 증가
  3. 애니메이션 처리를 위한 추가 컴포넌트 의존성
  4. 여러 컴포넌트 간의 복잡한 상태 관리

이러한 문제점들을 해결하기 위해 단일 컴포넌트로 모든 기능을 처리하는 커스텀 Popper를 개발했습니다.

전체 구현 코드

import { Box, Grid, SxProps, Theme } from "@mui/material";
import { 
  FC, 
  useCallback, 
  useEffect, 
  useRef, 
  useState, 
  memo, 
  TransitionEvent 
} from "react";

// Position 인터페이스 정의
interface Position {
  top: number;
  left: number;
}

// Placement 타입 정의
type Placement = "top" | "bottom";

// Props 인터페이스 정의
interface PopperProps {
  anchorEl: HTMLElement | null;  // Popper가 연결될 요소
  children: React.ReactNode;     // Popper 내부 콘텐츠
  open: boolean;                 // Popper 표시 여부
  placement?: Placement;         // Popper 위치 (top/bottom)
  offset?: number;              // 기준점으로부터의 거리
  sx?: SxProps<Theme>;          // 추가 스타일
  onClickOutside?: (e: MouseEvent) => void;        // 외부 클릭 핸들러
  onTransitionEnd?: (e: TransitionEvent<HTMLDivElement>) => void;  // 트랜지션 완료 핸들러
}

// 위치 계산 함수
const calculatePosition = (
  anchor: HTMLElement, 
  placement: Placement, 
  offset: number
): Position => {
  const rect = anchor.getBoundingClientRect();

  return {
    top: {
      top: -rect.top + offset,
      left: rect.left,
    },
    bottom: {
      top: rect.bottom + offset,
      left: rect.left,
    },
  }[placement];
};

// Popper 컴포넌트 구현
const Popper: FC<PopperProps> = memo(
  ({ 
    anchorEl, 
    children, 
    open, 
    placement = "bottom", 
    offset = 8, 
    sx, 
    onClickOutside, 
    onTransitionEnd 
  }) => {
    // 상태 관리
    const [{ top, left }, setPosition] = useState<Position>({ top: 0, left: 0 });
    const [mounted, setMounted] = useState(false);
    const popperRef = useRef<HTMLDivElement>(null);

    // 위치 업데이트 함수
    const updatePosition = useCallback(() => {
      if (anchorEl && popperRef.current) {
        setPosition(calculatePosition(anchorEl, placement, offset));
      }
    }, [anchorEl, placement, offset]);

    // 스크롤/리사이즈 이벤트 처리
    useEffect(() => {
      if (open && anchorEl) {
        updatePosition();
        setMounted(true);

        // 모든 가능한 위치 변경 이벤트에 대한 리스너 등록
        window.addEventListener("scroll", updatePosition);
        window.addEventListener("resize", updatePosition);
        window.addEventListener("wheel", updatePosition);
        window.addEventListener("touchmove", updatePosition);

        // 클린업 함수
        return () => {
          window.removeEventListener("scroll", updatePosition);
          window.removeEventListener("resize", updatePosition);
          window.removeEventListener("wheel", updatePosition);
          window.removeEventListener("touchmove", updatePosition);
        };
      }
    }, [open, anchorEl, updatePosition]);

    // 외부 클릭 감지
    useEffect(() => {
      const handleClickOutside = (e: MouseEvent) => {
        if (
          popperRef.current &&
          !popperRef.current.contains(e.target as Node) &&
          anchorEl &&
          !anchorEl.contains(e.target as Node)
        ) {
          onClickOutside?.(e);
        }
      };

      document.addEventListener("mousedown", handleClickOutside);
      return () => document.removeEventListener("mousedown", handleClickOutside);
    }, [anchorEl, onClickOutside]);

    // 조건부 렌더링
    if (!open && !mounted) return null;

    // 컴포넌트 렌더링
    return (
      <Box
        ref={popperRef}
        sx={{
          position: "fixed",
          zIndex: 1300,
          top,
          left,
          opacity: 0,
          visibility: "hidden",
          transition: "opacity 200ms ease-out, visibility 200ms ease-out",
          ...(open &&
            mounted && {
              opacity: 1,
              visibility: "visible",
            }),
          ...sx,
        }}
        onTransitionEnd={e => {
          if (e.propertyName === "opacity" && !open) {
            setMounted(false);
            onTransitionEnd?.(e);
          }
        }}
      >
        <Grid container>{children}</Grid>
      </Box>
    );
  }
);

// 컴포넌트 디스플레이 네임 설정
Popper.displayName = "Popper";

export default Popper;

주요 기능 상세 설명

1. 위치 계산 로직

const calculatePosition = (
  anchor: HTMLElement, 
  placement: Placement, 
  offset: number
): Position => {
  const rect = anchor.getBoundingClientRect();
  
  return {
    top: {
      top: -rect.top + offset,
      left: rect.left,
    },
    bottom: {
      top: rect.bottom + offset,
      left: rect.left,
    },
  }[placement];
};
  • getBoundingClientRect()를 사용하여 뷰포트 기준 위치 계산
  • placement에 따라 다른 위치 계산 로직 적용
  • offset 값으로 여백 조정 가능

2. 이벤트 핸들링

useEffect(() => {
  if (open && anchorEl) {
    updatePosition();
    setMounted(true);

    // 이벤트 리스너 등록
    const events = ["scroll", "resize", "wheel", "touchmove"];
    events.forEach(event => window.addEventListener(event, updatePosition));

    // 클린업
    return () => {
      events.forEach(event => window.removeEventListener(event, updatePosition));
    };
  }
}, [open, anchorEl, updatePosition]);
  • 다양한 이벤트에 대응하여 위치 업데이트
  • 모바일 환경 고려 (wheel, touchmove 이벤트)
  • 메모리 누수 방지를 위한 클린업 처리

3. 외부 클릭 처리

useEffect(() => {
  const handleClickOutside = (e: MouseEvent) => {
    if (
      popperRef.current &&
      !popperRef.current.contains(e.target as Node) &&
      anchorEl &&
      !anchorEl.contains(e.target as Node)
    ) {
      onClickOutside?.(e);  // setOpen(false) 호출
    }
  };

  document.addEventListener("mousedown", handleClickOutside);
  return () => document.removeEventListener("mousedown", handleClickOutside);
}, [anchorEl, onClickOutside]);
  • Popper와 anchorEl 영역 외 클릭 감지
  • 이벤트 버블링을 고려한 정확한 클릭 영역 체크

4. 애니메이션 처리

<Box
  sx={{
    opacity: 0,
    visibility: "hidden",
    transition: "opacity 200ms ease-out, visibility 200ms ease-out",
    ...(open && mounted && {
      opacity: 1,
      visibility: "visible",
    }),
  }}
  onTransitionEnd={e => {
    if (e.propertyName === "opacity" && !open) {
      setMounted(false);
      onTransitionEnd?.(e);  // setAnchorEl(null) 호출
    }
  }}
>
  • CSS 트랜지션을 활용한 부드러운 페이드 효과
  • visibility 속성을 통한 접근성 고려
  • 트랜지션 완료 후 정리 작업 실행

사용 예시

const App = () => {
  const [open, setOpen] = useState(false);
  const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
  
  const handleClick = (event: React.MouseEvent<HTMLElement>) => {
    setAnchorEl(event.currentTarget);
    setOpen(true);
  };

  return (
    <>
      <button onClick={handleClick}>
        Open Popper
      </button>
      
      <Popper
        open={open}
        anchorEl={anchorEl}
        onClickOutside={() => setOpen(false)}  // 애니메이션 시작
        onTransitionEnd={() => setAnchorEl(null)}  // 애니메이션 완료 후 정리
      >
        <div>Popper Content</div>
      </Popper>
    </>
  );
};

애니메이션 동작 순서

  1. 열기

    클릭 → setAnchorEl(element) → setOpen(true) → mounted true → 
    opacity 0 → transition → opacity 1
  2. 닫기

    외부 클릭 → onClickOutside → setOpen(false) → 
    opacity 1 → transition → opacity 0 → 
    onTransitionEnd → setAnchorEl(null)

성능 최적화 포인트

  1. 메모이제이션

    • 컴포넌트 자체 memo 처리
    • updatePosition 함수 useCallback 처리
    • 불필요한 리렌더링 방지
  2. 이벤트 최적화

    • 필요한 시점에만 이벤트 리스너 활성화
    • 정확한 클린업으로 메모리 누수 방지
    • 다양한 사용자 인터랙션 대응
  3. 렌더링 최적화

    • 조건부 렌더링으로 불필요한 DOM 트리 제거
    • CSS 트랜지션을 활용한 효율적인 애니메이션
    • 최소한의 상태 관리

이 구현은 MUI Popper의 복잡성을 제거하면서도, 더 나은 성능과 사용자 경험을 제공합니다. 특히 애니메이션 처리와 위치 계산에 있어 효율적인 방식을 채택하고 있습니다.

profile
FOR_THE_BEST_DEVELOPER

0개의 댓글