기술 메모장

첫 실전업무! 모달 제작해보기 with React Portal, 애니메이션 적용기

ghoon99 2022. 7. 27. 18:26

회사에서 이걸 한건 아니고..

첫 업무

처음으로 사내에서 쓰는 페이지가 아닌 사용자가 직접 볼 수 있는 페이지를 맡게 되었다.

간단한 초대 이벤트 페이지를 만드는 것이였고 구현해야 될 것 중에는 SNS 공유하기 모달창이 있었다. 

 

사내 CMS에는 UI 라이브러리(Ant Design)를 사용하여 직접 모달을 구현하지 않아도 괜찮지만

이 프로젝트에서는 라이브러리 없이 내가 직접 만들어서 사용해야 했다.

 

공유하기 모달창의 구현 내용은 다음과 같다.

 

- 버튼을 클릭하면 밑에서 공유하기 모달창이 올라오고 뒷 배경은 어두워진다.

- 뒷 배경을 클릭하면 모달이 닫히도록 구현해야 한다.

- 열고 닫을 때 페이드인/아웃, 슬라이드 업/다운 애니메이션이 적용되어야 한다.

 

 

처음 혼자서 만들어볼 때는 검색을 하면서 금방 만들어 볼 수 있었다.

그리고 팀장님께 중간 점검을 받았고 다른 방식은 어떠한가 말씀하시면서 팁을 주셨다.

 

팀장님께서 알려주신 내용을 잘 찾아보며 

React Portal , onAnimationEnd 에 대하여 알게 되었다.

 

이번에 글을 작성하면서 다시 한번 모달을 구현 해보았고

그 때 당시 알게 되었던 내용을 복습하게 되었다.

 

그 과정에서 더 생각해볼 것들이 떠올라 마무리에 남겨보았다.

 

 

 

모달 Modal..?

괜찮은 글이 있어서 공유를 해본다.

https://yozm.wishket.com/magazine/detail/1272/

 

컴포넌트 스터디: ①팝업, 바텀시트, 스낵바 | 요즘IT

모달(modal) UI는 무엇일까? 모달은 사용자의 이목을 끌기 위해 사용하는 화면전환 기법을 의미한다. 팝업, 바텀 시트, 스낵바 등 여러 가지가 존재해, 어떤 UI를 사용하면 좋을지 헷갈릴 때가 많다.

yozm.wishket.com

이렇게 생긴거 있잖아..

모달 UI 에도 다양한 분류가 있었다고 한다. 크게 팝업, 바텀 시트, 스낵바 라고 나누는 듯 싶다.

저 글에 따르면 내가 구현해야 할 기능은 바텀시트(Bottom Sheet) 라고 한다.

 

React Portal 

출처: 리액트 공식 문서

"리액트 포탈 한번 사용해보세요"

 

 

팀장님의 팁을 듣고 바로 리액트 포탈이 무엇을 하는 녀석인가를 살펴보았다.


말 그대로 "포탈" 같은 것이였다.
부모 컴포넌트 내부가 아닌 다른 컴포넌트의 자식으로
어떤 요소를 특정 DOM 요소의 자식으로 렌더링 시킬 수 있게 하는 것이다.


이게 모달을 만드는 것과 무슨 상관인가..? 했더니 

portal의 전형적인 유스케이스는 부모 컴포넌트에 overflow: hidden 이나  z-index가 있는 경우이지만,
시각적으로 자식을 “튀어나오도록” 보여야 하는 경우도 있습니다. 예를 들어, 다이얼로그, 호버카드나 툴팁과 같은 것입니다.
- React Portal 설명 문서 중 -

라고 한다. 

 

최상위 부모 노드 (div id="root") 에 독립적인 또 다른 최상위 노드를 생성하여 그곳에 모달 컴포넌트를 렌더링 하면

부모 컴포넌트(최상위까지)들의 css(z-index 등)의 상속 영향을 받지 않게 할 수 있다.

 

포탈을 컴포넌트화 시켜서 구현을 해보았다.

import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";

type TProps = {
  id?: string;
  children: React.ReactNode;
};

const Portal = ({ id, children }: TProps) => {
  const portalDOMRef = useRef<HTMLDivElement | null>(null);
  const [isPortalMounted, setIsPortalMounted] = useState(false);
  // 동적으로 노드 생성 
  useEffect(() => {
    const element = document.createElement("div");
    element.id = id ?? "portal";
    document.body.appendChild(element);
    portalDOMRef.current = element;
    setIsPortalMounted(true);

    return () => {
      const mountedElement = portalDOMRef.current as HTMLDivElement;
      mountedElement.parentElement!.removeChild(mountedElement);
      portalDOMRef.current = null;
      setIsPortalMounted(false);
    };
  }, [id]);

  if (!isPortalMounted) return null;
  // 포탈 생성 
  return createPortal(children, portalDOMRef.current!);
};

