diff --git a/packages/vkui/src/components/ScrollSaver/Readme.md b/packages/vkui/src/components/ScrollSaver/Readme.md
new file mode 100644
index 0000000000..692b91acbe
--- /dev/null
+++ b/packages/vkui/src/components/ScrollSaver/Readme.md
@@ -0,0 +1,434 @@
+Компонент-обертка для сохранения позиции скролла элемента при переходах между View и Panel.
+По умолчанию позволяет восстановить значение скролла при возвращении назад.
+
+
+
+Примеры использования **ScrollSaver**:
+
+- С компонентом Div.
+
+```jsx static
+
+
+
+
+
+```
+
+- С компонентом HorizontalScroll требуется задать проп withGetRef, потому что область скролла HorizontalScroll доступна не через getRootRef, а через getRef.
+
+```jsx static
+
+
+
+
+
+
+
+```
+
+- ScrollSaverWithCustomRef берёт ref не из children а из пропа elementRef.
+
+```jsx static
+
+
+
+ );
+};
+
+const Example = () => {
+ const [activeView, setActiveView] = useState('view1');
+ const [activePanel, setActivePanel] = useState(1);
+
+ const [swipeViewHistory, setSwipeViewHistory] = useState([`swipeView.${activePanel}`]);
+ const pushSwipeViewHistory = React.useCallback((panel) => {
+ setSwipeViewHistory((prevHistory) => [...prevHistory, `swipeView.${panel}`]);
+ }, []);
+ const onSwipeBack = React.useCallback(() => {
+ const newHistory = swipeViewHistory.slice(0, -1);
+ setSwipeViewHistory(newHistory);
+
+ const newActiveSwipeViewPanel = newHistory[newHistory.length - 1];
+ const swipeViewPanel = +newActiveSwipeViewPanel.split('swipeView.')[1];
+ setActivePanel(swipeViewPanel);
+ }, [swipeViewHistory]);
+
+ handleActivePanelSet = React.useCallback(
+ (panel) => {
+ setActivePanel(panel);
+ if (activeView === 'swipeView') {
+ pushSwipeViewHistory(panel);
+ }
+ },
+ [pushSwipeViewHistory, activeView],
+ );
+
+ const cache = useScrollSaverCache();
+ const clearScrollCache = useClearScrollSaverCache();
+ handleActiveViewSet = React.useCallback(
+ (view) => {
+ if (view === 'view1') {
+ clearScrollCache();
+ }
+ console.log(cache);
+ if (view === 'swipeView') {
+ const defaultSwipeViewActivePanel = 1;
+ setSwipeViewHistory([`swipeView.${defaultSwipeViewActivePanel}`]);
+ setActivePanel(defaultSwipeViewActivePanel);
+ }
+ setActiveView(view);
+ },
+ [activePanel, clearScrollCache, cache],
+ );
+
+ const navigationButtons = (
+
+ );
+
+ const swipeViewActivePanel = swipeViewHistory[swipeViewHistory.length - 1];
+ return (
+
+
+
+
+
+
+ Панель 1.1
+ {navigationButtons}
+
+
+
+
+ Панель 1.2
+ {navigationButtons}
+
+
+
+
+ Панель 1.3
+ {navigationButtons}
+
+
+
+
+
+
+ Панель 2.1
+ {navigationButtons}
+
+
+
+
+ Панель 2.2
+ {navigationButtons}
+
+
+
+
+ Панель 2.3
+ {navigationButtons}
+
+
+
+
+
+
+ П.1 iOS Swipe Back
+ {navigationButtons}
+
+
+
+ П.2 iOS Swipe Back
+ {navigationButtons}
+
+
+
+ П.3 iOS Swipe Back
+ {navigationButtons}
+
+
+
+
+
+
+
+ );
+};
+
+function NavigationButtons({ activePanel, activeView, setActiveView, setActivePanel }) {
+ return (
+ <>
+
+ {activeView === 'view1' ? (
+ <>
+ Перейти на View 1
+ setActiveView('view2')}>Перейти на View 2
+ setActiveView('swipeView')}>
+ Перейти на пример с iOS Swipe Back
+
+ >
+ ) : activeView === 'view2' ? (
+ <>
+ setActiveView('view1')}>Назад View 1
+ Перейти на View 2
+ setActiveView('swipeView')}>
+ Перейти на пример с iOS Swipe Back
+
+ >
+ ) : (
+ activeView === 'swipeView' && (
+ <>
+ setActiveView('view1')}>Назад на View 1
+ setActiveView('view2')}>Назад на View 2
+ Перейти на пример с iOS Swipe Back
+ >
+ )
+ )}
+
+
+
+
+ {activeView === 'view1' ? (
+ <>
+ {Array.from({ length: 3 }, (_, index) => (
+
+ ))}
+ >
+ ) : activeView === 'view2' ? (
+ <>
+ {Array.from({ length: 3 }, (_, index) => (
+
+ ))}
+ >
+ ) : (
+ activeView === 'swipeView' && (
+
+ )
+ )}
+
+
+
+
+ >
+ );
+}
+
+function PanelNavigationButton({ activePanel, viewNumber, panelNumber, setActivePanel }) {
+ const goOrBack = activePanel <= panelNumber ? 'Перейти' : 'Назад';
+ return (
+ setActivePanel(panelNumber)}>
+ {goOrBack} на панель {viewNumber}.{panelNumber}
+
+ );
+}
+
+function SwipeViewNavigationButton({ activePanel, setActivePanel }) {
+ return (
+ <>
+ {activePanel === 1 && (
+ Перейдите на панель 2 чтобы можно было свайпнуть назад
+ )}
+ {activePanel > 1 && (
+ Теперь свайпните от левого края направо, чтобы вернуться
+ )}
+ {activePanel < 3 && (
+ setActivePanel(activePanel + 1)}>
+ Перейти на панель {activePanel + 1}
+
+ )}
+ >
+ );
+}
+
+;
+```
diff --git a/packages/vkui/src/components/ScrollSaver/ScrollSaver.tsx b/packages/vkui/src/components/ScrollSaver/ScrollSaver.tsx
new file mode 100644
index 0000000000..aee6c42847
--- /dev/null
+++ b/packages/vkui/src/components/ScrollSaver/ScrollSaver.tsx
@@ -0,0 +1,40 @@
+import * as React from 'react';
+import { usePatchChildrenRef } from '../../hooks/usePatchChildrenRef';
+import { HasRootRef } from '../../types';
+import { ScrollSaveModeType } from './types';
+import { useScrollSaver } from './useScrollSaver';
+
+export interface ScrollSaverProps {
+ /*
+ * Уникальный идентификатор элемента скролл которого надо запомнить.
+ * Важно задавать id, так как на одной панели может понадобится запомнить позиции нескольких скролл-боксов.
+ **/
+ id: string;
+ /*
+ * Режим сохранения скролла: по умолчанию `forward`.
+ * `forward` - позиция скролла сохраняется только при переходе вперёд и восстанавливается при переходе назад.
+ * `always` - позиция скролла сохраняется и при переходе вперёд и при переходе назад.
+ **/
+ saveMode?: ScrollSaveModeType;
+ /*
+ * Если передан реакт-компонент, то он должен поддерживать getRootRef.
+ **/
+ children: React.ReactElement> | React.ReactElement;
+ /*
+ * Режим для получения рефа на элемент для скролла через getRef проп, вместо getRootRef (актуально для компонента HorizontalScroll)
+ **/
+ useGetRef?: boolean;
+}
+
+/**
+ * Компонент-обертка для сохранения позиции скролла элемента при переходах между View и Panel.
+ * По умолчанию позволяет восстановить значение скролла при возвращении назад.
+ *
+ * @see https://vkcom.github.io/VKUI/#/ScrollSaver
+ */
+export const ScrollSaver = (props: ScrollSaverProps) => {
+ const [childrenRef, children] = usePatchChildrenRef(props.children, props.useGetRef);
+ useScrollSaver(childrenRef, props.id, props.saveMode);
+
+ return children;
+};
diff --git a/packages/vkui/src/components/ScrollSaver/ScrollSaverContext.tsx b/packages/vkui/src/components/ScrollSaver/ScrollSaverContext.tsx
new file mode 100644
index 0000000000..d5b3473050
--- /dev/null
+++ b/packages/vkui/src/components/ScrollSaver/ScrollSaverContext.tsx
@@ -0,0 +1,34 @@
+import * as React from 'react';
+
+export type ScrollSaverCache = {
+ [key: string]: {
+ inlineStart: number;
+ blockStart: number;
+ };
+};
+
+export const ScrollSaverContext = React.createContext>({
+ current: {},
+});
+export const ScrollSaverContextProvider = ({
+ value,
+ children,
+}: React.PropsWithChildren<{ value?: ScrollSaverCache }>) => {
+ const valueRef = {
+ current: value || {},
+ };
+
+ return {children};
+};
+
+export const useScrollSaverCache = () => {
+ const contextCacheRef = React.useContext(ScrollSaverContext);
+ return contextCacheRef.current;
+};
+
+export const useClearScrollSaverCache = () => {
+ const cacheRef = React.useContext(ScrollSaverContext);
+ return () => {
+ cacheRef.current = {};
+ };
+};
diff --git a/packages/vkui/src/components/ScrollSaver/ScrollSaverWithCustomRef.ts b/packages/vkui/src/components/ScrollSaver/ScrollSaverWithCustomRef.ts
new file mode 100644
index 0000000000..08c1cd3ffb
--- /dev/null
+++ b/packages/vkui/src/components/ScrollSaver/ScrollSaverWithCustomRef.ts
@@ -0,0 +1,15 @@
+import { ScrollSaverProps } from './ScrollSaver';
+import { useScrollSaver } from './useScrollSaver';
+
+export interface ScrollSaverWithCustomRefProps
+ extends Omit {
+ elementRef: React.RefObject;
+}
+
+/* Компонентный Вариант useScrollSaver хука для динамического рендеринга, чтобы можно было пробросить и использовать любой ref
+ * children проп можно передать, но ref из него браться не будет */
+export function ScrollSaverWithCustomRef(props: ScrollSaverWithCustomRefProps) {
+ useScrollSaver(props.elementRef, props.id, props.saveMode);
+
+ return props.children;
+}
diff --git a/packages/vkui/src/components/ScrollSaver/types.ts b/packages/vkui/src/components/ScrollSaver/types.ts
new file mode 100644
index 0000000000..ec905250d5
--- /dev/null
+++ b/packages/vkui/src/components/ScrollSaver/types.ts
@@ -0,0 +1 @@
+export type ScrollSaveModeType = 'forward' | 'always';
diff --git a/packages/vkui/src/components/ScrollSaver/useScrollSaver.ts b/packages/vkui/src/components/ScrollSaver/useScrollSaver.ts
new file mode 100644
index 0000000000..fe2f02057b
--- /dev/null
+++ b/packages/vkui/src/components/ScrollSaver/useScrollSaver.ts
@@ -0,0 +1,66 @@
+import * as React from 'react';
+import { useNavId } from '../../components/NavIdContext/useNavId';
+import { useNavDirection } from '../../components/NavTransitionDirectionContext/NavTransitionDirectionContext';
+import { useIsomorphicLayoutEffect } from '../../lib/useIsomorphicLayoutEffect';
+import { useScrollSaverCache } from './ScrollSaverContext';
+import { ScrollSaveModeType } from './types';
+
+export function useScrollSaver(
+ elementRef: React.RefObject,
+ id: string,
+ saveMode: ScrollSaveModeType = 'forward',
+) {
+ const uniqueId = useUniqueId(id);
+ const direction = useNavDirection();
+ const scrollSaverCache = useScrollSaverCache();
+
+ useIsomorphicLayoutEffect(
+ function handleScrollPosition() {
+ const scrollId = uniqueId;
+ const refNode = elementRef.current;
+
+ function restoreScrollPosition() {
+ if (!refNode) {
+ return;
+ }
+
+ const scrollPosition = scrollSaverCache[scrollId];
+ if (!scrollPosition) {
+ return;
+ }
+
+ const shouldRestoreMovingBackwards = direction === 'backwards' && saveMode === 'forward';
+ const shouldRestoreMovingForwards = saveMode === 'always';
+ const shouldRestore = shouldRestoreMovingBackwards || shouldRestoreMovingForwards;
+ if (!shouldRestore) {
+ return;
+ }
+
+ const { inlineStart, blockStart } = scrollSaverCache[scrollId];
+ refNode.scrollLeft = inlineStart;
+ refNode.scrollTop = blockStart;
+ }
+
+ restoreScrollPosition();
+
+ return function saveScrollPositionOnUnmount() {
+ if (!refNode) {
+ return;
+ }
+ scrollSaverCache[scrollId] = {
+ inlineStart: refNode.scrollLeft,
+ blockStart: refNode.scrollTop,
+ };
+ };
+ },
+ [direction, uniqueId, saveMode, elementRef],
+ );
+
+ return elementRef;
+}
+
+function useUniqueId(id: string) {
+ const { view: viewId, panel: panelId } = useNavId();
+ const uniqueId = `${viewId}-${panelId}-${id}`;
+ return uniqueId;
+}
diff --git a/packages/vkui/src/components/View/Readme.md b/packages/vkui/src/components/View/Readme.md
index ab57e5488d..2d7d5ac7e8 100644
--- a/packages/vkui/src/components/View/Readme.md
+++ b/packages/vkui/src/components/View/Readme.md
@@ -1,3 +1,6 @@
+<<<<<<< HEAD
+<<<<<<< HEAD
+<<<<<<< HEAD
Базовый компонент для создания панелей.
- В качестве `children` принимает коллекцию [Panel](#/Panel). У каждой [Panel](#/Panel) должен быть
@@ -263,6 +266,269 @@ Xук возвращает правильное значение даже есл
На третьем `View` пример со свайпом в iOS от левого края назад, где видно, что панель на которую идёт переход определяет его тип в самом начале свайпа.
+=======
+## [useNavDirection(): определение типа перехода (вперёд/назад), с которым была отрисована панель.](#/View?id=usenavdirection_example)
+=======
+В примере ниже на View1 и View2 сохраняются позиции скролла HorizontalScroll используя ScrollSaver, useScrollSaver и ScrollSaverWithoutChildren.
+Позиция сохраняется как при переходе между View так и при переходе между Panel
+При возврате на View1 c других View мы сбрасываем весь кэш ScrollSaver.
+
+ScrollSaverWtihoutChildren - это версия useScrollSaver обёрнутая в компонент для динамического рендеринга, но она требудет преедачи пропа elementRef.
+>>>>>>> cffef2295 (Update Readme to show only ScrollSaver Example)
+=======
+Базовый компонент для создания панелей. В качестве `children` принимает коллекцию `Panel`.
+У каждой `Panel` должен быть уникальный `id`. Свойство `activePanel` определяет какая `Panel` активна.
+
+При смене значения свойства `activePanel` происходит плавный переход от одной панели к другой.
+Как только он заканчивается, вызывается свойство-функция `onTransition`.
+
+Чтобы понять был это переход вперёд или назад можно воспользоваться хуком [`useNavDirection()`](#/View?id=usenavdirection_example). Этот хук работает даже если анимации выключены (``).
+>>>>>>> af7abaa36 (Add ScrollSaver doc)
+
+>>>>>>> ffd276e66 (Update View Readme to use ScrollSaver with HorizontalScroll)
+```jsx
+const [activePanel, setActivePanel] = useState('panel1');
+
+
+
+ Panel 1
+
+
+ setActivePanel('panel2')}>Go to panel 2
+
+
+
+
+ Panel 2
+
+
+ setActivePanel('panel3')}>Go to panel 3
+
+
+
+
+ Panel 3
+
+
+ setActivePanel('panel1')}>Back to panel 1
+
+
+
+;
+```
+
+
+
+## [iOS Swipe Back](https://vkcom.github.io/VKUI/#/View?id=iosswipeback)
+
+В iOS есть возможность свайпнуть от левого края назад, чтобы перейти на предыдущую панель. Для того, чтобы
+повторить такое поведение в VKUI, нужно:
+
+- Передать во `View` коллбек `onSwipeBack` — он сработает при завершении анимации свайпа. Поменяйте в нем `activePanel` и обновите `history`.
+- Передать во `View` проп `history` — массив из id панелей в порядке открытия. Например, если пользователь из `main` перешел в `profile`, а оттуда попал в `education`, то `history=['main', 'profile', 'education']`.
+- Обернуть ваше приложение в `ConfigProvider` — он определит, открыто приложение в webview клиента VK или в браузере (там есть свой swipe back, который будет конфликтовать с нашим). Для проверки в браузере форсируйте определение webview: ``.
+
+**Блокировка свайпа (вариант #1)**
+
+Компоненты, которые сами обрабатывают жесты (например, карта или кастомный компонент по типу карусели), могут конфликтовать со свайпбеком. Вот как можно это решить:
+
+- либо повесьте на них свойство `data-vkui-swipe-back={false}`;
+- либо вызывайте `event.stopPropagation()` на событие `onStartX` компонента [Touch](#/Touch).
+
+
+
+**Блокировка свайпа (вариант #2)**
+
+Для блокирования свайпа по вашему условию есть коллбек `onSwipeBackStart()` (см. **Свойства и методы**)
+
+```tsx static
+import * as React from 'react';
+import { type ViewProps, View } from '@vkontakte/vkui';
+
+type ViewOnSwipeBackStartProp = Required['onSwipeBackStart'];
+
+const App = () => {
+ const handleSwipeBackStart = React.useCallback((activePanel) => {}, []);
+ return ;
+};
+```
+
+```jsx
+import vkBridge from '@vkontakte/vk-bridge';
+
+const App = () => {
+ const [history, setHistory] = useState(['main']);
+ const activePanel = history[history.length - 1];
+
+ const go = React.useCallback((panel) => {
+ setHistory((prevHistory) => [...prevHistory, panel]);
+ }, []);
+ const goBack = React.useCallback(() => {
+ setHistory((prevHistory) => prevHistory.slice(0, -1));
+ }, []);
+
+ const handleProfileClick = React.useCallback(() => go('profile'), [go]);
+ const handleSettingsClick = React.useCallback(() => go('settings'), [go]);
+
+ const [userName, setUserName] = React.useState('');
+ const [popoutWithRestriction, setPopoutWithRestriction] = React.useState(null);
+
+ const handleSwipeBackStartForPreventIfNeeded = React.useCallback(
+ (activePanel) => {
+ if (activePanel === 'settings') {
+ if (userName !== '') {
+ return;
+ }
+
+ setPopoutWithRestriction(
+ setPopoutWithRestriction(null)}
+ />,
+ );
+
+ return 'prevent';
+ }
+ },
+ [userName],
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const MainPanelContent = ({ onProfileClick }) => {
+ return (
+
+ Main
+
+
+
+ Профиль
+
+
+
+
+ );
+};
+
+const ProfilePanelContent = ({ onSettingsClick }) => {
+ return (
+
+ Профиль
+
+ Теперь свайпните от левого края направо, чтобы вернуться
+
+ Здесь свайпбек отключен
+
+
+
+
+ Настройки
+
+
+ Gallery}
+ description="Полностью блокирует свайпбэк (за счёт event.stopPropagation() на onStartX компонента Touch)"
+ >
+
+
+
+
+
+
+ HorizontalScroll}
+ description="Свайпбэк срабатывает либо если мы тянем за левый край экрана, либо если позиция горизонтального скролла равна нулю"
+ >
+
+
+
+
+
+ );
+};
+
+const SettingsPanelContent = ({ name, onChangeName }) => {
+ const handleNameChange = React.useCallback(
+ (event) => {
+ onChangeName(event.target.value.trim());
+ },
+ [onChangeName],
+ );
+
+ return (
+
+ Настройки
+
+ Пример с блокированием свайпбека пока не будет выполнено условие
+
+
+
+
+
+ );
+};
+
+
+
+;
+```
+
+
+
+## [useNavDirection(): определение типа перехода (вперёд/назад), с которым была отрисована панель.](#/View?id=usenavdirection_example)
+
+Хук `useNavDirection()` возвращает одно из трёх значений:
+
+- `undefined` означает, что компонент был смонтирован без перехода (тип перехода может быть не определён при самом первом монтировании приложения, когда ещё не было переходов между [View](#/View) и [Panel](#/Panel));
+- `"forwards"` переход вперёд;
+- `"backwards"` переход назад.
+
+Xук возвращает правильное значение даже если анимация **выключена** через [ConfigProvider](#/ConfigProvider) (``).
+
+Значение известно ещё до завершения анимации и определяется один раз, при первом монтировании панели.
+
+Этот хук можно использовать для определения типа анимации перехода не только между `Panel` внутри одного `View`, но и между `View` внутри `Root`.
+
+
+
+Хук также работает в режиме [iOS Swipe Back](#/View?id=iosswipeback). Тип перехода известен как только пользователь начал движение.
+
+
+
+В примере ниже c помощью спиннера имитируется загрузка данных если панель отрисована с анимацией перехода вперед.
+Используется два `View` и по три `Panel` компонента в каждом, чтобы показать, что тип перехода известен как при переходе между `View`, так и при переходе между `Panel`.
+
+На третьем `View` пример со свайпом в iOS от левого края назад, где видно, что панель на которую идёт переход определяет его тип в самом начале свайпа.
+
```jsx
const Content = () => {
const direction = useNavDirection();
diff --git a/packages/vkui/src/index.ts b/packages/vkui/src/index.ts
index d4ce796773..ad436abded 100644
--- a/packages/vkui/src/index.ts
+++ b/packages/vkui/src/index.ts
@@ -343,6 +343,19 @@ export type {
PopoverOnShownChange,
PopoverContentRenderProp,
} from './components/Popover/Popover';
+export { ScrollSaver, type ScrollSaverProps } from './components/ScrollSaver/ScrollSaver';
+export {
+ ScrollSaverWithCustomRef,
+ type ScrollSaverWithCustomRefProps,
+} from './components/ScrollSaver/ScrollSaverWithCustomRef';
+export { useScrollSaver } from './components/ScrollSaver/useScrollSaver';
+export { type ScrollSaveModeType } from './components/ScrollSaver/types';
+export {
+ ScrollSaverContextProvider,
+ useScrollSaverCache,
+ useClearScrollSaverCache,
+ type ScrollSaverCache,
+} from './components/ScrollSaver/ScrollSaverContext';
/**
* HOCs
diff --git a/styleguide/config.js b/styleguide/config.js
index 99b15c5a61..5814d35171 100644
--- a/styleguide/config.js
+++ b/styleguide/config.js
@@ -314,6 +314,7 @@ const baseConfig = {
`../${VKUI_PACKAGE.PATHS.COMPONENTS_DIR}/PlatformProvider/PlatformProvider.tsx`,
`../${VKUI_PACKAGE.PATHS.COMPONENTS_DIR}/AppearanceProvider/AppearanceProvider.tsx`,
`../${VKUI_PACKAGE.PATHS.COMPONENTS_DIR}/VisuallyHidden/VisuallyHidden.tsx`,
+ `../${VKUI_PACKAGE.PATHS.COMPONENTS_DIR}/ScrollSaver/ScrollSaver.tsx`,
],
},
],