React에서 ios 흉내내기 Slide Up Modal 구현

봉승우·2022년 8월 2일
45

모달창, 포스터치 등 ios에 있는 몇가지 기능들을 리액트에서 구현하는 첫 번째 프로젝트다!
해당 페이지에서는 리액트에서 돔에 접근하여 여러 스타일을 주면서 Slide Up 모달을 구현해보고자 한다😀

ios에서의 모달창은 어떻게 생겼나

특징정리

  1. 모달 창이 올라오면서, 모달 창에 의해 가려지는 요소가 쪼그라들며, 모서리가 둥글둥글해진다.
  2. 모달 창을 닫는 방법이 여러가지이다.
    1. 저장/취소 버튼을 누른다.
    2. 모달 창의 헤더(?)부분을 빠르게 내리면 닫힌다.
    3. 모달 창을 일정 수준이하로 내리면 닫힌다.
    4. 모달 창의 위치에 따라 뒷 배경(?)의 쪼그라드는 정도가 조정이 된다.

구현 전 고민과 생각

  1. 어떻게 뒷 배경에 스타일 효과를 줄 수 있을까.
    1. 뒷 배경의 DOM에 직접 접근해서 Style을 주면 되지 않을까
  2. 어떻게 모달 창의 열리는 정도를 손가락의 위치와 맞출 수 있을까.
    1. DOM에 이벤트 리스너를 적용하고, 해당 이벤트에서 넘어오는 인자에서 터치 포인트를 받아서 계산해보자
      MDN Touch 관련
  3. 어떻게 내리는 속도를 계산하여 닫을 수 있을까.
    1. 터치 이벤트가 시작될때, 그리고 끝날때 시간과 위치 차이를 계산해서 속도를 계산해서 특정 값에서분기처리를 해보자
  4. 어떻게 하나의 모달로 최대한 범용적으로 사용할 수 있을까.
    1. Hook에서 모달 컴포넌트를 반환하고, 인자(children) 값으로 ReactNode 타입의 컴포넌트를 받으면 되지 않을까...?
      커스텀 훅으로 컴포넌트 그리기-LINE 블로그

구현 중 발생한 문제들!

문제1 : useModal을 통해 정적(?)인 데이터를 내려주면 문제가 없는데, 내려주는 데이터가 바뀌면 모달 창이 뜨지 않는다.

const [idx, setIdx] = useState(0);
const { ModalComponent, openModal } = useModal({
  title: checkData[idx].title,
  children: checkData[idx].content,
});

위와 같이 데이터를 넘겨주는 부분을 state 값에 따라서 변경을 해주고 싶었다.
하지만, 제일 처음에전달된 {title: checkData[0].title, children: checkData[0].content}이 모달 이외에 idx 값이 변경되면 모달이 정상적으로 올라오지 않았다.

그렇지만, 정상적으로 모달 돔은 생성되어 있었고 단지 translateY(120%)가 적용되어 올라오지 않고 있었다.

원인 추측

뒷 배경에 해당하는 요소에는 Style이 잘 적용되는 걸로 봐서는, openModal() 작동시 기존의 돔(업데이트가되지 않은 돔)에 접근하여 모달 창에 style 효과를 주는데, children 값이 변경되면 다시 렌더링 되면서, 스타일 효과가 적용된 돔이 날아가는 것으로 추측했다.

해결

우선 위에서 원인이 맞는지 확인하기 위해서,
setTimeout을 걸어서 openModal() 을 특정 시간 이후에 호출해보고자 했다.
다행히(?) 잘 작동하였고, 해당 로직을 뜯어 고쳐보기로 했다.

useModal에서는 뒷 배경에 해당하는 요소만 넘겨주도록 하고,
렌더링을 담당하는 ModalComponent 함수의 인자 값을 전달하게 되면,
훅 자체가 다시 호출 되지 않고 jsx를 리턴하는 함수만 호출하기 때문에 잘 작동할 것 이라 생각하였다.

기존의 코드에서 <Modal.ModalComponent modalChildren={checkData[sIdx].content} title='약관동의'/>
이런 식으로 수정해서, jsx를 리턴해주는 함수가 데이터를 받아서 렌더링 할 수 있도록 하였다.

