본문 바로가기
개발/오픈소스

[React] react-custom-swipe 1.2 버전업 후기

by ISA(류) 2023. 9. 27.

최근에 흥미를 위해서 간략하게 개발하고 방치해둔 패키지인 react-custom-swipe의 버그들을 수정한 후 리팩토링 및 기능추가를 진행했다. 개인적인 흥미로 개발을 진행했던 패키지이다보니 패키지 개발 진행중 흥미를 상실하여서 기획만 해두고 방치해둔 기능들 그리고 알고 있으나 역시 귀찮아서 미뤄두었던 부분들을 해당 패키지를 지속적으로 관리하기로 결정하고 약 하루 정도 시간을 들여서 리팩토링을 진행후 기획했던 기능들을 개발완료했다.

 

이 글은 그 과정에서 소소하게 느낀부분이나 변경점들에 대한 간략한 기록이다.
이미 기존에 개발된 swipe나 slide 관련한 플러그인이나 라이브러리는 상당히 많은 편이다.
슬라이드나 스와이프 기능 자체가 dom 조작을 통해서 구현하는 요소다 보니 기존의 slide나 swipe 플러그인들은 spa 컴포넌트 특유의 리렌더링에 대한 고려나 특정 상황에(내부 아이템이 추가된다거나) 적합하지 않은 부분들이 있었는데 그런 부분을 간단하게 커스텀 할 수 있도록 간단한 swipe 기능을 중점으로 새로운 패키지를 하나 개발하면 좋지 않을까 라는 생각으로 개발을 진행하게 되었다.

 

개인적으로 기존에 swipe 기능이나 slide 구현에 대해서 진지하게 고민을 해보지 않고 진행을 하다보니 하드코딩으로 package가 구성될 수 밖에 없었고 특정 라이브러리에 종속성을 가지도록 결합도가 높은 코드를 작성하게 되었는데

관심사 분리를 통해서 결합도를 낮추고 중복코드를 정리하고 코드들을 리팩토링하여서 프로그램 안정성을 높일 필요가 있었다. 사용자가 거의 없는 해당 프로젝트가 그런 리소스를 투자하기에 적합한지 애매하다보니 최근에 패키지들을 제대로 관리해서 정리해보자는 생각이 들기 전까지는 방치해뒀었다.

 

react-custom-swipe의 경우 react custom hook과 swipe 라는 react 컴포넌트 두가지 구성으로 swipe 로직을 제공한다. 사실 제공하는 방식이 react기반이다보니 react와 결합도가 필연적으로 높을 수 밖에 없었는데 swipe 기능 자체는 SPA앱 전의 제이쿼리 기반의 구버전 웹사이트에서부터 개발된 기능이다 보니 해당 로직들이 react에 종속성을 가질 필요는 없었다. 그래서 swipe 로직들을 따로 core라는 부분으로 추출하여서 정리하고 추후 다른 파생 라이브러리나 프레임워크들에 사용하기 위해서 패키지화를 해뒀다. package 이름은 swipe-core로 하고 싶었으나 이미 선점자가 있는 관계로 swipe-core-provider로 지었다.

https://www.npmjs.com/package/swipe-core-provider?activeTab=readme 

 

swipe-core-provider

isa, swipe,core. Latest version: 1.0.4-0, last published: 17 hours ago. Start using swipe-core-provider in your project by running `npm i swipe-core-provider`. There is 1 other project in the npm registry using swipe-core-provider.

www.npmjs.com

swipe-core-provider dir


core의 구성은 관련한 유틸 함수들을 묶은 checkUserAgent, uri 그리고 swipe 상태를 모델링하고 관리하는 state, swipe 이벤트 처리에 필요한 데이터를 정리하는 swipeData, swipe 이벤트로 직접적으로 dom을 제어하는 swipeEvents, swipe이벤트는 아니지만 반응형이나 리렌더링등 프론트엔드 환경에 따라 필요한 resize,init,history 등의 otherEvents 그리고 그것들을 잘 정리해서 외부로 제공해주는 provider로 구성을 나누었다.

 

swipeState의 경우 기존의 일반 객체로 여기저기서 난잡하게 건드리던 상태를 class 문법과 get set를 사용해서 어느 정도 아래와 같이 정리해주었고

/** state.ts **/
export interface SwipeStateProps {
  isSwipe: 'pending' | 'wait' | 'disable';
  startX: number;
  startY: number;
  currentX: number;
  currentStep: number;
  swipeTime: number;
}

class SwipeState implements SwipeStateProps {
  private _isSwipe: 'pending' | 'wait' | 'disable';
  private _startXY: { x: number; y: number };
  private _current: {
    currentX: number;
    currentStep: number;
    swipeTime: number;
  };
  private _itemLength: number;

