몇 달 전에 팀에서 공통으로 쓸 팝업 컴포넌트를 만든 적이 있었다. 아래에서 위로 슉 하고 올라오는 컴포넌트인데, 우리 팀이 주로 모바일쪽을 다뤄서 그런지는 몰라도 리액트 오픈소스 자료들 중 쓸 만한 것들이 많이 없었다. 목마른 놈이 우물을 파는 법이기에 (그리고 보통 나다 싶은 놈이 작업을 뛰는 법이기에) 바닥에서 올라오는 팝업 (BottomPopup
이라고 하겠다)을 바닥부터 구현했었다. 비록 개똥같은 코드이지만 hoxy라도 누군가에게 도움이 되기를 바라며 여기에 공유한다.
BottomPopup
을 만들 때 Airbnb의 사례를 참고했다. 아래 영상처럼 바닥에서 올라오고, overlay(dimmed처리되는 영역)와 content(실제 뜨는 팝업 영역)의 투명도도 같이 조절되는 식으로 구현되었다.
구현 예시에 사용할 컴포넌트 구조는 아래와 같다. 간단한 div
태그의 버튼이 있고, 이 버튼을 클릭함으로써 react state를 변경하여 BottomPopup
의 isOpen
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
스타일을 적용한 처음 화면은 아래와 같다.
기본적으로 리액트 프로젝트 안에서 구현한 컴포넌트이기 때문에, 리액트에서 애니메이션을 넣는 데 유용한 패키지인 react-spring
을 사용했다. react-spring
의 문서가 잘 설명되어 있기 때문에 여기서는 굳이 패키지의 사용법을 적지는 않을 것이다.
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
와! 뭐가 엄청 많다! 하나씩 천천히 뜯어보자.
type BottomPopupProps = {
isOpen: boolean
onClose: () => void
children?: any
heightPixel: number
}
isOpen
팝업의 열림/닫힘 상태를 컨트롤한다. boolean 타입이며, 상위 컴포넌트에서 관장한다.
onClose
함수이며, 상위 컴포넌트에서 정의하여 내려주면 팝업에서는 단순히 실행만 한다. 따라서 isOpen
props의 상태를 onClose
함수에서 변경하도록 구현해야 한다. 상위 컴포넌트가 원하면 추가로 다른 작업도 같이 넣어줄 수 있다.
children
자식 리액트 컴포넌트들이다.
heightPixel
number 타입이며, 팝업이 떴을 때 몇 픽셀까지 띄워줄 지를 결정한다.
const [springProps, api] = useSpring(() => ({
height: '0px',
onRest: {
height: (height) => {
if (height.value === `${heightPixel}px`) {
return
}
if (height.value === '0px') {
setIsInDOM(false)
}
},
},
}))
spring props
의 onRest
는 애니메이션 동작이 완료되면 호출되는 콜백함수이다. height === `${heightPixel}px`
이면 BottomPopup이 열리는 동작이 완료된 것으로 간주하고 아무 것도 하지 않는다. height === '0px'
이면 BottomPopup이 close되는 동작이 완료된 것으로 간주하고 setIsInDOM(false)
를 호출하여 DOM에서 제거한다.useSpring
hook으로 받아온 start
콜백을 호출하면 애니메이션 동작이 시작된다.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])
isOpen
props를 true
로 바꾼다.useEffect
가 실행된다. isOpen
이 true
이므로 document body의 overflow
스타일을 hidden
으로 바꿔 스크롤을 막고, 현재 스크롤을 내린 만큼의 값을 top
으로 고정시켜 화면이 맨 위로 올라가지 못하도록 막는다.isInDOM
을 true
로 바꾼다.useEffect
가 실행된다.isInDOM
이 true
이므로 위로 올라오는 애니메이션이 시작된다.isOpen
props를 false
로 바꾼다.useEffect
가 실행된다. isOpen
이 false
이므로 아래로 내려가는 애니메이션이 시작된다.onRest
에 정의한 대로, isInDOM
이 false
로 바뀐다.isInDOM
이 false
이므로 컴포넌트는 null
을 리턴하고, document body의 overflow
가 hidden
이므로 overflow
와 top
을 원래대로 복원한다.useEffect(() => () => {
if (document.body.style.overflow === 'hidden') {
document.body.style.overflow = bodyOverflowStyleRef.current
document.body.style.top = topRef.current
}
}, [])
BottomProps
내부에 링크가 있는 등의 이유로 isOpen
이 false
로 바뀌어 컴포넌트가 정상적으로 닫히지 않고 바로 언마운트되는 경우, document body에 적용된 overflow: hidden
이 초기화되지 않고 빠져나가게 되어 스크롤이 막히게 된다. 따라서 언마운트 시에 document body의 스타일을 모두 복구하고 나가는 cleanup function이 있어야 한다.이렇게 구현한 컴포넌트를 heightPixel
props를 800을 주고 실행하면 아래와 같이 동작하는 것을 확인할 수 있다!
그런데 이 구현에는 문제가 있다. 바로 팝업의 높이를 미리 알고 props로 내려주어야 하는 것이다. 위의 예시에서도 글자를 모두 그려주고도 공간이 남아서 쓸데없이 팝업이 높이 올라가는 것을 볼 수 있다. 혹은 높이를 잘못 계산하여 적게 주게 된다면 글자가 짤리는 일이 발생할 것이다. 이를 방지하기 위해서는 자식 노드를 감싸는 방식으로도 구현할 수 있어야 한다.
먼저, 자식 노드를 감싸는 방식으로 높이를 계산하게끔 컴포넌트에 알려주어야 한다. 이를 위해서 BottomPopup
의 props를 조금 수정하자.
type HeightOption = {
heightPixel?: number
wrapChildren?: boolean
}
type BottomPopupProps = {
isOpen: boolean
onClose: () => void
children?: any
heightOption?: HeightOption
}
heightOption
의 wrapChildren
이 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>
</>
)
이제 heightOption
의 wrapChildren
props를 true로 놓으면, 아래처럼 딱 자식을 그릴 정도로만 팝업이 뜨는 것을 볼 수 있다!
// 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
팀에서 구현한 팝업을 간략화하여 소개하였다. 실제 팀에서 쓰는 팝업 컴포넌트는 조금 더 복잡하고, 이름도 다르므로 문제될 것 같지는 않지만, 혹시 이 글을 보는 관련자(HR이라던가 기술성장위원회라던가 보안팀이라던가)분들이 이게 문제된다고 생각하시면 흑흑 살려주세요 당장 지우겠읍니다.
혹시 개선사항이 있다면 알려주시면 좋을 것 같고, "이딴 개똥컴포넌트를 누가 쓰냐?"라고 생각하신다면 당신의 말이 다 맞습니다.