Swipe Pager

本日はスワイプジェスチャーでページ遷移するサンプルです。各ページに相当するコンポーネントは、遷移に応じてマウント・アンマウントされるため、画面数に制限はありません。
code: [github](https://github.com/takefumi-yoshii/react-hooks-ogiri/tree/master/apps/swipe-pager) / `$ yarn 1212`

|![1212.jpg](https://qiita-image-store.s3.amazonaws.com/0/59071/a4b05357-d236-a545-de67-580650ce1392.jpeg)|![1212-1.jpg](https://qiita-image-store.s3.amazonaws.com/0/59071/6e3b5073-473c-675c-42ce-a7e7d3734228.jpeg)|
|---|---|

# Component 概要図

各ページを内包するコンテナは、画面サイズ横3つ分の幅を確保しています。操作により「遷移する」と判断した時、遷移方向いっぱいまで移動します。**その瞬間、0ms でコンテナを中央に戻し、マウントしているコンポーネントを「すげ替え」ます。**コンテナは反復運動をしているだけですが、視覚的には移動方向に移動し続けている様に感じることが出来ます。

![1212.png](https://qiita-image-store.s3.amazonaws.com/0/59071/e6a74ce9-edb2-0e28-dc5d-6d8d4d5204c7.png)


# usePageSwiper

今回の Custom Hooks 内訳です。

```components/usePageSwiper.ts
const usePageSwiper = (props: Props) => {
  const [state, updateState] = useState<State>(...)
  const options = useMemo(...)
  const isFirstPage = useMemo(...)
  const isLastPage = useMemo(...)
  const containerStyle = useMemo(...)
  const handleStartSwipe = useCallback(...)
  const handleMoveSwipe = useCallback(...)
  const handleEndSwipe = useCallback(...)
  return {
    current: state.current,
    isFirstPage,
    isLastPage,
    handleStartSwipe,
    handleMoveSwipe,
    handleEndSwipe,
    containerStyle
  }
}
```

### 触れた瞬間の処理

タッチ時の座標を保持、transitionDuration を 0s にします。遷移アニメーション時の処理は early return します。

```components/usePageSwiper.ts
const handleStartSwipe = useCallback(
  (event: TouchEvent<HTMLElement>) => {
    event.persist()
    updateState(_state => {
      if (_state.isProcessTransit) return _state
      const startX = event.touches[0].clientX
      return {
        ..._state,
        isTouchDown: true,
        transitionDuration: 0,
        startX
      }
    })
  },
  []
)
```

### 指を動かしている時の処理

初期座標と現在座標の差分を移動量として保持します。指を離してからアニメーションしている最中にもこのイベントは起こり得るので、ここでも early return します。

```components/usePageSwiper.ts
const handleMoveSwipe = useCallback(
  (event: TouchEvent<HTMLElement>) => {
    event.persist()
    updateState(_state => {
      if (!_state.isTouchDown || _state.isProcessTransit)
        return _state
      const offsetX =
        event.touches[0].clientX - _state.startX
      return { ..._state, offsetX }
    })
  },
  []
)
```

### 離した瞬間の処理

指を動かした移動量と、Options に保持している遷移閾値を比較して、遷移判定を行います。混み入った実装なので、インラインコメントで補足します。setTimeout で後処理しているところは手を抜いてしまっています。

```components/usePageSwiper.ts
const handleEndSwipe = useCallback(
  (event: TouchEvent<HTMLElement>) => {
    event.persist()
    if (
      props.ref.current === null ||
      state.isProcessTransit
    )
      return // 遷移中に発生するイベントはここで return
    const isPrev = state.offsetX >= 0
    const shouldUpdatePage =
      Math.abs(state.offsetX) >= options.threshold
    const {
      width
    } = props.ref.current.getBoundingClientRect()
    let current = state.current
    let offsetX = 0 // 遷移無し判定時は元座標に戻る
    if (isPrev && !isFirstPage && shouldUpdatePage) {
      current = current - 1
      offsetX = width * 1
    }
    if (!isPrev && !isLastPage && shouldUpdatePage) {
      current = current + 1
      offsetX = width * -1
    }
    updateState(_state => ({
      ..._state,
      offsetX,
      startX: 0,
      isProcessTransit: true, // 他タッチイベントブロックのためのフラグ
      transitionDuration: options.animationDuration
    }))
    // css animation 終了と同タイミングで「すげ替え」する
    setTimeout(() => {
      updateState(_state => ({
        ..._state,
        current,
        offsetX: 0,
        isTouchDown: false,
        isProcessTransit: false,
        transitionDuration: 0
      }))
    }, options.animationDuration)
  },
  [state.isProcessTransit, state.offsetX]
)
```

Custom Hooks で得られる current を memoize したり、親コンポーネントの callback ハンドラを useEffect で叩けば完成です。子コンポーネントに props を渡したい場合には、もう一工夫必要になります。

```components/pageSwiper.tsx
const View = (props: Props) => {
  const ref = useRef({} as HTMLDivElement)
  const {
    current,
    isFirstPage,
    isLastPage,
    containerStyle,
    handleStartSwipe,
    handleMoveSwipe,
    handleEndSwipe
  } = usePageSwiper({
    ref,
    pages: props.pages,
    current: props.current,
    threshold: props.threshold,
    animationDuration: props.animationDuration
  })
  useEffect(
    () => {
      if (props.onChangePage === undefined) return
      props.onChangePage(current)
    },
    [current]
  )
  return (
    <div
      ref={ref}
      className={props.className}
      onTouchStart={handleStartSwipe}
      onTouchMove={handleMoveSwipe}
      onTouchEnd={handleEndSwipe}
      onTouchCancel={handleEndSwipe}
    >
      <div className="container" style={containerStyle}>
        <div className="page prev">
          {useMemo(
            () => {
              if (isFirstPage) return <div />
              return props.pages[current - 1]()
            },
            [current]
          )}
        </div>
        <div className="page current">
          {useMemo(() => props.pages[current](), [current])}
        </div>
        <div className="page next">
          {useMemo(
            () => {
              if (isLastPage) return <div />
              return props.pages[current + 1]()
            },
            [current]
          )}
        </div>
      </div>
    </div>
  )
}
```