  constructor(itemLength: number) {
    this._isSwipe = 'wait';
    this._startXY = { x: 0, y: 0 };
    this._current = { currentX: 0, currentStep: 0, swipeTime: 0 };
    this._itemLength = itemLength;
  }

  get isSwipe() {
    return this._isSwipe;
  }
  get startX() {
    return this._startXY.x;
  }
  get startY() {
    return this._startXY.y;
  }
  get currentX() {
    return this._current.currentX;
  }
  get currentStep() {
    return this._current.currentStep;
  }
  get swipeTime() {
    return this._current.swipeTime;
  }
  set currentX(value: number) {
    this._current.currentX = value;
  }
  set currentStep(value: number) {
    if (value >= 0 && value < this._itemLength)
      this._current.currentStep = value;
  }
  startSwipe(x: number, y: number) {
    this._startXY.x = x;
    this._startXY.y = y;
    this._isSwipe = 'pending';
    this._current.swipeTime = Date.now();
  }
  endSwipe(currentX: number, disableTime: number) {
    this._current.swipeTime = 0;
    this._isSwipe = 'disable';
    this._current.currentX = currentX;
    setTimeout(() => (this._isSwipe = 'wait'), disableTime);
  }
}

export default SwipeState;

swipeEvents는 Events 처리에 필요한 데이터와 실제로 그 데이터와 상태를 이용해서 이벤트 처리하는 로직을 아래와 같이 정리했다.

/** swipeData.ts **/
import { SwipeStateProps } from './state';

export const getStart = (e: Partial<TouchEvent & MouseEvent>) => {
  const x = e.targetTouches ? e.targetTouches[0].pageX : e.pageX || 0;
  const y = e.targetTouches ? e.targetTouches[0].pageY : e.pageY || 0;
  return { x, y };
};
export const getMove = (
  e: Partial<TouchEvent & MouseEvent>,
  swipeState: SwipeStateProps,
) => {
  const x = e.targetTouches ? e.targetTouches[0].pageX : e.pageX || 0;
  const y = e.targetTouches ? e.targetTouches[0].pageY : e.pageY || 0;
  const offset = x - swipeState.startX - swipeState.currentX;

  return { x, y, offset };
};
export const getEnd = (
  e: Partial<TouchEvent & MouseEvent>,
  swipeState: SwipeStateProps,
) => {
  const x = e.changedTouches ? e.changedTouches[0].pageX : e.pageX || 0;
  const y = e.changedTouches ? e.changedTouches[0].pageY : e.pageY || 0;
  const offset = swipeState.startX - x;
  return { x, y, offset };
};
/** swipeEvents.ts **/
import SwipeState from './state';
import { getStart, getMove, getEnd } from './swipeData';

export const swipestart = (
  e: Partial<TouchEvent & MouseEvent>,
  swipeState: SwipeState,
) => {
  if (swipeState.isSwipe === 'wait') {
    const { x, y } = getStart(e);
    swipeState.startSwipe(x, y);
  }
};
export const swipeMove = (
  e: Partial<TouchEvent & MouseEvent>,
  swipeState: SwipeState,
  target: HTMLElement,
) => {
  if (swipeState.isSwipe === 'pending') {
    const { x, y, offset } = getMove(e, swipeState);
    if (Math.abs(swipeState.startY - y) < Math.abs(swipeState.startX - x)) {
      target.style.transition = 'none';
      target.style.transform = `translateX(${offset}px)`;
    }
  }
};
export const swipeEnd = (
  e: Partial<TouchEvent & MouseEvent>,
  swipeState: SwipeState,
  target: HTMLElement,
) => {
  if (swipeState.isSwipe === 'pending') {
    const { x, y, offset } = getEnd(e, swipeState);

    if (
      (Math.abs(offset) >= target.clientWidth / 2 ||
        Date.now() - swipeState.swipeTime < 200) &&
      Math.abs(swipeState.startY - y) < Math.abs(swipeState.startX - x)
    ) {
      offset < 0 ? swipeState.currentStep-- : swipeState.currentStep++;
    }

    swipeState.endSwipe(
      swipeState.currentStep * parseFloat(getComputedStyle(target).width),
      333,
    );
    target.style.transition = '333ms';
    target.style.transform = `translateX(-${swipeState.currentX}px)`;
  }
};

