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 + + +
+ +
+
+
+``` + +- useScrollSaver хук + +```jsx static +const ContentWithScrollSaverHook = () => { + const horizontalScrollRef = useRef(); + useScrollSaver(horizontalScrollRef, 'horizontal-scroll-saver-hook'); + + return ( +
+ + With ScrollSaverHook + + +
+ +
+
+
+ ); +}; +``` + +
+ +## Хук useClearScrollSaverCache() позволяет получить функцию глобальной очистки кэша ScrollSaver. + +```jsx static +const clearScrollCache = useClearScrollSaverCache(); +``` + +
+ +В примере ниже на View1 и View2 сохраняются позиции скролла HorizontalScroll используя **ScrollSaver**, **useScrollSaver** и **ScrollSaverWithCustomRef**. +Позиция сохраняется как при переходе между View так и при переходе между Panel +При возврате на View1 c других View мы сбрасываем весь кэш ScrollSaver. + +```jsx +const albumItems = [ + { + id: 1, + title: 'Команда <3', + size: 4, + thumb_src: 'https://sun9-33.userapi.com/ODk8khvW97c6aSx_MxHXhok5byDCsHEoU-3BwA/sO-lGf_NjN4.jpg', + }, + { + id: 2, + title: 'Зингер', + size: 22, + thumb_src: 'https://sun9-60.userapi.com/bjwt581hETPAp4oY92bDcRvMymyfCaEsnojaUA/_KWQfS-MAd4.jpg', + }, + { + id: 3, + title: 'Медиагалерея ВКонтакте', + size: 64, + thumb_src: 'https://sun9-26.userapi.com/YZ5-1A6cVgL7g1opJGQIWg1Bl5ynfPi8p41SkQ/IYIUDqGkkBE.jpg', + }, +]; + +const largeImageStyles = { + width: 220, + height: 124, + borderRadius: 4, + boxSizing: 'border-box', + border: 'var(--vkui--size_border--regular) solid var(--vkui--color_image_border_alpha)', + objectFit: 'cover', +}; + +const AlbumItems = () => { + return albumItems.map(({ id, title, size, thumb_src }) => ( + + + + )); +}; + +const Content = () => { + const direction = useNavDirection(); + + const [spinner, setSpinner] = useState(null); + + React.useEffect( + function simulateDataLoadingWhenMovingForwards() { + let timerId = null; + const loadData = () => { + setSpinner(); + timerId = setTimeout(() => setSpinner(null), 1000); + }; + + if (direction !== 'backwards') { + loadData(); + } + + return () => clearTimeout(timerId); + }, + [direction], + ); + + return ( +
+ + Направление перехода:{' '} + {direction === 'forwards' + ? 'вперёд' + : direction === 'backwards' + ? 'назад' + : 'не определено'} + + {spinner} +
+ ); +}; + +const ContentWithScrollSaverComponent = () => { + return ( +
+ + With ScrollSaver component + + + +
+ +
+
+
+
+ ); +}; + +const ContentWithScrollSaverWithCustomRef = () => { + const horizontalRef = useRef(); + return ( +
+ + With ScrollSaverWithCustomRef - doesn't look for children ref + + + +
+ +
+
+
+
+ ); +}; + +const ContentWithScrollSaverHook = () => { + const horizontalScrollRef = useRef(); + useScrollSaver(horizontalScrollRef, 'horizontal-scroll-saver-hook'); + + return ( +
+ + With ScrollSaverHook + + +
+ +
+
+
+ ); +}; + +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="Свайпбэк срабатывает либо если мы тянем за левый край экрана, либо если позиция горизонтального скролла равна нулю" + > + +
+ {getRandomUsers(15).map((user) => ( + + + + ))} +
+
+
+ + ); +}; + +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`, ], }, ],