본문 바로가기
프론트엔드

[JS]스와이프 기능을 만들어보며.

by ISA(류) 2022. 7. 2.

바닐라 js로 다양한 기능을 간단히 만들어보는 js-util 프로젝트의 10번째 모듈로 간단한 스와이프 슬라이드를 만들어 보았다.

https://github.com/yoonjonglyu/js-util

 

GitHub - yoonjonglyu/js-util: html, css, js, vanillajs, animation, module & util

html, css, js, vanillajs, animation, module & util - GitHub - yoonjonglyu/js-util: html, css, js, vanillajs, animation, module & util

github.com

회사에서 자주 쓰이는 프론트엔드 기능중 하나로 슬라이드 기능이 없는 스와이프 기능이 있다. 해당 기능을 구현할때 목적에 적합하고 적절한 오픈소스가 없어서 직접 리액트에서 하드코딩으로 구현하였는데 해당 기능을 조금 더 심플하게 구현해보고자 바닐라 js로 구현해보았다. 착오로 인해서 스와이프 방향은 모션과 반대로 또 슬라이드의 경우 단순하게 단방향일정 주기로 계속 반복되는 방식으로 간단히 구현하였는데. 생각보다 흥미로운 점이 많아서 따로 관련한 패키지를 만들어서 배포를 해 볼 생각이다.

 

스와이프라는 것은 스마트폰 조작 방법에서 온 용어인데 한 손가락을 화면에 터치한 상태에서 수평이나 수직 방향으로 움직이는 제스처다. 사선 방향은 인정 되지 않고 여러 손가락의 경우 멀티 스와이프라는 제스처로 구분 하는 움직인데 스마트폰이 보급된 요즘에는 보편적으로 자주 쓰이는 이벤트 방식이기도 하다. 손가락을 따라서 움직이는 화면이라는 직관적인 이벤트와 플립 액션과 결합해서 적은 동작으로 손쉽게 원하는 목적을 입력 할 수 있다는 장점을 가진다.

 

해당 프로젝트의 핵심은 해당 스와이프 기능을 구현하는데 있는데 정확히는 스와이프와 플립을 통한 슬라이드 전환이 핵심이다. 그리고 스와이프 기능의 동작 모션에서 CSS 애니메이션을 이용하는데 그런 특징에 의해서 dom 렌더링에 제한이 있는 점이 있다. 먼저 스와이프 동작을 통해서 전환된 슬라이드 구조를 DOM을 만들어 줄 필요가 있다.

function SlideComponent() {
  const SlideWrap = () => {
    const Node = document.createElement('article');
    setStyle(Node, {
      'width': '360px',
      margin: '0 auto',
      overflow: 'hidden',
      border: '1px solid',
    });

    return Node;
  };
  const SlideContainer = (itemLength) => {
    const Node = document.createElement('ul');
    setStyle(Node, {
      display: 'flex',
      width: `${itemLength * 360}px`,
      margin: '0 auto',
      padding: 0,
      'list-style': 'none',
    });
    const Style = document.createElement('style');
    setGlobalStyle(`@media screen and (min-width: 500px) {
      article {
        width: 720px !important;
      }
      ul {
        width: ${itemLength * 720}px !important;
      }
      li {
        width: 720px !important;
      }
    }`);
    document.querySelector('head').appendChild(Style);

    return Node;
  };
  const SlideItem = (text) => {
    const Node = document.createElement('li');
    Node.innerText = text;
    setStyle(Node, {
      width: '360px',
      height: '300px',
      border: 'tomato 1px solid',
      'text-align': 'center',
    });

    return Node;
  };

  return {
    SlideWrap,
    SlideContainer,
    SlideItem,
  };

  function setStyle(node, style) {
    for (const [key, value] of Object.entries(style)) {
      node.style[key] = value;
    }
  }
  function setGlobalStyle(style) {
    const Style = document.createElement('style');
    Style.innerHTML = style;
    document.querySelector('head').appendChild(Style);
  }
}

간단히 바닐라 js를 통해서 기본적인 구조와 스타일을 잡아주었다. 어차피 중요한 부분은 아니다 보니 최소한도의 요구사항을 간단하게 구현해본 내용이다. (스타일이나 dom을 조금 더 세련되게 컨트롤 할 수 있다.)

별다른 내용은 없고 슬라이드를 감싸줄 wrap(잘라줄 액자)과 그 슬라이드 아이템들을 담을 container을 구현한다. 그리고 그 container안에 들어갈 item을 구현한다. 편의를 위해서 360px, 720px 두가지 크기만을 고정적으로 지원하기로 했다.

 

