바닥에서 올라오는 팝업 만들기

PeaceSong·2021년 12월 12일
0
post-custom-banner

0. Intro

몇 달 전에 팀에서 공통으로 쓸 팝업 컴포넌트를 만든 적이 있었다. 아래에서 위로 슉 하고 올라오는 컴포넌트인데, 우리 팀이 주로 모바일쪽을 다뤄서 그런지는 몰라도 리액트 오픈소스 자료들 중 쓸 만한 것들이 많이 없었다. 목마른 놈이 우물을 파는 법이기에 (그리고 보통 나다 싶은 놈이 작업을 뛰는 법이기에) 바닥에서 올라오는 팝업 (BottomPopup이라고 하겠다)을 바닥부터 구현했었다. 비록 개똥같은 코드이지만 hoxy라도 누군가에게 도움이 되기를 바라며 여기에 공유한다.

1. Airbnb의 사례

BottomPopup을 만들 때 Airbnb의 사례를 참고했다. 아래 영상처럼 바닥에서 올라오고, overlay(dimmed처리되는 영역)와 content(실제 뜨는 팝업 영역)의 투명도도 같이 조절되는 식으로 구현되었다.

2. BottomPopup 구현

구현 예시에 사용할 컴포넌트 구조는 아래와 같다. 간단한 div 태그의 버튼이 있고, 이 버튼을 클릭함으로써 react state를 변경하여 BottomPopupisOpen props를 컨트롤하는 구조이다.

// App.tsx
const App: FC = () => {
  const [isPopupOpen, setIsPopupOpen] = useState(false)
  const style = {
    backgroundColor: isPopupOpen ? 'greenyellow' : 'white'
  }
  
  return (
    <div className={styles.body}>
      <div className={styles.button} onClick={() => setIsPopupOpen(!isPopupOpen)} style={style}>
        {isPopupOpen ? 'ON' : 'OFF'}
      </div>
      <BottomPopup isOpen={isPopupOpen} onClose={() => setIsPopupOpen(false)} heightPixel={500}>
        <div className={styles.content}>
          <h1>Lorem Ipsum</h1>
          Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed arcu neque, elementum eget molestie ac, tincidunt eget ligula. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer tempus placerat lectus, non suscipit lectus gravida ac. Donec viverra blandit enim. Suspendisse tincidunt, turpis at semper feugiat, velit ante auctor purus, et blandit felis quam vel diam. Quisque vitae nunc sed diam commodo convallis vel quis nisl. Morbi sagittis, lorem et porttitor tempus, arcu massa euismod ipsum, ut aliquam purus lectus et neque. Nullam nisi diam, vulputate porttitor laoreet eget, faucibus a massa. Morbi scelerisque maximus nisi, id auctor sem fringilla sed. Nunc porta dignissim velit, eu mattis mi tincidunt sollicitudin. Duis vitae arcu lacus. Vestibulum porttitor metus odio, sed mattis neque pretium non. Nunc elit tellus, posuere quis risus ac, luctus vulputate lectus. Nam dapibus ligula et arcu semper ultricies et eleifend odio. Aliquam erat volutpat. <br />
          <br />
          Maecenas consequat mattis lacus id molestie. Etiam ac enim pulvinar, sodales eros at, pellentesque ipsum. Nunc sed enim pellentesque, tristique metus in, iaculis metus. Curabitur lorem ex, feugiat a ipsum et, fermentum condimentum eros. Ut rhoncus a augue eu faucibus. Proin id elit posuere, iaculis dui dapibus, rhoncus lorem. Duis eget faucibus nisl. Aenean quis tellus ex. Praesent vitae magna feugiat, molestie sem et, lobortis risus. Etiam sit amet semper purus. Vestibulum condimentum, erat eget tristique bibendum, nunc orci tempor tortor, eu eleifend nulla nunc et tellus. Proin nulla turpis, mollis at pharetra eu, consequat quis mi. Fusce scelerisque urna et ligula auctor posuere. Aenean auctor dui mollis nulla volutpat, sed pharetra dolor interdum. Etiam vitae nisl a magna efficitur consequat.
        </div>
      </BottomPopup>
    </div>
  )
}

export default App

스타일을 적용한 처음 화면은 아래와 같다.

2.0. 프레임워크: react-spring