아직 부족하긴하지만 관심사가 적절히 분리되어서 코드의 가독성이 한결 좋아진 것을 확인 할 수 있다. otherEvents의 경우 config등 공유하는 args가 많은 관계로 클래스로 묶었지만 swipe 이벤트 자체는 start,move,end 3가지 이벤트에 대응하는 부분만 있다보니 함수만으로 처리했고 관심사에 따라서 분리한 로직들을 한데 묶어서 외부 파생 패키지에 제공할 provider의 경우 아래와 같이 설정에 필요한 config props를 받고 패키지 기능을 구성하는데 필요한 events들을 정리해서 반환해준다.

/** provider.tsx **/
import SwipeState from './state';
import { swipestart, swipeMove, swipeEnd } from './swipeEvents';
import OtherEvents, { ConfigProps } from './otherEvent';
import { checkMobile } from './checkUserAgent';

export default function SwipeProvider<T extends HTMLElement>(
  itemLength: number,
  config?: ConfigProps,
) {
  const index = config?.paramName ? config.paramName : 'index';
  const swipeState = new SwipeState(itemLength);
  const otherEvents = new OtherEvents(swipeState, index, config);

  return {
    desktopStart: (e: MouseEvent) => {
      if (!checkMobile()) swipestart(e, swipeState);
    },
    desktopMove: (e: MouseEvent, Container: T) => {
      if (!checkMobile()) swipeMove(e, swipeState, Container);
    },
    desktopEnd: (e: MouseEvent, Container: T) => {
      if (!checkMobile()) {
        swipeEnd(e, swipeState, Container);
        otherEvents.changeHistory();
      }
    },
    mobileStart: (e: TouchEvent) => {
      swipestart(e, swipeState);
    },
    mobileMove: (e: TouchEvent, Container: T) => {
      swipeMove(e, swipeState, Container);
    },
    mobileEnd: (e: TouchEvent, Container: T) => {
      swipeEnd(e, swipeState, Container);
      otherEvents.changeHistory();
    },
    resize: (Container: T) => otherEvents.resize(Container),
    init: (Container: T) => otherEvents.init(Container),
    slidehandler: (flag: 'L' | 'R', Container: T) =>
      otherEvents.slide(flag, Container),
    changeIndex: (index: number, Container: T) =>
      otherEvents.changeIndex(index, Container),
  };
}

위와같이 swipe-core-provider를 분리해낸 react-custom-swipe의 경우 기존에 기획했던 historypush에 의한 history back이나 go를 swipe가 추적하지 못하는 이슈가 있어서 해당 부분을 수정하고 간단한  carouselUI와 커스텀훅을 통해서 직접 carousel이나 slide를 구축할 수 있도록 기능을 추가하고 inline-style 등의 구조를 리팩토링 하였는데 그러다보니 자연히 버전을 1.2까지 올렸다. 

https://www.npmjs.com/package/react-custom-swipe

 

react-custom-swipe

react custom swipe as components and Hooks. Latest version: 1.2.0, last published: 16 hours ago. Start using react-custom-swipe in your project by running `npm i react-custom-swipe`. There are no other projects in the npm registry using react-custom-swipe.

www.npmjs.com

 

react-custom-swipe dir

swipe 컴포넌트의 경우 기존에 임시로 inline-style로 처리하던 부분들을 따로 style.css로 분리해주었고 Carousel컴포넌트를 추가하여서 구성요소인 dot과 button을 추가한후 config에 isCarousel플래그를 추가해서 기본적인 캐로셀 구성을 사용자가 선택가능하도록 하였다. hook의 경우 기존의 단순한 eventshandler 들을 반환해주던 것에서 슬라이드를 구성하는데 필요한 handleSlide API와 목표로한 item index로 바로 swipe 가능하게 해주는 changeIndex API를 추가해주었다.

/** useSwipe.ts **/
import React, { useEffect } from 'react';

import SwipeProvider, { ConfigProps } from 'swipe-core-provider';

export interface UseSwipe<T> {
  swipeEvents: UseSwipeEvents<T>;
  handleSlide: (flag: 'L' | 'R') => void;
  changeIndex: (index: number) => void;
}

interface UseSwipeEvents<T> {
  onTouchStart: React.TouchEventHandler<T> | undefined;
  onTouchMove: React.TouchEventHandler<T> | undefined;
  onTouchEnd: React.TouchEventHandler<T> | undefined;
  onTouchCancel: React.TouchEventHandler<T> | undefined;
  onPointerDown: React.MouseEventHandler<T> | undefined;
  onPointerMove: React.MouseEventHandler<T> | undefined;
  onPointerUp: React.MouseEventHandler<T> | undefined;
  onPointerLeave: React.MouseEventHandler<T> | undefined;
  onPointerCancel: React.MouseEventHandler<T> | undefined;
}