스와이프가 될 최소한의 DOM과 CSSOM을 만들었으니 이제 스와이프 이벤트를 만들어 볼 차례다. 스와이프 이벤트의 경우 모바일과 PC 라는 플랫폼의 조작 방식의 차이로 인해서 조금 고려해야 할 부분이 조금 존재한다. 보통 비슷한 종류의 액션들이 서로 존재하기에 마우스와 터치펜이나 손가락이라는 인터페이스의 차이 정도를 제외하고는 사용자 입장에서 큰 차이를 느낄 수 없지만 실제로 개발하는 입장에서는 엄연히 서로 다른 인터페이스에서 서로 다른 동작을 인식하다보니 양쪽 플랫폼 모두를 고려해야하는데.

 

특히 PC에서 적용되는 이벤트와 모바일에서 적용되는 이벤트가 서로 중복된다거나 한쪽이 다른 쪽에서는 다른 동작이 되는 경우가 있어서 관련해서는 조금 더 신중히 접근할 필요가 있다는 교훈을 새삼 느꼈다. 하드코딩으로 구현한 후 문제점이 발견되어서 개인적으로는 기존의 메이저한 오픈소스의 도움을 조금 받았는데 결론적으로 말하면 PC에서 스와이프 이벤트의 경우 pointer 이벤트를 통해서 구현하고 모바일에서는 touch 이벤트를 통해서 구현하는 방법을 통해서 구현하는게 정석적인 방법이다.

 

스와이프 이벤트의 경우 사용자 액션에 따라서 DOM이 CSS를 통해서 애니메이션 되는 것을 보여줘야하는데 그러다보니 자연히 리렌더링이 제한된다.(그래픽 효과가 생략 되기 때문) 그리고 현재 위치와 해당 모션의 이동을 보여주기 위해서 위치값이 최소한 2개는 요구되는데 동일한 공간의 영역이 전환되는 방식이기 때문에 인터페이스 상으로는 동일한 뷰포트를 통해서 해당 영역보다 넓은 길이를 가진 DOM을 조작해야하기 때문인데 페이지 전환이 일어난다면 처음 터치가 일어난 위치 값을 제외하고는 필요가 없을 것으로 보인다.

 

개인적으로는 편의를 위해서 터치 초기 위치, 해당 슬라이드의 순서, 해당 슬라이드 순서에 맞는 위치 등 3가지 좌표를 통해서 위치를 계산했고, PC에서의 잘못된 스와이프 이벤트를 방지하기 위해서 터치 와 이동 그리고 터치 종료 간의 불리언 플래그를 하나를 조건으로 줘서 스와이프 제한을 줬다.

 

또 스와이프에서 자주 쓰이는 플립 액션의 경우 스와이프와 동일하지만 액션을 인식하는 동작시간이 짧은 편이라서 점이 차이점이라 동작에 걸리는 시간을 저장하는 변수를 하나줬다. 아래의 코드와 같다. 

function SwipeSlide(Container, itemLength) {
  let isSwipe = false;
  let initOffset = 0;
  let currentStep = 0;
  let currentOffset = 0;
  let swipeTime = 0;

  /**
   * @description 스와이프 기능(플립액션)과 리사이즈 관련 된 로직들
   */
  const handleStart = (x) => {
    isSwipe = true;
    initOffset = x;
    swipeTime = Date.now();
  };
  const handleMove = (x) => {
    if (isSwipe) {
      const offset = initOffset - x - currentOffset;
      Container.style.transition = 'none';
      Container.style.transform = `translateX(${offset}px)`;
    }
  };
  const handleEnd = (x) => {
    if (isSwipe) {
      const viewport = window.innerWidth > 500 ? 720 : 360;
      const offset = initOffset - x;
      if (Math.abs(offset) >= viewport / 2 || Date.now() - swipeTime < 200) {
        if (offset > 0 && currentStep > 0) {
          currentStep--;
          currentOffset = currentStep * viewport;
        } else if (offset < 0 && currentStep < itemLength - 1) {
          currentStep++;
          currentOffset = currentStep * viewport;
        }
      }
      Container.style.transition = '400ms';
      Container.style.transform = `translateX(-${currentOffset}px)`;
      isSwipe = false;
      swipeTime = 0;
    }
  };
  const handleResize = () => {
    const viewport = window.innerWidth > 500 ? 720 : 360;
    currentOffset = currentStep * viewport;
    Container.style.transition = '0';
    Container.style.transform = `translateX(-${currentOffset}px)`;
  };
  /**
   * @description 간단한 자동 슬라이드 기능
   */
  const handleSlide = (time) => {
    return setInterval(() => {
      currentStep < itemLength - 1 ? currentStep++ : (currentStep = 0);

      const viewport = window.innerWidth > 500 ? 720 : 360;
      currentOffset = currentStep * viewport;
      Container.style.transition = '500ms';
      Container.style.transform = `translateX(-${currentOffset}px)`;
    }, time);
  };

  return {
    desktopStart: (e) => {
      handleStart(e.pageX);
    },
    desktopMove: (e) => {
      handleMove(e.pageX);
    },
    desktopEnd: (e) => {
      if (!/iPhone|iPad|Android/g.test(navigator.userAgent)) handleEnd(e.pageX);
    },
    mobileStart: (e) => {
      handleStart(e.touches[0].pageX);
    },
    mobileMove: (e) => {
      handleMove(e.targetTouches[0].pageX);
    },
    mobileEnd: (e) => {
      handleEnd(e.changedTouches[0].pageX);
    },
    resize: (e) => {
      handleResize();
    },
    slide: (time) => {
      handleSlide(time);
    },
  };
}