기본적으로 리액트 프로젝트 안에서 구현한 컴포넌트이기 때문에, 리액트에서 애니메이션을 넣는 데 유용한 패키지인 react-spring을 사용했다. react-spring의 문서가 잘 설명되어 있기 때문에 여기서는 굳이 패키지의 사용법을 적지는 않을 것이다.

2.1. 기본 기능 구현

type BottomPopupProps = {
  isOpen: boolean
  onClose: () => void
  children?: any
  heightPixel: number
}

const BottomPopup: FC<BottomPopupProps> = ({
  isOpen,
  onClose,
  children,
  heightPixel,
}) => {
  const [isInDOM, setIsInDOM] = useState(false)
  const bodyOverflowStyleRef = useRef<string>(document.body.style.overflow)
  const topRef = useRef<string>(document.body.style.top)

  const [springProps, api] = useSpring(() => ({
    height: '0px',
    onRest: {
      height: (height) => {
        if (height.value === `${heightPixel}px`) {
          return
        }
        if (height.value === '0px') {
          setIsInDOM(false)
        }
      },
    },
  }))

  const handleOverlayClick = useCallback(() => onClose(), [onClose])

  const handleContentClick = useCallback((e: React.MouseEvent) => e.stopPropagation(), [])

  useEffect(() => {
    if (isOpen) {
      const currY = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop

      bodyOverflowStyleRef.current = document.body.style.overflow
      topRef.current = document.body.style.top
      document.body.style.overflow = 'hidden'
      document.body.style.top = `-${currY}px`
      setIsInDOM(true)
    } else {
      api.start({ height: '0px', immediate: false })
    }
  }, [isOpen, api])

  useEffect(() => {
    if (isInDOM) {
      api.start({ height: `${heightPixel}px` })
    } else if (document.body.style.overflow === 'hidden') {
      document.body.style.overflow = bodyOverflowStyleRef.current
      document.body.style.top = topRef.current
    }
  }, [isInDOM, api, heightPixel])

  useEffect(() => () => {
    if (document.body.style.overflow === 'hidden') {
      document.body.style.overflow = bodyOverflowStyleRef.current
      document.body.style.top = topRef.current
    }
  }, [])

  if (!isInDOM) return null

  return (
    <>
      <div className={styles.overlay} onClick={handleOverlayClick} />
      <animated.div style={springProps} className={styles.content} onClick={handleContentClick}>
        {children}
      </animated.div>
    </>
  )
}

export default BottomPopup

와! 뭐가 엄청 많다! 하나씩 천천히 뜯어보자.

2.1.1. props

type BottomPopupProps = {
  isOpen: boolean
  onClose: () => void
  children?: any
  heightPixel: number
}
  • isOpen
    팝업의 열림/닫힘 상태를 컨트롤한다. boolean 타입이며, 상위 컴포넌트에서 관장한다.

  • onClose
    함수이며, 상위 컴포넌트에서 정의하여 내려주면 팝업에서는 단순히 실행만 한다. 따라서 isOpen props의 상태를 onClose 함수에서 변경하도록 구현해야 한다. 상위 컴포넌트가 원하면 추가로 다른 작업도 같이 넣어줄 수 있다.

  • children
    자식 리액트 컴포넌트들이다.

  • heightPixel
    number 타입이며, 팝업이 떴을 때 몇 픽셀까지 띄워줄 지를 결정한다.

2.1.2. useSpring

const [springProps, api] = useSpring(() => ({
  height: '0px',
  onRest: {
    height: (height) => {
      if (height.value === `${heightPixel}px`) {
        return
      }
      if (height.value === '0px') {
        setIsInDOM(false)
      }
    },
  },
}))
  • spring propsonRest는 애니메이션 동작이 완료되면 호출되는 콜백함수이다. height === `${heightPixel}px` 이면 BottomPopup이 열리는 동작이 완료된 것으로 간주하고 아무 것도 하지 않는다. height === '0px'이면 BottomPopup이 close되는 동작이 완료된 것으로 간주하고 setIsInDOM(false)를 호출하여 DOM에서 제거한다.
  • useSpring hook으로 받아온 start 콜백을 호출하면 애니메이션 동작이 시작된다.

2.1.3. openness control

useEffect(() => {
  if (isOpen) {
    const currY = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop

    bodyOverflowStyleRef.current = document.body.style.overflow
    topRef.current = document.body.style.top
    document.body.style.overflow = 'hidden'
    document.body.style.top = `-${currY}px`
    setIsInDOM(true)
  } else {
    api.start({ height: '0px', immediate: false })
  }
}, [isOpen, api])