또한 기존의 html의 id 값을 받아서 최상단에서 document.getElementById('id 값') 을 가져오는 방법에서,
함수가 호출될 때, 돔에 접근하도록 수정하였다.
ref를 직접 넘겨주는 방법도 있지만, html id를 넘겨주는 이유는 현재 구현된 방식에서 뒷 배경을 한번에 잡기 위해서는
props 를 여러 단계에 걸쳐 내려주거나, 전역 상태 관리 툴을 사용해야한다고 생각하여 그냥 뒷 배경에 해당하는 id 값을 통해 요소에 접근하도록 하였다.
(물론 상황에따라서 더 편리한 방법으로 내려 줄 수 있도록, ref 타입의 값도 받을 수 있도록 해두었다.)

또한 모달 자체에서도 모달 컨테이너를 document.getElementById('모달 창의 id') 로 값을 가져오도록 되어 있었는데,
해당 부분도 아래와 같이 직접 ref를 생성 후 내려주는(?) 방법을 통해서 혹시라도 잘못 된 돔에 접근할 가능성을 낮춰보고자 했다.
(Modal.ContainercreateElement를 통해서 생성해주기 때문에, ref를 거는 방식으로는 해당 방법이 좋을 것이라고 생각했다.)

// useModal
const $modal = useRef<HTMLDivElement>(document.createElement('div'));
const $drag = useRef<HTMLDivElement>(document.createElement('div'));
~

return(<Modal.Container modalDiv={$modal}>
   <Modal.DragHeader ref={$drag}>
   ~
)

위의 방법을 통해서 위 문제를 해결하였다.

또 다르게 접근했던 방법은 기존의 useModal 코드에서,
그냥 useModal()을 3번 호출하는 방식으로도 구현을 해보았는데,
잘 작동은 하지만 몇 가지 문제가 있었다.
1. useModal()을 3번 호출하는데, 하드코딩을 해야한다. (아래 코드 확인)
'그냥 반복문을 넣으면 안되나' 싶을 수 있지만,
훅으로 사용하기 때문에 조건에 따라 호출 될 수도 있고 안 될수 있는 상황을 만들면 안된다.
(만약 배열이 null이라면 훅은 호출되지 않는다.)

modalControl.push(
   useModal({
      title: checkData[0].title,
      children: checkData[0].content,
      backId: "sign-header",
   })
);
modalControl.push(
   useModal({
      title: checkData[1].title,
      children: checkData[1].content,
      backId: "sign-header",
   })
);
modalControl.push(
   useModal({
      title: checkData[2].title,
      children: checkData[2].content,
      backId: "sign-header",
   })
);
  1. 각각의 모달을 따로 관리해야한다.
    너무 당연한 이야기지만 위의 코드에서 확인할 수 있듯이,
    modalControl이라는 배열에 useModal()이 리턴하는 객체를 담게 된다.
    그러면, 사용시에는 아래와 같이 렌더링(과 기타 제어 함수 포함) 시켜줘야한다.
   {modalControl.map((item, idx) => (
      <item.ModalComponent key={idx} />
   ))}

위의 이유로 인하여, jsx를 리턴하는 함수에 데이터를 담아 넘기는 방법을 택했다.
다만 더 좋은 방법이 있을것 같은데, 더 찾아봐야겠다.

문제2 : 아니 너가 왜 내려가?

위 gif와 같이 모달창을 끌어내려서 당기면, touchmove이벤트가 뒤에서도 작동이 된다.
그렇기 때문에 아이폰이나 크롬 브라우져에서 아래로 쭉내려서 새로고침하는 이벤트가 트리거 된다.

원인 추측

모달창 어느 부분을 내리더라도 스크롤이 되는 것으로 봐서는
touch 이벤트 들에 문제가 있을 것이라고 생각했다.

해결

'react touchmove event parent child' 키워드로 찾아본 결과,
stopPropagationpreventDefault 라는 키워드를 확인할 수 있었다.

실제로 무지성으로 onTouchMove에서 해당 함수를 호출해보니 새로고침 되는 문제가 해결되었다.

preventDefault는 브라우져에서의 이벤트들의 기본 작동을 중단시키는 효과를 가지고 있고,
stopPropagation은 특정 이벤트가 부모 요소에 해당 이벤트가 전파 되는 것을 중단 시키는 효과가 있다.

해당 작동 방법을 찾아보니 stopPropagation는 위 문제를 해결하는데 큰 도움이 될 것 같지 않았다.
왜냐하면 '스크롤 다운 -> 새로고침'이 되는 현상은 지극히 브라우져의 기본 동작이라고 생각했기 때문이다.
(테스트 결과 preventDefault 만 있어도 정상 작동한다.)