동작 자체는 간단한데 사용자가 처음 포인터(터치)한 위치를 저장하고, 사용자 이동에 따라서 화면이 따라가다가 그 터치가 종료 되었을때 플립액션이나 스와이프 액션 각각의 조건에 맞게 슬라이드 전환을 시켜주면 되었다. 다만 pointer(PC)와 touch(모바일)액션의 경우 서로 충돌 하는 케이스가 있어서 PC와 모바일에 따라서 서로간의 이벤트를 다르게 걸어야지 정상적으로 원하는 동작을 볼 수 있는데 개인적으로 해당 소스에서 그렇게까지 이벤트를 관리하기는 불필요하다 느껴서 그냥 동작만 막았다. 리사이즈 이벤트의 경우도 리사이즈 옵저버를 사용하는게 조금 더 좋지만 간단히 window resize이벤트를 걸었고, 슬라이드의 경우도 간단히 단방향으로 계속 흐르게 된다.

function Main() {
  const Root = document.querySelector('#app');
  const SlideItems = [1, 2, 3, 4, 5];

  const Components = new SlideComponent();

  const Wrap = Components.SlideWrap();
  const Container = Components.SlideContainer(SlideItems.length);
  addEvents(Wrap, Container, SlideItems);
  const Items = SlideItems.map((item) => Components.SlideItem(item));

  for (const item of Items) {
    Container.appendChild(item);
  }
  Wrap.appendChild(Container);
  Root.appendChild(Wrap);

  function addEvents(Wrap, Container, items) {
    const SwipeEvents = new SwipeSlide(Container, items.length);
    SwipeEvents.slide(3000);

    Wrap.addEventListener('touchstart', SwipeEvents.mobileStart);
    Wrap.addEventListener('touchmove', SwipeEvents.mobileMove);
    Wrap.addEventListener('touchend', SwipeEvents.mobileEnd);
    Wrap.addEventListener('touchcancel', SwipeEvents.mobileEnd);

    Wrap.addEventListener('pointerdown', SwipeEvents.desktopStart);
    Wrap.addEventListener('pointermove', SwipeEvents.desktopMove);
    Wrap.addEventListener('pointerup', SwipeEvents.desktopEnd);
    Wrap.addEventListener('pointerleave', SwipeEvents.desktopEnd);

    window.addEventListener('resize', SwipeEvents.resize);
  }
}

Main();

관련 링크

https://yoonjonglyu.github.io/js-util/swipeSlide/

 

Swipe Slide

 

yoonjonglyu.github.io

https://github.com/nolimits4web/swiper

 

GitHub - nolimits4web/swiper: Most modern mobile touch slider with hardware accelerated transitions

Most modern mobile touch slider with hardware accelerated transitions - GitHub - nolimits4web/swiper: Most modern mobile touch slider with hardware accelerated transitions

github.com

 

반응형

'프론트엔드' 카테고리의 다른 글

[React]React-dom 과 render  (0) 2022.07.17
프론트엔드 설계 고민 -5-  (0) 2022.07.10
[React] React-dom에서의 innerHTML  (0) 2022.05.31
프론트엔드 설계 고민 -4-  (0) 2022.05.21
프론트엔드 설계 고민 -3-  (0) 2022.04.26