export default Portal;

이렇게 Portal 컴포넌트 내부에 있는 요소들은 모두 root 바깥에 렌더링되게 만들 수 있다.

 <Portal id={id}><Modal/></Portal>

root 말고 다른 노드(portal)의 자식으로 모달을 렌더링 하는 모습

이런식으로 말이다.

 

애니메이션 적용하기

모달 컴포넌트를 만드는건 생각보다 간단했다.

다만 어떤식으로 설계를 해야 모달 컴포넌트를 재사용을 가능하게 만들 수 있을까에 대한 생각은 어려웠다.

이는 마지막에서 다시 언급하겠다.

Modal 컴포넌트를 사용하는 쪽의 코드. 출력 여부,취소,확인 동작을 정의하고 연결했다.

스타일과 동작은 어느정도 구현이 완료되었고,

모달이 보여지고 사라질때의 애니메이션을 적용해보려고 하였다.

 

Fade in / Slide up 은 되는데...

모달을 열때 뒷 배경이 점점 어두워지고 , 창이 위로 슬라이드 되는 애니메이션은 적용이 정상적으로 된다.

 

하지만 모달을 닫을 때 모달의 렌더링 여부를 결정하는 상태인 visible 이 false 가 되며 

모달 컴포넌트는 애니메이션 끝나기도 전에 화면에서 사라지는 문제가 생겼다.

 

검색을 해보니 이를 해결하는 방법이 있었고 처음엔 첫번째 방법을 사용해서 구현을 했었다.

 

첫번째 방법, setTimeout

visible 의 상태를 바로 변경시키지말고, 애니메이션이 끝날때까지 렌더링 상태를 유지시키고

애니메이션이 끝나면 visible 을 false 로 만드는 방법이다.

 

렌더링 상태를 유지시키는 것을 setTimeout 으로 구현하고 , 애니메이션 지속 시간과 타이머 시간을 맞춰 해결하는 방법이였다.

 

리액트 입문 시절 많은 도움을 받았던 이 문서에서 봤던 기억이 나서 보고 구현하였다. 

https://react.vlpt.us/styling/03-styled-components.html

 

3. styled-components · GitBook

03. styled-components 이번에 배워볼 기술은 CSS in JS 라는 기술입니다. 이 문구가 뜻하는 그대로, 이 기술은 JS 안에 CSS 를 작성하는 것을 의미하는데요, 우리는 이번 튜토리얼에서 해당 기술을 사용하

react.vlpt.us

 

두번째 방법, onAnimationEnd

원리는 똑같다.

visible 의 상태를 바로 변경시키지말고, 애니메이션이 끝날때까지 렌더링 상태를 유지시키는 것을 다른 방식으로 구현했다.

애니메이션이 끝날 때에 특정 함수를 호출하는 방법이 존재하였다.

 

 

animationend 라는 이벤트는 요소의 애니메이션이 종료될때 발생하는 이벤트라고 한다.

이것을 이용하여 애니메이션이 끝날 때까지 모달을 닫는 함수를 호출하지 않고

 

애니메이션이 끝난 후 모달을 닫는 함수를 호출하면 

visible 의 값이 애니메이션이 끝난 후 변경되어

slide down / fade out 애니메이션을 볼 수 있게된다. 

 

const Modal = ({ id, onConfirm, onClose, title, children }: TProps) => {
  const [close, setClose] = useState(false);

  // 모달이 닫혀야 하는 상태로 만들음
  const handleModalClose = () => {
    setClose(true);
  };

  const handleConfirm = () => {
    onConfirm();
    handleModalClose();
  };

  // 애니메이션이 끝난 후 호출 
  const handleAnimationEnd = () => {
  	// 애니메이션이 끝났을 때 모달이 닫혀야 할 때면
    if (close) {
    // 밖에 있는 visible 를 false 로 변경하는 함수 
      onClose();
    }
  };

  return (
     <Portal id={id}>
      <StyledMask close={close} onAnimationEnd={handleAnimationEnd}>
        <StyledModalWrapper close={close}>
          <div className="modal-content">
            <div className="modal-title">{title}</div>
            <div className="modal-body">{children}</div>
            <div className="button-group">
              <Button color="gray" onClick={handleModalClose}>
                취소
              </Button>
              <Button color="skyblue" onClick={handleConfirm}>
                확인
              </Button>
            </div>
          </div>
        </StyledModalWrapper>
      </StyledMask>
    </Portal>
  );
};

