LoginSignup
1
0

More than 5 years have passed since last update.

昨日に続き、スワイプジェスチャーで「くるくる」ページ遷移するサンプルです。各ページに相当するコンポーネントは、遷移に応じてマウント・アンマウントされるため、画面数に制限はありません。
code: github / $ yarn 1213

1213.jpg 1213-1.jpg

Component 概要図

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

1213.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>
  )
}
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0