근데, 생각해보면 스크롤 다운 이벤트가 body 영역까지 올라가는 거라면,
stopPropagation을 해줘야하는게 아닌가라는 생각이 든다.
추후에 DOM 이벤트 작동(캡쳐링, 버블링)에 관해서 정리할 때 확인해봐야겠다.

완성된 모달!

위의 gif와 같이 스크롤도 잘 작동하고 앞, 뒷 배경의 효과가 잘 작동한다!
구현된 내용은
1. 스크롤 위치에 맞는 뒷 배경 효과
2. 모달 창을 드래그로 조정
3. 열리고 닫힐때 전환(?)효과
4. 모달 창을 내리는 속도에 따라 닫히거나 열리는 기능
5. 어디까지 내렸는지에 따라서, 모달창이 열리거나 닫히는 기능
6. useModal을 호출할때, 모달이 열리는 높이 지정
7. 모달이 열린 정도에 맞춰서 조정되는 모달창의 scroll 영역

등이 있다.

코드

const Modal = useModal({backId: 'sign-header'});
~
<Modal.ModalComponent modalChildren={checkData[sIdx].content} title='약관동의'/>
// useModal
import React, {ReactNode, useCallback, useEffect, useRef} from 'react';
import { Modal } from '../components/Modal';

interface useModalProps {
  backRef?: HTMLElement;
  backId?: string;
  topMargin?: number;
}

const useModal = ({backRef, backId, topMargin }: useModalProps) => {
  // backRef 혹은 backId 가 둘 중에 하나만 있어야만 작동 가능.
  if (!backId && !backRef) throw new Error('No Dom Selected');
  if (backId && backRef) throw new Error('Two Dom Received');

  const MARGIN = topMargin || 50;

  let $back: HTMLElement | null;
  if (backRef) $back = backRef;

  const $modal = useRef<HTMLDivElement>(document.createElement('div'));
  const $drag = useRef<HTMLDivElement>(document.createElement('div'));

  useEffect(() => {
    backId && ($back = document.getElementById(backId));
    if (!$back || !$modal.current || !$drag.current) return;

    let touchStartTime: Date, touchEndTime: Date;
    let touchStartPoint = 0,
      touchEndPoint = 0;

    const onTouchStart = (e: TouchEvent) => {
      touchStartTime = new Date();
      touchStartPoint = e.touches[0].clientY;
    };

    const onTouchMove = (e: TouchEvent) => {
      if (!$back || !$modal || !$drag.current) return;

      let position = e.touches[0].clientY;
      const modalHeight = $back.offsetHeight - position;

      $back.style.touchAction = 'none';
      $back.style.overflowY = 'none';
      // (현재 스크롤 위치 / 클라이언트 높이) * 0.2
      $back.style.transform = `scale(${0.95 + (position / $back.scrollHeight) * 0.05})`;

      if ($modal.current.style.transition) $modal.current.style.transition = 'all 0s ease';
      $modal.current.style.transform = `translate3D(0px, ${position || 0}px, 0)`;
      $modal.current.style.height = `${modalHeight}px`;

      e.preventDefault();
      // e.stopPropagation();
    };

    const onTouchEnd = (e: TouchEvent) => {
      if (!$back || !$modal || !$drag.current) return;
      touchEndTime = new Date();
      touchEndPoint = e.changedTouches[0].clientY;

      if (e.changedTouches[0].clientY < MARGIN) {
        const modalHeight = $back.offsetHeight - MARGIN;
        $modal.current.style.transform = `translate3D(0px, ${MARGIN}px, 0)`;
        $modal.current.style.height = `${modalHeight}px`;
        return;
      }

      if (!touchEndTime || !touchStartTime) return;

      // @ts-ignore
      const timeInterval = touchEndTime - touchStartTime;
      const velocity = (touchEndPoint - touchStartPoint) / timeInterval;

      if (velocity > 2) return closeModal();

      if (e.changedTouches[0].clientY / $modal.current.clientHeight > 0.7) return closeModal();
      else return openModal();
    };

    $drag.current.addEventListener('touchstart', onTouchStart);
    $drag.current.addEventListener('touchmove', onTouchMove);
    $drag.current.addEventListener('touchend', onTouchEnd);

    return () => {
      $drag.current?.removeEventListener('touchstart', onTouchStart);
      $drag.current?.removeEventListener('touchmove', onTouchMove);
      $drag.current?.removeEventListener('touchend', onTouchEnd);
    };
  }, [$drag.current, $modal.current]);

  const closeModal = () => {
    backId && ($back = document.getElementById(backId));
    if (!$back || !$modal.current) return;

    $modal.current.style.transform = `translate3D(0px, 120%, 0)`;
    $modal.current.style.transition = `all 0.3s linear`;
    $modal.current.style.height = '';

    $back.style.transform = `scale(1)`;
    $back.style.borderRadius = '';
    $back.style.opacity = '';
  };

  const openModal = () => {
    console.log('openModal')
    backId && ($back = document.getElementById(backId));
    if (!$back || !$modal.current) return;

    const modalHeight = $back.offsetHeight - MARGIN;

    $modal.current.style.height = `${modalHeight}px`;
    $modal.current.style.transform = `translate(0px, ${MARGIN}px)`;
    $modal.current.style.transition = `all 0.2s linear`;

    $back.style.transition = `all 0.35s linear`;
    $back.style.transform = `scale(0.95)`;
    $back.style.borderRadius = '1rem';
    $back.style.opacity = '0.8';
  };

  const ModalComponent = useCallback(({modalChildren, title = '모달창'} : {modalChildren: ReactNode, title?: string}) => {
        console.log('ModalComponent')
        console.log($drag)
      return (
        <Modal.Container modalDiv={$modal}>
          <Modal.DragHeader ref={$drag}>
            <Modal.HeaderLeft className="invisible">...</Modal.HeaderLeft>
            <Modal.HeaderCenter>{title}</Modal.HeaderCenter>
            <Modal.HeaderRight onClose={() => closeModal()}>Done</Modal.HeaderRight>
          </Modal.DragHeader>

          <Modal.Body>{modalChildren}</Modal.Body>
        </Modal.Container>
      );
    },
    [backRef, backId],
  );

  return { ModalComponent, openModal, closeModal };
};