useEffect(() => {
  if (isInDOM) {
    api.start({ height: `${heightPixel}px` })
  } else if (document.body.style.overflow === 'hidden') {
    document.body.style.overflow = bodyOverflowStyleRef.current
    document.body.style.top = topRef.current
  }
}, [isInDOM, api, heightPixel])
BottomPopup이 열릴 때
  1. 상위 컴포넌트에서 isOpen props를 true로 바꾼다.
  2. 첫 번째 useEffect가 실행된다.
  3. isOpentrue이므로 document body의 overflow 스타일을 hidden으로 바꿔 스크롤을 막고, 현재 스크롤을 내린 만큼의 값을 top으로 고정시켜 화면이 맨 위로 올라가지 못하도록 막는다.
  4. isInDOMtrue로 바꾼다.
  5. 두 번째 useEffect가 실행된다.
  6. isInDOMtrue이므로 위로 올라오는 애니메이션이 시작된다.
BottomPopup이 닫힐 때
  1. 상위 컴포넌트에서 isOpen props를 false로 바꾼다.
  2. 첫 번째 useEffect가 실행된다.
  3. isOpenfalse이므로 아래로 내려가는 애니메이션이 시작된다.
  4. 애니메이션이 끝나면 onRest에 정의한 대로, isInDOMfalse로 바뀐다.
  5. isInDOMfalse이므로 컴포넌트는 null을 리턴하고, document body의 overflowhidden이므로 overflowtop을 원래대로 복원한다.

2.1.4. cleanup function

useEffect(() => () => {
  if (document.body.style.overflow === 'hidden') {
    document.body.style.overflow = bodyOverflowStyleRef.current
    document.body.style.top = topRef.current
  }
}, [])
  • BottomProps 내부에 링크가 있는 등의 이유로 isOpenfalse로 바뀌어 컴포넌트가 정상적으로 닫히지 않고 바로 언마운트되는 경우, document body에 적용된 overflow: hidden이 초기화되지 않고 빠져나가게 되어 스크롤이 막히게 된다. 따라서 언마운트 시에 document body의 스타일을 모두 복구하고 나가는 cleanup function이 있어야 한다.

이렇게 구현한 컴포넌트를 heightPixel props를 800을 주고 실행하면 아래와 같이 동작하는 것을 확인할 수 있다!

2.2. 자식 노드 감싸기

그런데 이 구현에는 문제가 있다. 바로 팝업의 높이를 미리 알고 props로 내려주어야 하는 것이다. 위의 예시에서도 글자를 모두 그려주고도 공간이 남아서 쓸데없이 팝업이 높이 올라가는 것을 볼 수 있다. 혹은 높이를 잘못 계산하여 적게 주게 된다면 글자가 짤리는 일이 발생할 것이다. 이를 방지하기 위해서는 자식 노드를 감싸는 방식으로도 구현할 수 있어야 한다.

contentRef

먼저, 자식 노드를 감싸는 방식으로 높이를 계산하게끔 컴포넌트에 알려주어야 한다. 이를 위해서 BottomPopup의 props를 조금 수정하자.

type HeightOption = {
  heightPixel?: number
  wrapChildren?: boolean
}

type BottomPopupProps = {
  isOpen: boolean
  onClose: () => void
  children?: any
  heightOption?: HeightOption
}

heightOptionwrapChildren이 true이면, 높이값을 미리 주지 않고 자식을 감싸는 높이만큼만 뜨게 될 것이다.

이러한 구현을 위해 여기에서는 useRef을 이용하여 자식 컴포넌트의 ref를 가져와서 자식의 높이를 알아내고, 이 높이를 팝업의 높이로 사용한다.

