Swipe Flipper

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

|![1213.jpg](https://qiita-image-store.s3.amazonaws.com/0/59071/03347af3-2602-d19d-d65f-4969d21ddd21.jpeg)|![1213-1.jpg](https://qiita-image-store.s3.amazonaws.com/0/59071/08c6d7e9-a133-5ade-fff3-15e4f71b5101.jpeg)|
|---|---|


# Component 概要図

今回は画面サイズ1つ分のコンテナを用意、ページコンポーネントを3枚重ねます。**コンテナ回転角度に応じて z-index 操作し、遷移回転着地後に「すげ替え」を行います。**必要なタイミングでマウント・アンマウントするので、ページ数制限はありません。

![1213.png](https://qiita-image-store.s3.amazonaws.com/0/59071/695dc221-ae56-d35c-adff-06325a8d8b18.png)

最初と最後のページは、めくって表示する要素がないため、回転方向に制御がかかっています。

# usePageFlipper

今日の Custom Hooks 内訳です。memoize する inline-style が多いです。

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

# page inline-style

複雑な指定になっていますが、memoize した rotateY を基準に style 構築します。iOS safari では transform を適用した要素が正しく z-index 適用されないバグがあるため、translateZ を適用し回避します。

```components/usePageFlipper.ts
const prevStyle = useMemo(
  () => ({
    zIndex: rotateY > -90 ? 1 : 0,
    transform: `${transformPerspective} rotateY(${rotateY}deg) scale(-1, 1) translateZ(0px)`,
    transitionDuration: `${state.transitionDuration /
      1000}s`
  }),
  [
    rotateY,
    transformPerspective,
    state.transitionDuration
  ]
)
const nextStyle = useMemo(
  () => ({
    zIndex: rotateY < 90 ? 1 : 0,
    transform: `${transformPerspective} rotateY(${rotateY}deg) scale(-1, 1) translateZ(0px)`,
    transitionDuration: `${state.transitionDuration /
      1000}s`
  }),
  [
    rotateY,
    transformPerspective,
    state.transitionDuration
  ]
)
const currentStyle = useMemo(
  () => ({
    zIndex: rotateY < 90 && rotateY > -90 ? 2 : 0,
    transform: `${transformPerspective} rotateY(${rotateY}deg) translateZ(1px)`,
    transitionDuration: `${state.transitionDuration /
      1000}s`
  }),
  [
    rotateY,
    transformPerspective,
    state.transitionDuration
  ]
)
```

### 触れた瞬間の処理

ここの処理はいつもと同じ。初期座標を確保します。

```components/usePageFlipper.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/usePageFlipper.ts
const handleMoveSwipe = useCallback(
  (event: TouchEvent<HTMLElement>) => {
    event.persist()
    updateState(_state => {
      if (!_state.isTouchDown || _state.isProcessTransit)
        return _state
      const offsetR =
        event.touches[0].clientX - _state.startX
      if (offsetR < -180 || offsetR > 180) return _state
      if (isFirstPage && offsetR > 0) return _state
      if (isLastPage && offsetR < 0) return _state
      return { ..._state, offsetR }
    })
  },
  [isFirstPage, isLastPage]
)
```

### 離した瞬間の処理

スワイプで得られた回転量に応じて、次頁前頁に遷移するか・否かを判断します。

```components/usePageFlipper.ts
const handleEndSwipe = useCallback(
  (event: TouchEvent<HTMLElement>) => {
    event.persist()
    if (
      props.ref.current === null ||
      state.isProcessTransit
    )
      return
    const isPrev = state.offsetR >= 0
    const shouldUpdatePage =
      Math.abs(state.offsetR) >= options.threshold
    let current = state.current
    let offsetR = 0
    if (isPrev && !isFirstPage && shouldUpdatePage) {
      current = current - 1
      offsetR = 180
    }
    if (!isPrev && !isLastPage && shouldUpdatePage) {
      current = current + 1
      offsetR = -180
    }
    updateState(_state => ({
      ..._state,
      offsetR,
      startX: 0,
      isProcessTransit: true,
      transitionDuration: options.animationDuration
    }))
    setTimeout(() => {
      updateState(_state => ({
        ..._state,
        current,
        offsetR: 0,
        isTouchDown: false,
        isProcessTransit: false,
        transitionDuration: 0
      }))
    }, options.animationDuration)
  },
  [state.isProcessTransit, state.offsetR]
)
```

仕上げに Custom Hooks を利用するコンポーネントで memoize します。

```components/pageFlipper.tsx
const View = (props: Props) => {
  const ref = useRef({} as HTMLDivElement)
  const {
    current,
    isFirstPage,
    isLastPage,
    prevStyle,
    currentStyle,
    nextStyle,
    handleStartSwipe,
    handleMoveSwipe,
    handleEndSwipe
  } = usePageFlipper({
    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="page prev" style={prevStyle}>
        {useMemo(
          () => {
            if (isFirstPage) return <div />
            return props.pages[current - 1]()
          },
          [current]
        )}
      </div>
      <div className="page current" style={currentStyle}>
        {useMemo(() => props.pages[current](), [current])}
      </div>
      <div className="page next" style={nextStyle}>
        {useMemo(
          () => {
            if (isLastPage) return <div />
            return props.pages[current + 1]()
          },
          [current]
        )}
      </div>
    </div>
  )
}
```