export default useModal;

새롭게 알게 된 내용

1. 컴포넌트를 불러올때, <Component />Component()의 차이

해당 차이를 블로그에서 답을 찾았다.

정리하면, 리액트는 우리가 함수를 호출하여 jsx를 호출하여 컴포넌트를 만드는 것인지 모른다는 것이다.
그렇기 때문에 리액트가 가지고 있는 몇몇 특성을 활용하지 못한다고 한다.

예를들어, 렌더링이 작동하는 과정에 있어서 jsx를 리턴하는 함수를 만나면 memo와 같은 최적화를 시킬 수 없다는 점도 있고,
좀 더 실질적인 예시로는 jsx를 리턴하는 함수에서는 리액트 훅을 사용하지 못한다.
(에러 참고: React Hook "React.useState" cannot be called inside a callback. React Hooks must be called in a React function component or a custom React Hook function react-hooks/rules-of-hooks)

그래서 컴포넌트를 렌더링 할때는 <Component />와 같이 렌더링 하는 것이 더 좋을 것이라고 한다.

2. addEventListener 에서 option 값의 {passive: false} 의 의미

일반적으로 내용을 찾아보면 {passive : true}은 스크롤 성능 향상을 위한 옵션이며, 스크롤을 위해 블록되는 것을 방지한다.
라고 나와있다. (무슨 소리인지 모르겠다.)

해당 내용에 대해 정리가 잘된 블로그를 참고해보면,
등록한 이벤트를 메인쓰레드에서 발생하는 어떤 결과를 기다리지 않고,
컴포지터 쓰레드에서 어떤 작업(스크롤)을 바로 해버린다는 것이다.

단, {passive: true} 인 경우에는 e.preventDefault()를 사용할 수 없다.
왜냐면, 너무 당연한 이유지만 메인 쓰레드와 독립적으로 컴포지터 쓰레드가 작동하는데,
해당 옵션을 켜두면 메인 쓰레드를 기다리지 않고 작업(합성)하기 때문이다.

3. 커스텀 훅 useModal을 통한 컴포넌트 그리는 법

이번에는 해당 내용에 대해 블로그에서 아이디어를 얻을 수 있었다.

내용은 '커스텀 훅을 통해서 일반적인 컴포넌트 분할과는 달리 컴포넌트 로직 자체를 분할하거나 재사용 할 수 있다' 라는 내용이다.