// BottomPopup.tsx

  ...
  
  const contentRef = useRef<HTMLDivElement>(null) // contentRef 생성
  
  const { heightPixel: _heightPixel, wrapChildren } = heightOption || {}

  const heightPixel = wrapChildren ? contentRef.current?.offsetHeight : _heightPixel || window.innerHeight / 2 // 두 값 모두 주지 않거나, 문제가 있으면 window.innerHeight의 절반만큼만 뜨도록 설정
  
  ...

  // wrapChildren과 상관 없이 isInDOM이 false일 때 컴포넌트를 그리지 않는다면, contentRef의 current가 가리키는 레퍼런스도 같이 사라진다.
  // 따라서 wrapChildren이 true일 때는 BottomPopup이 닫힌 상태여도 contentRef의 레퍼런스를 잃어버리지 않도록 해야 한다.
  // 이 경우 비록 메모리상의 낭비가 있더라도 팝업이 보이지 않을 때에도 계속 DOM에 유지시켜줘야 한다.
  if (!wrapChildren && !isInDOM) return null

  return (
    <>
      {/* wrapChildren이 true이고 isInDOM이 false인 경우 overlay도 같이 사라져야 하므로 isInDOM으로 조건을 걸어준다. */}
      {isInDOM && <div className={styles.overlay} onClick={handleOverlayClick} />}
      <animated.div style={springProps} className={styles.content} onClick={handleContentClick}>
        <div ref={contentRef}> // contentRef을 
          {children}
        </div>
      </animated.div>
    </>
  )

이제 heightOptionwrapChildren props를 true로 놓으면, 아래처럼 딱 자식을 그릴 정도로만 팝업이 뜨는 것을 볼 수 있다!

2.3. 최종 구현

// BottomPopup.tsx
type HeightOption = {
  heightPixel?: number
  wrapChildren?: boolean
}

type BottomPopupProps = {
  isOpen: boolean
  onClose: () => void
  children?: any
  heightOption?: HeightOption
}

const BottomPopup: FC<BottomPopupProps> = ({
  isOpen,
  onClose,
  children,
  heightOption,
}) => {
  const [isInDOM, setIsInDOM] = useState(false)
  const bodyOverflowStyleRef = useRef<string>(document.body.style.overflow)
  const topRef = useRef<string>(document.body.style.top)
  const contentRef = useRef<HTMLDivElement>(null)

  const { heightPixel: _heightPixel, wrapChildren } = heightOption || {}

  const heightPixel = wrapChildren ? contentRef.current?.offsetHeight : _heightPixel || window.innerHeight / 2

  const [springProps, api] = useSpring(() => ({
    height: '0px',
    onRest: {
      height: (height) => {
        if (height.value === `${heightPixel}px`) {
          return
        }
        if (height.value === '0px') {
          setIsInDOM(false)
        }
      },
    },
  }))

  const handleOverlayClick = useCallback(() => onClose(), [onClose])

  const handleContentClick = useCallback((e: React.MouseEvent) => e.stopPropagation(), [])

  useEffect(() => {
    if (isOpen) {
      const currY = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop

      bodyOverflowStyleRef.current = document.body.style.overflow
      topRef.current = document.body.style.top
      document.body.style.overflow = 'hidden'
      document.body.style.top = `-${currY}px`
      setIsInDOM(true)
    } else {
      api.start({ height: '0px', immediate: false })
    }
  }, [isOpen, api])

  useEffect(() => {
    if (isInDOM) {
      api.start({ height: `${heightPixel}px` })
    } else if (document.body.style.overflow === 'hidden') {
      document.body.style.overflow = bodyOverflowStyleRef.current
      document.body.style.top = topRef.current
    }
  }, [isInDOM, api, heightPixel])

  useEffect(() => () => {
    if (document.body.style.overflow === 'hidden') {
      document.body.style.overflow = bodyOverflowStyleRef.current
      document.body.style.top = topRef.current
    }
  }, [])

  if (!wrapChildren && !isInDOM) return null

  return (
    <>
      {isInDOM && <div className={styles.overlay} onClick={handleOverlayClick} />}
      <animated.div style={springProps} className={styles.content} onClick={handleContentClick}>
        <div ref={contentRef}>
          {children}
        </div>
      </animated.div>
    </>
  )
}

export default BottomPopup

3. 마무리

팀에서 구현한 팝업을 간략화하여 소개하였다. 실제 팀에서 쓰는 팝업 컴포넌트는 조금 더 복잡하고, 이름도 다르므로 문제될 것 같지는 않지만, 혹시 이 글을 보는 관련자(HR이라던가 기술성장위원회라던가 보안팀이라던가)분들이 이게 문제된다고 생각하시면 흑흑 살려주세요 당장 지우겠읍니다.

혹시 개선사항이 있다면 알려주시면 좋을 것 같고, "이딴 개똥컴포넌트를 누가 쓰냐?"라고 생각하신다면 당신의 말이 다 맞습니다.

profile
127.0.0.1
post-custom-banner

0개의 댓글