export default Modal;

결과는 다음과 같다.

 

화면이 깜빡이는 버그가 생겼다.

아주 순간적이라 캡쳐하면 보이지도 않는다..

 

fade out 애니메이션 실행 후, 모달을 렌더링 해주는 visible 의 state 가 변경되면서 

순간적으로 (애니메이션이 끝난) 모달 상태가 보였다가 visible 이 false 가 되면서 깜빡이며 사라지는 현상이다.

 

 

이는 알고보니 애니메이션이 끝나고 순간적으로 opacitiy 가 다시 1이 되어버려 잠깐 화면에 다시 나타났다 사라지는게 원인이였다.

그래서 다음과 같이 close 상태로 (모달이 닫히는 애니메이션이 발생하는지 여부) 

모달이 닫힐때 화면에서 보이지 않게 설정하여 깜빡임 문제를 해결하였다.

close 는 모달이 닫히는 중일때 true 이다.

 

마무리

공유하기 모달을 만들면서 리액트 포탈, 애니메이션 이벤트(onAnimationEnd)를 알게 되었다.

자세한 내용이 더 궁금하면 참고자료에 있는 링크로 가서 찾아보자.

앞으로도 어떤 개념에 대해서 글을 옮겨적지는 않을 것이다.

 

 

 

포탈에 대해서 알아보다가 눈에 띄는 것이 있었다.

React Portal 을 이용할 때의 주의점. 웹 접근성과 관련이 있나보다.

링크를 타고 들어가보니 웹 접근성에 대한 내용이 있었고

이것도 나중에 한번 꼭 다시 봐야할 내용 같아 남겨놓는다.

 

 

 

 

모달 컴포넌트를 만들어보면서 

이 컴포넌트를 깔끔하게, 재사용성이 좋게 설계를 하려면 어떻게 만들까 라는 여러가지 고민이 생겼다.

모달 컴포넌트를 조건부 렌더링 해줄 visible은 어디에 놓아야 하는가..?
조금 더 유연하고 재사용성이 높은 컴포넌트는 어떻게 설계를 하나..?

 

antd , mui 등 의 ui 라이브러리를 사용하면서 나도 저렇게 

쉽게 가져다 쓸 수 있는 재사용 가능한 컴포넌트를 만들어보고 싶었다.

 

그래서 모달의 관련된 컴포넌트의 prop들은 어떻게 구성되어있는지 컴포넌트 API 문서를 보고 참고를 했다.

antd Modal API props
MUI Modal (Dialog) 컴포넌트

 

 

 

모달을 열고 닫는 상태에 대해서도 고민이 있었다.

위에서 고민한 내용과 동일한 맥락인 것 같다.

 

모달을 렌더링 할때마다 visible state, modal open , modal close  에 대한 handler 들을 정의해줘야 할까?

 

이들을 커스텀 hook 으로 묶어 const [openModal] = useModal() 형식으로 모달을 열 수 있는 함수 하나만 반환하게 만들고

<Button onClick={openModal}> 느낌으로 연결시켜주는 방법이 생각났다.

이런 방식은 어떻게 구현을 하는지도 연구를 해봐야겠다.

모달을 열기위해 쓰이는 state 와 handler 코드들을 줄일 방법은 없을까

공부할 것들이 계속 쌓인다..

다음 글은 클라이언트에서 이미지를 서버로 보내는 일에 대한 글을 작성할 예정이다

 

2편 끝!

 

참고자료

https://ko.reactjs.org/docs/portals.html

 

Portals – React

A JavaScript library for building user interfaces

ko.reactjs.org

https://developer.mozilla.org/en-US/docs/Web/API/Element/animationend_event

 

Element: animationend event - Web APIs | MDN

The animationend event is fired when a CSS Animation has completed. If the animation aborts before reaching completion, such as if the element is removed from the DOM or the animation is removed from the element, the animationend event is not fired.

developer.mozilla.org

https://mui.com/material-ui/api/dialog/

 

Dialog API - Material UI

API documentation for the React Dialog component. Learn about the available props and the CSS API.

mui.com

https://ant.design/components/modal/#API

 

Modal - Ant Design

Modal dialogs. When To Use# When requiring users to interact with the application, but without jumping to a new page and interrupting the user's workflow, you can use Modal to create a new floating layer over the current page to get user feedback or displa

ant.design