이번 프로젝트에 맞춰서 생각해보면, useModal이라는 커스텀 훅에 모달 창이 어떤식으로 열리게 될지에 대한 모든 로직과 뷰를 넣어뒀고,
필요한 경우 재활용하여 재사용하여 활용할 수 있다는 장점이 생겼다.
또한, Checks 에서는 useModal의 작동 원리(?)에 대해 파악할 필요 없이, 단순히 보여질 데이터만 내려주면 되었다.

만약 컴포넌트 분할을 통해서 모달을 구현했다면, 모달 창을 열거나 닫는 로직을 상위 컴포넌트에서 코드를 구현했어야 했을 것이다.

4. Dependency Array 의 값 비교 방법 및 useEffect에서 최적화

해당 내용을 너무 잘 설명 해둔 블로그를 참고하였다.

우선 useEffect는 다음과 같이 작동한다.
1. 초기 렌더: init Component → useEffect
2. 이후 리렌더: init Component → clean up useEffect → useEffect

여기서 리렌더 과정에서 useEffect에 있는 콜백 함수를 조건에 따라 실행을 시킬거나 그러지 않을 수도 있다.
해당 선택을 Dependency Array을 통해서 결정을 한다.

다만, 리액트의 구현을 보면 Dependency Array 의 값을 얕은비교를 통해서 진행하기 때문에,
원시값은 문제가 없지만, 객체타입이 들어갈 경우 예상대로 작동하지 않을 수 있다.

그 이유는 아래와 같이 js는 객체는 레퍼런스 주소만 가지고 있고,
리액트는 객체의 모든 속성을 하나하나 비교하지 않기 때문이다.

var a = {name: "bong"}
Object.is(a,a) // true
Object.is(a, {name: "bong"}) // false
var b = a;
Object.is(a,b) // true

그렇기 때문에, 깊은 비교를 통해서 조건을 걸어주고 싶다면
객체를 원시타입으로 변경하여 Dependency Array에 넘겨주거나,
useRef를 통해서 이전의 값과 비교하여 콜백 함수내에서 결정을 하는 방법이 있을 것이다.

그것마저 귀찮다면 이미 만들어진 라이브러리들을 사용하면 된다.

5. 이벤트 버블링과 캡쳐링

해당 개념의 차이점을 설명해둔 블로그 글들이 너무 많았다.

정리하면 이벤트가 발생했을때 자식 요소에서 부모 요소(document 객체까지)로 올라가면서 이벤트들을 작동시키는 것을 '버블링' 이라고 하며,
반대로 최상위 요소에서 이벤트가 발생한 요소까지 내려오는 상황에서 등록된 이벤트를 실행시키며 내려오는 것을 '캡쳐링'이라고 한다.
단, '캡쳐링'의 경우에는
div.addEventListener('click', logEvent, {capture: true});와 같이 옵션 값을 넣어줘야 한다.

6. stopPropagation()를 통해 특정 요소에만 이벤트 붙이기

const onClickEvent = (e) => {
	e.stopPropagation();
	console.log(e.currentTarget.className);
}

와 같이 이벤트 콜백 함수를 등록해둔다면 버블링의 경우에는 위 요소로 이벤트가 전파되지 않을 것이며,
캡쳐링이 설정되어 있다면, 최상위 요소만 작동되고 하위 요소의 이벤트들이 작동하지 않을 것이다.

7. 이벤트 위임

이번 프로젝트에서 사용된 내용은 아니지만, 이벤트 캡쳐링과 버블링과 세트 같은 개념이라서 함께 찾아봤다.
해당 내용은 이미 매우 유명한 javascript.info에서 참고하였다.

정리하면, 특정 부모요소에서 가변적이거나 다수의 자식요소에서 이벤트를 한번에 붙일 수 있는 개념이다.

이벤트 버블링을 활용한다면 자식 요소에서 올라온 이벤트를 부모가 캐치할 수 있고,
event.target을 통해서 어느 요소에서 온 이벤트인지 파악할 수 있는 방법을 활용하여
이벤트를 처리하는 방식이다.

-- 끗 --

profile
안녕하세요🙂

4개의 댓글

comment-user-thumbnail
2022년 8월 10일

우와.. 정리 잘되어있어서 감탄하고 갑니다..

1개의 답글
comment-user-thumbnail
2022년 8월 11일

우와.... 참고하겠습니다 감사합니다

1개의 답글