export default function useSwipe<T extends HTMLElement>(
  dom: React.RefObject<T>,
  length: number,
  config?: ConfigProps,
): UseSwipe<T> {
  const Events = SwipeProvider(length, config);

  useEffect(() => {
    const initCb = () => Events.init(dom.current as T);
    let init: any;
    if (!config?.isHistory) {
      init = setTimeout(initCb, 0);
    } else init = setInterval(initCb, 10);
    return () =>
      !config?.isHistory ? clearTimeout(init) : clearInterval(init);
  });
  useEffect(() => {
    const handleResize = () => Events.resize(dom.current as T);
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, [length]);

  const handleSlide = (flag: 'L' | 'R') => {
    Events.slidehandler(flag, dom.current as T);
  };

  const events = {
    onTouchStart: (e: TouchEvent) => Events.mobileStart(e),
    onTouchMove: (e: TouchEvent) => Events.mobileMove(e, dom.current as T),
    onTouchEnd: (e: TouchEvent) => Events.mobileEnd(e, dom.current as T),
    onTouchCancel: (e: TouchEvent) => Events.mobileEnd(e, dom.current as T),
    onPointerDown: (e: MouseEvent) => Events.desktopStart(e),
    onPointerMove: (e: MouseEvent) => Events.desktopMove(e, dom.current as T),
    onPointerUp: (e: MouseEvent) => Events.desktopEnd(e, dom.current as T),
    onPointerLeave: (e: MouseEvent) => Events.desktopEnd(e, dom.current as T),
    onPointerCancel: (e: MouseEvent) => Events.desktopEnd(e, dom.current as T),
  } as unknown as UseSwipeEvents<T>;

  return {
    swipeEvents: events,
    handleSlide,
    changeIndex: (index: number) => Events.changeIndex(index, dom.current as T),
  };
}

 

/** Swipe.tsx **/
import React, { useRef, useEffect } from 'react';
import { ConfigProps } from 'swipe-core-provider';

import useSwipe from './useSwipe';
import Carousel from './Carousel';

import './style.css';

interface SwipeConfigProps extends ConfigProps {
  isCarousel?: boolean;
}

export interface SwipeProps {
  containerProps?: React.HTMLAttributes<HTMLDivElement>;
  itemProps?: React.HTMLAttributes<HTMLLIElement>;
  item: Array<React.ReactNode>;
  config?: SwipeConfigProps;
}

const Swipe: React.FC<SwipeProps> = ({
  containerProps,
  itemProps,
  item,
  config,
}) => {
  const ref = useRef<HTMLUListElement>(null);
  const DotsRef = useRef<HTMLUListElement>(null);

  const { swipeEvents, handleSlide } = useSwipe(ref, item.length, {
    historyCallback: (state) => {
      config?.historyCallback && config?.historyCallback(state);
      handleDot(state.currentStep);
    },
    isHistory: config?.isHistory || false,
    paramName: config?.paramName,
  });

  const handleDot = (index: number) => {
    if (DotsRef.current !== null) {
      DotsRef.current.childNodes.forEach((node: ChildNode, idx: number) => {
        const Node = node as HTMLLIElement;
        Node.className = index === idx ? 'active' : '';
      });
    }
  };

  useEffect(() => {
    const index = new URLSearchParams(location.search).get('index');
    if (index) handleDot(parseInt(index));
  }, []);

  return (
    <div
      {...containerProps}
      className={`swipe-container ${containerProps?.className}`}>
      {config?.isCarousel && !config.isHistory ? (
        <div>
          <button
            className='swipe-button swipe-left-button'
            onClick={() => handleSlide('L')}>
            〈
          </button>
          <button
            className='swipe-button swipe-right-button'
            onClick={() => handleSlide('R')}>
            〉
          </button>
          <Carousel itemLength={item.length} ref={DotsRef} />
        </div>
      ) : null}
      <ul className='swipe-wrap' ref={ref} {...swipeEvents}>
        {item.map((item, key) => {
          return (
            <li
              key={key}
              {...itemProps}
              className={`swipe-item ${itemProps?.className}`}>
              {item}
            </li>
          );
        })}
      </ul>
    </div>
  );
};

export default Swipe;

원래 목적인 "기존 UI에 최대한 swipe라는 기능만을 더할 수 있을 수 있는 패키지" 라는 목적을 고려해본다면 현재로서는 여기서 추가적인 기능 개발은 없을거 같다. 내부 로직의 개선은 할 부분들이 남았으며 추후에 다른 라이브러리나 프레임워크 기반으로 패키지를 내는 것 정도는 생각중이다.

Carousel UI

데모의 경우 아래 링크에서 확인가능하다.

https://yoonjonglyu.github.io/react-custom-swipe/

 

react-custom-swipe-demo

 

yoonjonglyu.github.io

 

반응형