Last active
February 28, 2025 08:12
-
-
Save manabuyasuda/af64d84d14cf0f64d9450b2ee2a04c01 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useEffect, useCallback, useRef } from 'react'; | |
// ブラウザ環境かどうかを判定する | |
const isBrowser = typeof window !== 'undefined'; | |
// iOSデバイスかどうかを判定する | |
const isIosDevice = | |
isBrowser && | |
window.navigator && | |
window.navigator.platform && | |
/iP(ad|hone|od)/.test(window.navigator.platform); | |
// 背景固定時に保存する元のスタイル情報 | |
type BodyInfo = { | |
position: string; | |
blockSize: string; | |
inlineSize: string; | |
insetBlockStart: string; | |
insetInlineStart: string; | |
paddingInlineEnd: string; | |
}; | |
/** | |
* bodyのスクロールをロックするためのカスタムフックです。 | |
* モーダルやダイアログ表示時にページのスクロールを防ぎ、スクロール位置を保持します。 | |
* @see https://github.com/streamich/react-use/blob/master/src/useLockBodyScroll.ts | |
* @see https://zenn.dev/tak_dcxi/articles/bbdb6cd9305ba4 | |
* @example | |
* function ExampleModal({ isOpen }) { | |
* useLockBodyScroll(isOpen); | |
* return isOpen ? <div>モーダルの内容</div> : null; | |
* } | |
*/ | |
export const useLockBodyScroll = (isLocked: boolean): void => { | |
// bodyへの参照を保持する | |
const bodyRef = useRef<HTMLElement | null>(null); | |
// スクロール位置を保持する | |
const scrollPositionRef = useRef<number>(0); | |
// 背景の固定状態を保持する | |
const isLockedRef = useRef(false); | |
// 元のスタイル情報を保持する | |
const savedBodyInfoRef = useRef<BodyInfo | null>(null); | |
// タッチイベントリスナーが追加されているかどうかを保持する | |
const documentListenerAddedRef = useRef(false); | |
/** | |
* タッチイベントのハンドラーです。 | |
* シングルタッチ(1本指での操作)の場合のみスクロールを防ぎます。 | |
* ピンチインや拡大縮小などのマルチタッチ操作は有効のままです。 | |
*/ | |
const touchMoveHandler = useCallback((e: TouchEvent) => { | |
if (e.touches.length > 1) return; | |
e.preventDefault(); | |
}, []); | |
/** | |
* スクロール位置を取得します。 | |
*/ | |
const getScrollPosition = useCallback((): number => { | |
return document.scrollingElement?.scrollTop ?? 0; | |
}, []); | |
/** | |
* スクロールバーの幅を計算します。 | |
*/ | |
const getScrollBarSize = useCallback((): number => { | |
// ウィンドウの幅 - スクロールバーを除いたコンテンツ幅 | |
return window.innerWidth - document.body.clientWidth; | |
}, []); | |
/** | |
* bodyのスタイルを一括で設定します | |
*/ | |
const setBodyStyles = useCallback( | |
(body: HTMLElement, styles: Partial<BodyInfo>) => { | |
Object.entries(styles).forEach(([key, value]) => { | |
if (value !== undefined) { | |
body.style[key as keyof BodyInfo] = value; | |
} | |
}); | |
}, | |
[], | |
); | |
/** | |
* 背景固定のスタイルを適用します。 | |
*/ | |
const lock = useCallback( | |
(body: HTMLElement) => { | |
// 固定済みなら何もしない | |
if (isLockedRef.current) return; | |
// 現在のスクロール位置を保存する | |
const currentScrollPosition = window.scrollY; | |
// スクロール位置を保存する | |
scrollPositionRef.current = currentScrollPosition; | |
// スクロールバーの幅を取得する | |
const scrollBarWidth = getScrollBarSize(); | |
// 固定前のスタイルを保存する | |
savedBodyInfoRef.current = { | |
position: body.style.position, | |
blockSize: body.style.blockSize, | |
inlineSize: body.style.inlineSize, | |
insetBlockStart: body.style.insetBlockStart, | |
insetInlineStart: body.style.insetInlineStart, | |
paddingInlineEnd: body.style.paddingInlineEnd, | |
}; | |
// iOSのバウンスを防ぐために、touchmoveイベントをキャンセルします | |
if (isIosDevice && !documentListenerAddedRef.current) { | |
document.addEventListener('touchmove', touchMoveHandler, { | |
passive: false, | |
}); | |
documentListenerAddedRef.current = true; | |
} | |
// 背景を固定するスタイルを適用します | |
// 現在のスクロール位置で固定し、スクロールバーの分だけ余白を追加してガタつきを防ぎます | |
setBodyStyles(body, { | |
position: 'fixed', | |
blockSize: '100dvb', | |
inlineSize: '100dvi', | |
insetBlockStart: `${currentScrollPosition * -1}px`, | |
insetInlineStart: '0', | |
paddingInlineEnd: `${scrollBarWidth}px`, | |
}); | |
// 背景が固定状態であると記録する | |
isLockedRef.current = true; | |
}, | |
[getScrollPosition, getScrollBarSize, touchMoveHandler, setBodyStyles], | |
); | |
/** | |
* 背景の固定を解除します。 | |
* 1. 保存された元のスタイルを復元する | |
* 2. iOSデバイスの場合、タッチ操作の制御を解除する | |
* 3. スクロール位置を復元する | |
*/ | |
const unlock = useCallback( | |
(body: HTMLElement) => { | |
// 固定されていないか、固定前のスタイル情報が保存されていない場合は何もしない | |
if (!isLockedRef.current || !savedBodyInfoRef.current) return; | |
// `lock`で追加したイベントリスナーを解除します | |
if (isIosDevice && documentListenerAddedRef.current) { | |
document.removeEventListener('touchmove', touchMoveHandler); | |
// タッチイベントリスナーは解除されていることを記録する | |
documentListenerAddedRef.current = false; | |
} | |
// 元のスタイルを復元する | |
setBodyStyles(body, savedBodyInfoRef.current); | |
// スクロール位置を復元する | |
window.scrollTo({ | |
top: scrollPositionRef.current, | |
behavior: 'instant', | |
}); | |
// 固定前のスタイル情報をクリアする | |
savedBodyInfoRef.current = null; | |
// 固定が解除されたことを記録する | |
isLockedRef.current = false; | |
}, | |
[touchMoveHandler, setBodyStyles], | |
); | |
useEffect(() => { | |
// SSR環境では実行しない | |
if (!isBrowser) return; | |
// ドキュメントのbody要素を取得する | |
const body = document.body; | |
// body要素への参照を保持する | |
bodyRef.current = body; | |
if (isLocked) { | |
lock(body); | |
} else { | |
unlock(body); | |
} | |
return () => { | |
if (bodyRef.current && isLocked) { | |
unlock(bodyRef.current); | |
} | |
}; | |
}, [isLocked, lock, unlock]); | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment