昨日に続き、スワイプジェスチャーで「くるくる」ページ遷移するサンプルです。各ページに相当するコンポーネントは、遷移に応じてマウント・アンマウントされるため、画面数に制限はありません。
code: github / $ yarn 1213
Component 概要図
今回は画面サイズ1つ分のコンテナを用意、ページコンポーネントを3枚重ねます。**コンテナ回転角度に応じて z-index 操作し、遷移回転着地後に「すげ替え」を行います。**必要なタイミングでマウント・アンマウントするので、ページ数制限はありません。
最初と最後のページは、めくって表示する要素がないため、回転方向に制御がかかっています。
usePageFlipper
今日の Custom Hooks 内訳です。memoize する inline-style が多いです。
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 を適用し回避します。
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
]
)
触れた瞬間の処理
ここの処理はいつもと同じ。初期座標を確保します。
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 します。
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]
)
離した瞬間の処理
スワイプで得られた回転量に応じて、次頁前頁に遷移するか・否かを判断します。
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 します。
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>
)
}