diff --git a/packages/vkui/src/components/AppRoot/ScrollContext.test.tsx b/packages/vkui/src/components/AppRoot/ScrollContext.test.tsx index 9370472b90..aefb5def8f 100644 --- a/packages/vkui/src/components/AppRoot/ScrollContext.test.tsx +++ b/packages/vkui/src/components/AppRoot/ScrollContext.test.tsx @@ -70,19 +70,25 @@ describe(useScrollLock, () => { clearWindowMeasuresMock(); }); - - test('context api', () => { + test.each([true, false])('context api with locked=%s', (locked) => { const contextRef = createRef(); - render( + const Fixture = () => ( ()}> - , + ); + const { rerender } = render(); + const clearWindowMeasuresMock = mockWindowMeasures(50, 50); const clearElementScrollMock = mockElementScroll(document.body, 100, 100); const clearMockWindowScrollToMock = mockWindowScrollTo(); + if (locked) { + contextRef.current?.incrementScrollLockCounter(); + rerender(); + } + expect(contextRef.current?.getScroll()).toEqual({ x: 0, y: 50 }); expect(contextRef.current?.getScroll({ compensateKeyboardHeight: false })).toEqual({ x: 0, @@ -90,6 +96,14 @@ describe(useScrollLock, () => { }); contextRef.current?.scrollTo(10, 10); expect(contextRef.current?.getScroll()).toEqual({ x: 10, y: 60 }); + + if (locked) { + expect(getPositionOfBody()).toEqual([`-${10}px`, `-${10}px`]); + expect(window.pageYOffset).toBe(0); + } else { + expect(window.pageYOffset).toBe(10); + } + contextRef.current?.scrollTo(); expect(contextRef.current?.getScroll()).toEqual({ x: 0, y: 50 }); @@ -97,6 +111,52 @@ describe(useScrollLock, () => { clearElementScrollMock(); clearMockWindowScrollToMock(); }); + + test('scroll when not locked and then when locked', () => { + const contextRef = createRef(); + const Fixture = () => ( + ()}> + + + ); + + const { rerender } = render(); + + const clearWindowMeasuresMock = mockWindowMeasures(50, 50); + const clearElementScrollMock = mockElementScroll(document.body, 100, 100); + const clearMockWindowScrollToMock = mockWindowScrollTo(); + + // Скролим не залоченный скролл + contextRef.current?.scrollTo(10, 10); + expect(window.pageYOffset).toBe(10); + expect(window.pageXOffset).toBe(10); + + // Блокируем скролл + contextRef.current?.incrementScrollLockCounter(); + rerender(); + + // Блокируем скролл - отступы остаются те же + expect(window.pageYOffset).toBe(10); + expect(window.pageXOffset).toBe(10); + expect(getPositionOfBody()).toEqual([`-${10}px`, `-${10}px`]); + + // Скролим залоченный скролл + contextRef.current?.scrollTo(25, 25); + + expect(getPositionOfBody()).toEqual([`-${25}px`, `-${25}px`]); + + // Выключаем блокировку скролла + contextRef.current?.decrementScrollLockCounter(); + rerender(); + + // Отступы window должны пересчитаться + expect(window.pageYOffset).toBe(25); + expect(window.pageXOffset).toBe(25); + + clearWindowMeasuresMock(); + clearElementScrollMock(); + clearMockWindowScrollToMock(); + }); }); describe(ElementScrollController, () => { @@ -222,6 +282,11 @@ function getStyleAttributeObject(el: HTMLElement | null) { ); } +function getPositionOfBody() { + const styles = getStyleAttributeObject(document.body); + return styles && [styles.left, styles.top]; +} + function mockWindowMeasures(width: number, height: number) { const originalW = window.innerWidth; const originalH = window.innerHeight; @@ -237,9 +302,9 @@ function mockWindowScrollTo() { const original = window.scrollTo; Object.defineProperty(window, 'scrollTo', { configurable: true, - value: (x: number, y: number) => { - Object.defineProperty(window, 'pageXOffset', { configurable: true, value: x }); - Object.defineProperty(window, 'pageYOffset', { configurable: true, value: y }); + value: ({ left, top }: { left: number; top: number }) => { + Object.defineProperty(window, 'pageXOffset', { configurable: true, value: left }); + Object.defineProperty(window, 'pageYOffset', { configurable: true, value: top }); }, }); return function clearMock() { diff --git a/packages/vkui/src/components/AppRoot/ScrollContext.tsx b/packages/vkui/src/components/AppRoot/ScrollContext.tsx index 6aa3b82d1f..572f1c59c5 100644 --- a/packages/vkui/src/components/AppRoot/ScrollContext.tsx +++ b/packages/vkui/src/components/AppRoot/ScrollContext.tsx @@ -19,13 +19,13 @@ const clearDisableScrollStyle = (node: HTMLElement) => { }); }; -const getPageYOffsetWithoutKeyboardHeight = (window: Window) => { +const getPageYOffsetWithoutKeyboardHeight = (window: Window, scrollTop: number) => { // Note: здесь расчёт на то, что `clientHeight` равен `window.innerHeight`. // Это достигается тем, что тегу `html` задали`height: 100%` и у него нет отступов сверху и снизу. Если есть отступы, // то надо задать `box-sizing: border-box`, чтобы они не учитывались. const diffOfClientHeightAndViewportHeight = window.document.documentElement.clientHeight - window.innerHeight; - return window.pageYOffset - diffOfClientHeightAndViewportHeight; + return scrollTop - diffOfClientHeightAndViewportHeight; }; export type GetScrollOptions = { @@ -86,6 +86,17 @@ function useScrollLockController(enableScrollLock: () => void, disableScrollLock }; } +export function useScrollingLockedScroll(): Pick { + const { scrollTo, getScroll } = React.useContext(ScrollContext); + return React.useMemo( + () => ({ + scrollTo, + getScroll, + }), + [getScroll, scrollTo], + ); +} + export interface ScrollControllerProps extends HasChildren { elRef: React.RefObject; } @@ -93,23 +104,51 @@ export interface ScrollControllerProps extends HasChildren { export const GlobalScrollController = ({ children }: ScrollControllerProps): React.ReactNode => { const { window, document } = useDOM(); const beforeScrollLockFnSetRef = React.useRef void>>(new Set()); + const scrollLockEnabledRef = React.useRef(false); const getScroll = React.useCallback( - (options = { compensateKeyboardHeight: true }) => ({ - x: window!.pageXOffset, - y: options.compensateKeyboardHeight - ? getPageYOffsetWithoutKeyboardHeight(window!) - : window!.pageYOffset, - }), - [window], + (options = { compensateKeyboardHeight: true }) => { + if (!window) { + throw new Error('window is not defined'); + } + const bodyStyles = document!.body.style; + const [scrollLeft, scrollTop] = scrollLockEnabledRef.current + ? [-parseFloat(bodyStyles.left || '0'), -parseFloat(bodyStyles.top || '0')] + : [window.pageXOffset, window.pageYOffset]; + return { + x: scrollLeft, + y: options.compensateKeyboardHeight + ? getPageYOffsetWithoutKeyboardHeight(window, scrollTop) + : scrollTop, + }; + }, + [document, window], ); const scrollTo = React.useCallback( (x = 0, y = 0) => { + if (!window || !document) { + return; + } + // Some iOS versions do not normalize scroll — do it manually. - window!.scrollTo( - x ? clamp(x, 0, document!.body.scrollWidth - window!.innerWidth) : 0, - y ? clamp(y, 0, document!.body.scrollHeight - window!.innerHeight) : 0, - ); + const clampX = (value: number) => + value ? clamp(value, 0, document.body.scrollWidth - window.innerWidth) : 0; + const clampY = (value: number) => + value ? clamp(value, 0, document.body.scrollHeight - window.innerHeight) : 0; + + const [left, top] = [clampX(x), clampY(y)]; + + if (scrollLockEnabledRef.current) { + Object.assign(document.body.style, { + left: `-${left}px`, + top: `-${top}px`, + }); + } else { + window.scrollTo({ + left, + top, + }); + } }, [document, window], ); @@ -118,32 +157,32 @@ export const GlobalScrollController = ({ children }: ScrollControllerProps): Rea beforeScrollLockFnSetRef.current.forEach((fn) => { fn(); }); - - const scrollY = window!.pageYOffset; - const scrollX = window!.pageXOffset; + const { x: scrollX, y: scrollY } = getScroll({ compensateKeyboardHeight: false }); const overflowY = window!.innerWidth > document!.documentElement.clientWidth ? 'scroll' : ''; const overflowX = window!.innerHeight > document!.documentElement.clientHeight ? 'scroll' : ''; Object.assign(document!.documentElement.style, { overscrollBehavior: 'none' }); Object.assign(document!.body.style, { position: 'fixed', - top: `-${scrollY}px`, - left: `-${scrollX}px`, right: '0', overscrollBehavior: 'none', overflowY, overflowX, }); - }, [document, window]); - const disableScrollLock = React.useCallback(() => { - const scrollY = document!.body.style.top; - const scrollX = document!.body.style.left; + scrollLockEnabledRef.current = true; + + scrollTo(scrollX, scrollY); + }, [document, getScroll, scrollTo, window]); + const disableScrollLock = React.useCallback(() => { + const scrollData = getScroll({ compensateKeyboardHeight: false }); Object.assign(document!.documentElement.style, { overscrollBehavior: '' }); clearDisableScrollStyle(document!.body); - window!.scrollTo(-parseInt(scrollX || '0'), -parseInt(scrollY || '0')); - }, [document, window]); + scrollLockEnabledRef.current = false; + + scrollTo(scrollData.x, scrollData.y); + }, [document, getScroll, scrollTo]); const { incrementScrollLockCounter, decrementScrollLockCounter } = useScrollLockController( enableScrollLock, diff --git a/packages/vkui/src/index.ts b/packages/vkui/src/index.ts index 024d236cb0..e8588be90b 100644 --- a/packages/vkui/src/index.ts +++ b/packages/vkui/src/index.ts @@ -445,7 +445,7 @@ export { usePagination } from './hooks/usePagination'; export { type Orientation, useOrientationChange } from './hooks/useOrientationChange'; export { usePatchChildren } from './hooks/usePatchChildren'; export { useTodayDate } from './hooks/useTodayDate'; -export { useScrollLock } from './components/AppRoot/ScrollContext'; +export { useScrollLock, useScrollingLockedScroll } from './components/AppRoot/ScrollContext'; export { useNavTransition } from './components/NavTransitionContext/NavTransitionContext'; export { useNavDirection } from './components/NavTransitionDirectionContext/NavTransitionDirectionContext'; export { useNavId } from './components/NavIdContext/useNavId';