diff --git a/src/hooks.tsx b/src/hooks.tsx index b922dbe..881a2c1 100644 --- a/src/hooks.tsx +++ b/src/hooks.tsx @@ -1,5 +1,6 @@ import { type MutableRefObject, + type RefObject, useCallback, useEffect, useLayoutEffect, @@ -7,32 +8,100 @@ import { useState, } from 'react'; -import { isBrowser, type BoundingBox } from 'framer-motion'; +import { type MotionValue, type BoundingBox, transform } from 'framer-motion'; import { IS_SSR } from './constants'; import { type SheetEvents } from './types'; -import { applyRootStyles, cleanupRootStyles } from './utils'; export const useIsomorphicLayoutEffect = IS_SSR ? useEffect : useLayoutEffect; -export function useModalEffect(isOpen: boolean, rootId?: string) { - const prevOpen = usePrevious(isOpen); +export function useModalEffect({ + y, + rootId, + sheetRef, +}: { + y: MotionValue; + rootId?: string; + sheetRef: RefObject; +}) { + const heightRef = useRef(window.innerHeight); + + function setup() { + const root = document.querySelector(`#${rootId}`) as HTMLDivElement; + const body = document.querySelector('body') as HTMLBodyElement; + if (!root) return; + + body.style.backgroundColor = '#000'; + root.style.overflow = 'hidden'; + root.style.transitionTimingFunction = 'cubic-bezier(0.32, 0.72, 0, 1)'; + root.style.transitionProperty = 'transform, border-radius'; + root.style.transitionDuration = '0.5s'; + root.style.transformOrigin = 'center top'; + } + + function cleanup() { + const root = document.querySelector(`#${rootId}`) as HTMLDivElement; + const body = document.querySelector('body') as HTMLBodyElement; + if (!root) return; + + setTimeout(() => { + body.style.removeProperty('background-color'); + root.style.removeProperty('overflow'); + root.style.removeProperty('transition-timing-function'); + root.style.removeProperty('transition-property'); + root.style.removeProperty('transition-duration'); + root.style.removeProperty('transform-origin'); + root.style.removeProperty('transform'); + root.style.removeProperty('border-top-right-radius'); + root.style.removeProperty('border-top-left-radius'); + }, 100); + } + + useIsomorphicLayoutEffect(() => { + return () => { + if (rootId) cleanup(); + }; + }, []); // eslint-disable-line - // Automatically apply the iOS modal effect to the body when sheet opens/closes useEffect(() => { - if (rootId && !prevOpen && isOpen) { - applyRootStyles(rootId); - } else if (rootId && !isOpen && prevOpen) { - cleanupRootStyles(rootId); + const root = document.querySelector(`#${rootId}`) as HTMLDivElement; + if (!root) return; + + function onCompleted() { + if (y.get() - 10 >= heightRef.current) cleanup(); } - }, [isOpen, prevOpen]); // eslint-disable-line - // Make sure to cleanup modal styles on unmount - useEffect(() => { + const removeStartListener = y.on('animationStart', () => { + heightRef.current = sheetRef.current?.offsetHeight || window.innerHeight; + setup(); + }); + + const removeChangeListener = y.on('change', (value) => { + if (root) { + const progress = Math.max(0, 1 - value / heightRef.current); + const pageWidth = window.innerWidth; + const scale = (pageWidth - 16) / pageWidth; + const ty = transform(progress, [0, 1], [0, 24]); + const s = transform(progress, [0, 1], [1, scale]); + const borderRadius = transform(progress, [0, 1], [0, 10]); + const inset = 'env(safe-area-inset-top)'; + + root.style.transform = `scale(${s}) translate3d(0, calc(${inset} + ${ty}px), 0)`; // prettier-ignore + root.style.borderTopRightRadius = `${borderRadius}px`; + root.style.borderTopLeftRadius = `${borderRadius}px`; + } + }); + + const removeCompleteListener = y.on('animationComplete', onCompleted); + const removeCancelListener = y.on('animationCancel', onCompleted); + return () => { - if (rootId && isOpen) cleanupRootStyles(rootId); + removeStartListener(); + removeChangeListener(); + removeCompleteListener(); + removeCancelListener(); }; - }, [isOpen]); // eslint-disable-line + }, [y, rootId]); // eslint-disable-line } export function useEventCallbacks( diff --git a/src/sheet.tsx b/src/sheet.tsx index b1ac36b..aa10f33 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -231,7 +231,11 @@ const Sheet = forwardRef( }, })); - useModalEffect(isOpen, rootId); + useModalEffect({ + y, + rootId, + sheetRef, + }); // Framer Motion should handle body scroll locking but it's not working // properly on iOS. Scroll locking from React Aria seems to work much better. diff --git a/src/utils.ts b/src/utils.ts index 85c5d26..3256788 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -15,48 +15,6 @@ export function getClosest(nums: number[], goal: number) { return closest; } -export function applyRootStyles(rootId: string) { - const body = document.querySelector('body') as HTMLBodyElement; - const root = document.querySelector(`#${rootId}`) as HTMLDivElement; - - if (root) { - const p = 24; - const h = window.innerHeight; - const s = (h - p) / h; - body.style.backgroundColor = '#000'; - root.style.overflow = 'hidden'; - root.style.willChange = 'transform'; - root.style.transition = - 'transform 200ms ease-in-out, border-radius 200ms linear'; - root.style.transform = `translateY(calc(env(safe-area-inset-top) + ${p / 2}px)) scale(${s})`; // prettier-ignore - root.style.borderTopRightRadius = '10px'; - root.style.borderTopLeftRadius = '10px'; - } -} - -export function cleanupRootStyles(rootId: string) { - const body = document.querySelector('body') as HTMLBodyElement; - const root = document.getElementById(rootId) as HTMLDivElement; - - function onTransitionEnd() { - root.style.removeProperty('overflow'); - root.style.removeProperty('will-change'); - root.style.removeProperty('transition'); - body.style.removeProperty('background-color'); - root.removeEventListener('transitionend', onTransitionEnd); - } - - if (root) { - // Start animating back - root.style.removeProperty('border-top-right-radius'); - root.style.removeProperty('border-top-left-radius'); - root.style.removeProperty('transform'); - - // Remove temp properties after animation is finished - root.addEventListener('transitionend', onTransitionEnd); - } -} - export function inDescendingOrder(arr: number[]) { for (let i = 0; i < arr.length; i++) { if (arr[i + 1] > arr[i]) return false;