From ca240157c5a3694d76af9944ceef456ab61563aa Mon Sep 17 00:00:00 2001 From: "e.muhamethanov" Date: Thu, 26 Dec 2024 16:13:10 +0300 Subject: [PATCH 1/4] feat(ScrollContext): add ability to manual scroll window when scroll locked --- .../src/components/AppRoot/ScrollContext.tsx | 71 +++++++++++++++---- 1 file changed, 58 insertions(+), 13 deletions(-) diff --git a/packages/vkui/src/components/AppRoot/ScrollContext.tsx b/packages/vkui/src/components/AppRoot/ScrollContext.tsx index d96d8eedcd..3f9e64759f 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 = { @@ -80,6 +80,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; } @@ -87,23 +98,53 @@ 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] = scrollLockEnabledRef.current + ? [-clampX(x), -clampY(y)] + : [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], ); @@ -128,6 +169,8 @@ export const GlobalScrollController = ({ children }: ScrollControllerProps): Rea overflowY, overflowX, }); + + scrollLockEnabledRef.current = true; }, [document, window]); const disableScrollLock = React.useCallback(() => { @@ -137,6 +180,8 @@ export const GlobalScrollController = ({ children }: ScrollControllerProps): Rea Object.assign(document!.documentElement.style, { overscrollBehavior: '' }); clearDisableScrollStyle(document!.body); window!.scrollTo(-parseInt(scrollX || '0'), -parseInt(scrollY || '0')); + + scrollLockEnabledRef.current = false; }, [document, window]); const { incrementScrollLockCounter, decrementScrollLockCounter } = useScrollLockController( From 1e9d7c16312f3e326d80280308a80bba31bd9437 Mon Sep 17 00:00:00 2001 From: "e.muhamethanov" Date: Thu, 9 Jan 2025 16:41:07 +0300 Subject: [PATCH 2/4] feat(ScrollContext): add tests to new logic, and export hook `useScrollingLockedScroll` --- .../components/AppRoot/ScrollContext.test.tsx | 34 +++++++++++++++---- .../src/components/AppRoot/ScrollContext.tsx | 8 ++--- packages/vkui/src/index.ts | 2 +- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/packages/vkui/src/components/AppRoot/ScrollContext.test.tsx b/packages/vkui/src/components/AppRoot/ScrollContext.test.tsx index 9370472b90..46e18437b2 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,15 @@ 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(getPositionOfBody()).toEqual([undefined, undefined]); + expect(window.pageYOffset).toBe(10); + } + contextRef.current?.scrollTo(); expect(contextRef.current?.getScroll()).toEqual({ x: 0, y: 50 }); @@ -222,6 +237,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 +257,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 3f9e64759f..2490c9babc 100644 --- a/packages/vkui/src/components/AppRoot/ScrollContext.tsx +++ b/packages/vkui/src/components/AppRoot/ScrollContext.tsx @@ -130,14 +130,12 @@ export const GlobalScrollController = ({ children }: ScrollControllerProps): Rea const clampY = (value: number) => value ? clamp(value, 0, document.body.scrollHeight - window.innerHeight) : 0; - const [left, top] = scrollLockEnabledRef.current - ? [-clampX(x), -clampY(y)] - : [clampX(x), clampY(y)]; + const [left, top] = [clampX(x), clampY(y)]; if (scrollLockEnabledRef.current) { Object.assign(document.body.style, { - left: `${left}px`, - top: `${top}px`, + left: `-${left}px`, + top: `-${top}px`, }); } else { window.scrollTo({ 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'; From 653b51cf4da0b91e527da4e9daa7952a2b0ef371 Mon Sep 17 00:00:00 2001 From: "e.muhamethanov" Date: Thu, 9 Jan 2025 17:17:48 +0300 Subject: [PATCH 3/4] fix: fix test --- packages/vkui/src/components/AppRoot/ScrollContext.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/vkui/src/components/AppRoot/ScrollContext.test.tsx b/packages/vkui/src/components/AppRoot/ScrollContext.test.tsx index 46e18437b2..1b375336ca 100644 --- a/packages/vkui/src/components/AppRoot/ScrollContext.test.tsx +++ b/packages/vkui/src/components/AppRoot/ScrollContext.test.tsx @@ -101,7 +101,6 @@ describe(useScrollLock, () => { expect(getPositionOfBody()).toEqual([`-${10}px`, `-${10}px`]); expect(window.pageYOffset).toBe(0); } else { - expect(getPositionOfBody()).toEqual([undefined, undefined]); expect(window.pageYOffset).toBe(10); } From 2c097a178c02480d477b514ea4ebf65aa819e7a1 Mon Sep 17 00:00:00 2001 From: "e.muhamethanov" Date: Fri, 10 Jan 2025 17:35:33 +0300 Subject: [PATCH 4/4] test: add tests --- .../components/AppRoot/ScrollContext.test.tsx | 46 +++++++++++++++++++ .../src/components/AppRoot/ScrollContext.tsx | 20 ++++---- 2 files changed, 54 insertions(+), 12 deletions(-) diff --git a/packages/vkui/src/components/AppRoot/ScrollContext.test.tsx b/packages/vkui/src/components/AppRoot/ScrollContext.test.tsx index 1b375336ca..aefb5def8f 100644 --- a/packages/vkui/src/components/AppRoot/ScrollContext.test.tsx +++ b/packages/vkui/src/components/AppRoot/ScrollContext.test.tsx @@ -111,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, () => { diff --git a/packages/vkui/src/components/AppRoot/ScrollContext.tsx b/packages/vkui/src/components/AppRoot/ScrollContext.tsx index 90175f3e2c..572f1c59c5 100644 --- a/packages/vkui/src/components/AppRoot/ScrollContext.tsx +++ b/packages/vkui/src/components/AppRoot/ScrollContext.tsx @@ -157,17 +157,13 @@ 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, @@ -175,18 +171,18 @@ export const GlobalScrollController = ({ children }: ScrollControllerProps): Rea }); scrollLockEnabledRef.current = true; - }, [document, window]); - const disableScrollLock = React.useCallback(() => { - const scrollY = document!.body.style.top; - const scrollX = document!.body.style.left; + 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')); - scrollLockEnabledRef.current = false; - }, [document, window]); + + scrollTo(scrollData.x, scrollData.y); + }, [document, getScroll, scrollTo]); const { incrementScrollLockCounter, decrementScrollLockCounter } = useScrollLockController( enableScrollLock,