
transform을 이용해 Animated Popover 컴포넌트 구현하기
2022.11.02
TLDR; 간단한 블로그 포스트 공유 버튼을 만들었다.
구현에 필요한 기능은 아래와 같다.
- clipboard에 주소를 복사할 수 있을 것
- Animation(slide-in/slide-out)이 적용된 Popover을 보여줄 것
간단하게 아래 코드로 먼저 만들어보았다.
// ShareButton.tsx import { CopyToClipboard } from "@/utils/copyText"; import React, { useState } from "react"; import { FiLink } from "react-icons/fi"; import Popover from "@/components/popover"; interface ShareButton { url: string; } const ShareButton = ({ url }: ShareButton) => { const [open, setOpen] = useState(false); const timerRef = React.useRef(0); React.useEffect(() => { return () => clearTimeout(timerRef.current); }, []); const handleClick = () => { CopyToClipboard(url); setOpen(true); window.clearTimeout(timerRef.current); timerRef.current = window.setTimeout(() => { setOpen(false); }, 1000); }; return ( <div className="relative flex flex-col items-center"> <Popover open={open} onOpenChange={setOpen}> 링크가 복사되었습니다 😄 </Popover> <div className="w-max flex flex-row gap-2 items-center p-3 cursor-pointer text-lg leading-normal text-gray-400 transition-colors rounded-xl bg-gray-500 bg-opacity-10 hover:bg-opacity-30" onClick={handleClick} > <FiLink /> 공유하기 </div> </div> ); }; export default ShareButton;
// Popover.tsx import React, { useState, useEffect } from "react"; import styled, { keyframes } from "styled-components"; const Popover = ({ open, onOpenChange, children, }: { open: boolean; onOpenChange: any; children: any; }) => { return ( <Panel open={open}> <div className="text-center font-medium">{children}</div> </Panel> ); }; const slideIn = keyframes` from { opacity: 0; transform: translateY(50%); } to { opacity: 1; transform: translateY(0); } `; const slideOut = keyframes` from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(50%); } `; const Panel = styled.div<{ open: boolean }>` position: absolute; top: -60px; z-index: 20; transform: translateX(-50%); padding: 8px; width: max-content; border-radius: 1rem; background-color: white; box-shadow: rgba(17, 12, 46, 0.15) 0px 48px 100px 0px; display: flex; justify-content: center; align-items: center; animation-duration: 0.25s; animation-timing-function: ease-out; animation-name: ${({ open }) => (open ? slideIn : slideOut)}; animation-fill-mode: forwards; `; export default Popover;
버튼을 누르면 Popover가 1초간 보여지고, 사라지게 했다.
animation
대신 이렇게 하면 Popover가 보이지 않는 상태에도 opacity가 0인채로 DOM에 남아있다는 단점이 있다. 단점이라고 생각한 이유는 우리 눈에만 보이지 않을 뿐 화면에 렌더링 되며 불필요한 자원을 사용하고 있고, 이는 최적화 측면에서 좋지 않기 때문이다. Element의 visiblility를 조정할 수 있는 세가지 방법(opcity, visibility, display)을 비교한 stackoverflow 내용을 아래와 같이 정리할 수 있다.
visibility: hidden
,opacity : 0
→ DOM tree와 Render tree에서 남아있음 (Paint 되지 않음)
display: none
→ DOM tree에는 존재, Render tree에서는 제외
조건부 렌더링
→ DOM tree에도 존재하지 않음
CSS 속성을 이용해 visibility를 조정하지 않고, Popover 컴포넌트 내에
visible
이라는 상태를 따로 두어 조건부 렌더링 방식으로 바꾸어주는 것이 바람직하다고 생각한다. import React, { useState, useEffect } from "react"; import styled, { keyframes } from "styled-components"; const Popover = ({ open, onOpenChange, children, }: { open: boolean; onOpenChange: any; children: any; }) => { const [visible, setVisible] = useState(false); useEffect(() => { if (open && !visible) { setVisible(open); // open===true -> visible===true } if (!open && visible) { setTimeout(() => setVisible(false), 250); // open===false -> after 250ms visible===false } }, [open]); if (!visible) return <></>; return ( <Panel open={open}> <div className="text-center font-medium">{children}</div> </Panel> ); }; const slideIn = keyframes` from { opacity: 0; transform: translateY(50%); } to { opacity: 1; transform: translateY(0); } `; const slideOut = keyframes` from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(50%); } `; const Panel = styled.div<{ open: boolean }>` position: absolute; top: -60px; z-index: 20; transform: translateX(-50%); padding: 8px; width: max-content; border-radius: 1rem; background-color: white; box-shadow: rgba(17, 12, 46, 0.15) 0px 48px 100px 0px; display: flex; justify-content: center; align-items: center; animation-duration: 0.25s; animation-timing-function: ease-out; animation-name: ${({ open }) => (open ? slideIn : slideOut)}; animation-fill-mode: forwards; `; export default Popover;
Popover 컴포넌트 안에서 open, visible이라는 boolean 값을 이용해 시간차로 animation을 보여주는 로직이 들어가있는데, 추후 Popover 뿐 아니라 Modal, Dropdown 등의 컴포넌트에서 Animated Visible 효과를 사용할 수 있다는 점에서 Custom hook으로 분리를 하면 좋을 것 같았다. 따라서
useAnimationVisible
이라는 Hook을 만들어서 분리했다.최종 코드는 아래와 같다.
// useAnimationVisible.tsx import { useState, useEffect, useCallback } from "react"; type useAnimationProps = [boolean, (open: boolean) => void]; const useAnimationVisible = (duration: number): useAnimationProps => { const [visible, setVisible] = useState<boolean>(false); const [open, setOpen] = useState<boolean>(false); useEffect(() => { if (open && !visible) { setVisible(open); } if (!open && visible) { setTimeout(() => setVisible(false), duration); } }, [open]); const _setVisible = useCallback( (open: boolean) => { setOpen(open); }, [setOpen], ); return [visible, _setVisible]; }; export { useAnimationVisible };
import { useAnimationVisible } from "@/hook/useAnimationVisible"; import React, { useState, useEffect } from "react"; import styled, { keyframes } from "styled-components"; const Popover = ({ open, duration, children, }: { open: boolean; duration: number; children: any; }) => { const [visible, setVisible] = useAnimationVisible(duration); useEffect(() => { setVisible(open); }, [open]); if (!visible) return <></>; return ( <Panel open={open} duration={duration}> <div className="text-center font-medium">{children}</div> </Panel> ); }; const slideIn = keyframes` from { opacity: 0; transform: translateY(50%); } to { opacity: 1; transform: translateY(0px); } `; const slideOut = keyframes` from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(50%); } `; const Panel = styled.div<{ open: boolean; duration: number }>` position: absolute; top: -60px; z-index: 20; transform: translateX(-50%); padding: 8px; width: max-content; border-radius: 1rem; background-color: rgba(107, 114, 128, 0.1); box-shadow: rgba(17, 12, 46, 0.15) 0px 48px 100px 0px; display: flex; justify-content: center; align-items: center; animation-duration: ${({ duration }) => duration / 1000}s; animation-timing-function: ease-out; animation-name: ${({ open }) => (open ? slideIn : slideOut)}; animation-fill-mode: forwards; `; export default Popover;
import { CopyToClipboard } from "@/utils/copyText"; import React, { useState } from "react"; import { FiLink } from "react-icons/fi"; import Popup from "@/components/popover"; interface ShareButton { url: string; } const ShareButton = ({ url }: ShareButton) => { const [open, setOpen] = useState(false); const timerRef = React.useRef(0); React.useEffect(() => { return () => clearTimeout(timerRef.current); }, []); const handleClick = () => { CopyToClipboard(url); setOpen(true); window.clearTimeout(timerRef.current); timerRef.current = window.setTimeout(() => { setOpen(false); }, 1000); }; return ( <div className="relative flex flex-col items-center"> <Popup open={open} duration={250}> 링크가 복사되었습니다 😄 </Popup> <div className="w-max flex flex-row gap-2 items-center p-3 cursor-pointer text-lg leading-normal text-gray-400 transition-colors rounded-xl bg-gray-500 bg-opacity-10 hover:bg-opacity-30" onClick={handleClick} > <FiLink /> 공유하기 </div> </div> ); }; export default ShareButton;
