ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • iOS 디바이스에서 body의 scroll을 막는 방법 (How to prevent body scrolling on iOS?)
    Frontend 2020. 6. 1. 00:02

     

     

     

    지난 주부터 회사에서 해결이 안되서 골머리를 앓았던 문제에 대해서 얘기해보려고 한다.

     

    보통의 UI에서 대부분 모달(팝업이라고도 부른다)이 뜨면

    모달 뒤에 body 영역을 반투명한 검정색 레이어로 덮어서 모달의 컨텐츠가 더 도드라지게 만든다.

    이 반투명한 검정색 영역을 주로 Dim 영역이라고 부른다.

     

    보통 팝업창 내에 컨텐츠가 길어서 스크롤이 있는 경우에는

    팝업 내부에만 스크롤이 잘 되게 하기 위해서 Dim 영역 뒤에 있는 body의 scroll은 막는 경우가 많다.

     

    그리고 웹에서는 scroll 막는 것도 쉽게 처리할 수 있다.

    바로 팝업이 떴을 때 body 태그에 overflow: hidden을 걸어줘서 scroll을 못하게끔 막아버리는 방법이다.

     

     

     

    1. body 태그, overflow: hidden;

    코드 예시를 보면 다음과 같다.

    나는 주로 이런 기능은 Higher Order Component(HOC)를 사용해서 만드는 것을 좋아한다.

     

    HOC로 만들면 Scroll Lock 기능이 필요한 컴포넌트를 리턴할 때

    HOC로 감싸서 리턴하는 방식으로 쉽게 기능을 적용할 수 있어서 편리하기 때문이다.

     

    export const withScrollLock = <P extends {}>(
      Feature: React.FC<P>,
    ): React.FC<P> => (props: P) => {
        const body = document.querySelector('body') as HTMLElement;
        
        useEffect(() => {
          body.style.overflow = 'hidden';
          
          return () => {
            body.style.removeProperty('overflow');
          };
        }, []);
    
        return <Feature { ...props } />;
      };
    

     

    위와 같은 HOC를 만든 다음,

     

    import { withScrollLock } from '@/helpers';
    
    const Modal = () => {
      // ...
    };
    
    export default withScrollLock(Modal);

     

    요렇게 사용하면 된다.

     

    이제 이 Modal 컴포넌트는 화면에 보여지는 즉시, body 태그에 overflow: hidden 속성을 적용시켜

    body의 스크롤을 막아버릴 것이다.

     

     

     

    그런데 문제는 바로 이 방식이 모바일 safari 브라우저에서 안된다는 것이다. 또르르.. 😭.

    모바일 safari에서 안된다는 말은 iOS 웹뷰에 적용할 수 없다는 말이다...ㅜㅜ

    그래서 iOS 디바이스에도 적용할 수 있는 새로운 방법을 찾아 나섰는데 처음 찾아낸 방법은 이거였다.

     

     

     

    2. pointer-events: none;

    export const withScrollLock = <P extends {}>(
      Feature: React.FC<P>,
    ): React.FC<P> => (props: P) => {
        const body = document.querySelector('body') as HTMLElement;
        const scrollPosition = window.pageYOffset;
    
        useEffect(() => {
          body.style.overflow = 'hidden';
          body.style.pointerEvents = 'none';
    
          return () => {
            body.style.removeProperty('overflow');
            body.style.removeProperty('pointer-events');
          };
        }, []);
    
        return <Feature { ...props } />;
      };

     

    pointer-events라는 CSS 속성을 이용한 방법이다.

    pointer-events 속성은 특정 element의 트리거 역할을 설정한다. 이 속성은 none을 적용하면 이벤트 핸들러가 등록된 상태의 element의 이벤트 트리거로서의 역할을 강제로 막을 수도 있다(!)

     

    그리고 놀랍게도 위 방법으로 잘 해결되는 듯 보였다.!

    최신 디바이스가 아닌 예전 iOS 기기의 오래된 OS 버전으로 테스트해보기 전까지는...

     

    최신 OS 에서는 위 방법으로 잘 되었으나 12 버전 이하 기기로 테스트해보니 제대로 기능을 하지 못했다...

     

     

     

    3. touchmove eventListener

    그 다음 고심해본 방법은 touchmove 이벤트 리스너를 달아서 e.preventDefault();로 기본 터치 동작을 막아버리는 것이었다.

     

    export const withScrollLock = <P extends {}>(
      Feature: React.FC<P>,
    ): React.FC<P> => (props: P) => {
        const body = document.querySelector('body') as HTMLElement;
        const lockScroll = e => e.preventDefault();
    
        useEffect(() => {
          body.addEventListener('touchmove', lockScroll, { passive: false });
          body.style.overflow = 'hidden';
    
          return () => {
            body.removeEventListener('touchmove', lockScroll, { passive: false });
            body.style.removeProperty('overflow');
          };
        }, []);
    
        return <Feature { ...props } />;
      };

     

    이 방법은 아주 확실하게 모든 iOS 디바이스에서 완벽하게 스크롤을 차단하지만 치명적인 문제가 있다.

    바로 모달 창 안의 child element의 스크롤도 전부 막아버린다는 것이다..!

     

    그러니까 body 태그의 touchmove 기본 동작을 막아버리는데 event 버블링, 캡쳐링에 의해

    하위 요소들의 touchmove 까지 막힌다는 문제가 있다.

     

    모달 내부 컨텐츠 길이가 길어서 스크롤이 필요한 경우에 모달 내부 컨텐츠의 스크롤이 작동하지 않게 된다.

     

     

     

    4. body-scroll-lock npm package

    사실 이 npm 패키지를 다운받아 사용하는 것이 가장 쉬운 방법일 것이다.

    모든 디바이스에서 Scroll Lock을 완벽히 적용할 수 있도록 도와주는 패키지이고 TypeScript도 지원된다.

     

    import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock';
    
    export const withScrollLock = <P extends {}>(
      Feature: React.FC<P>,
    ): React.FC<P> => (props: P) => {
        const body = document.querySelector('body') as HTMLElement;
    
        useEffect(() => {
          disableBodyScroll(body);
    
          return () => {
            enableBodyScroll(body);
          };
        }, []);
    
        return <Feature { ...props } />;
      };

     

    사용 방법도 이렇게 간단하다. 그런데 이 패키지도 body 하위 요소들의 스크롤을 막아버린다는 문제가 여전히 있다.

    docs를 읽어보면 allowTouchMove라는 속성을 통해 특정 요소의 스크롤은 허용하는 옵션이 있는데,

    예제와 동일하게 적용했는데도 하위 iOS 디바이스에서 제대로 동작하지 않았다.

    내가 잘 못 적용한 걸수도 있는데 일단 촉박한 시간 내에 잘 해결이 안되어 이 패키지는 패쓰!

     

     

     

    5. position: fixed;

    돌고 돌아서 이 문제를 가지고 밤 늦게까지 야근하면서 별의 별 방법을 다 시도해보다가

    결국 최종 해결법으로 채택한 방법은 다음과 같다.

     

    export const withScrollLock = <P extends {}>(
      Feature: React.FC<P>,
    ): React.FC<P> => (props: P) => {
        const body = document.querySelector('body') as HTMLElement;
        const scrollPosition = window.pageYOffset;
    
        useEffect(() => {
          body.style.overflow = 'hidden';
          body.style.pointerEvents = 'none';
          body.style.position = 'fixed';
          body.style.top = `-${scrollPosition}px`;
          body.style.left = '0';
          body.style.right= '0';
    
          return () => {
            body.style.removeProperty('overflow');
            body.style.removeProperty('pointer-events');
            body.style.removeProperty('position');
            body.style.removeProperty('top');
            body.style.removeProperty('left');
            body.style.removeProperty('right');
    
            window.scrollTo(0, scrollPosition);
          };
        }, []);
    
        return <Feature { ...props } />;
      };

     

    body 태그에 position: fixed; 속성을 주는 것이다!

    이 방법의 문제점은 혹시 모를 UI 부작용이 있을 수 있다는 것인데,

    일단 적어도 내가 작업하는 UI에서는 아무 문제 없이 완벽하게 스크롤이 차단되었다.

     

    다만, body 태그에 position: fixed를 걸기 때문에

    사용자가 body 스크롤을 어느 정도 내린 상태에서 모달 창을 켰을 때

    body 스크롤이 맨 위로 가버린다는 문제가 있었다.

     

    이 방법을 해결하기 위해 window.pageYOffset 속성을 이용해서

    모달 창이 화면에 보여진 시점의 스크롤 위치를 기억해두고,

    top 속성에 음수 값으로 적용한다. 그러면 그 스크롤 위치에 고정된 상태로 보여진다.

     

    그리고 모달 창이 꺼졌을 때에도 여전히 그 위치에 스크롤이 된 상태여야 하기 때문에

    window.scrollTo() 메소드를 이용해 스크롤 위치를 이동시킨다.

     

    이 방법이 현재까지 내가 알아 낸 여러 방법들 중에서 가장 완벽하게 모든 디바이스에서 Scroll Lock을 주는 방법이다.

    반응형

    COMMENT