Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(HorizontalScroll): support rtl #5842

Merged
merged 4 commits into from
Oct 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as React from 'react';
import { classNames, noop } from '@vkontakte/vkjs';
import { useAdaptivityHasPointer } from '../../hooks/useAdaptivityHasPointer';
import { useDirection } from '../../hooks/useDirection';
import { useEventListener } from '../../hooks/useEventListener';
import { useExternRef } from '../../hooks/useExternRef';
import { easeInOutSine } from '../../lib/fx';
Expand All @@ -14,14 +15,15 @@ interface ScrollContext {
scrollAnimationDuration: number;
animationQueue: VoidFunction[];
getScrollPosition: (currentPosition: number) => number;
onScrollToRightBorder: VoidFunction;
onScrollToEndBorder: VoidFunction;
onScrollEnd: VoidFunction;
onScrollStart: VoidFunction;
/**
* Начальная ширина прокрутки.
* В некоторых случаях может отличаться от текущей ширины прокрутки из-за transforms: translate
*/
initialScrollWidth: number;
textDirection: 'ltr' | 'rtl';
}

export type ScrollPositionHandler = (currentPosition: number) => number;
Expand Down Expand Up @@ -58,11 +60,27 @@ function now() {
return performance && performance.now ? performance.now() : Date.now();
}

/**
* Округление к большему по модулю
*
* ## Пример
*
* ```ts
* import { strict as assert } from 'node:assert';
*
* assert.equal(roundingAwayFromZero(5.1), 6)
* assert.equal(roundingAwayFromZero(-5.1), -6)
* ```
*/
function roundingAwayFromZero(value: number): number {
return value > 0 ? Math.ceil(value) : Math.floor(value);
}

/**
* Округляем el.scrollLeft
* https://github.com/VKCOM/VKUI/pull/2445
*/
const roundUpElementScrollLeft = (el: HTMLElement) => Math.ceil(el.scrollLeft);
const roundUpElementScrollLeft = (el: HTMLElement) => roundingAwayFromZero(el.scrollLeft);

/**
* Код анимации скрола, на основе полифила: https://github.com/iamdustan/smoothscroll
Expand All @@ -75,29 +93,38 @@ function doScroll({
scrollElement,
getScrollPosition,
animationQueue,
onScrollToRightBorder,
onScrollToEndBorder,
onScrollEnd,
onScrollStart,
initialScrollWidth,
scrollAnimationDuration = SCROLL_ONE_FRAME_TIME,
textDirection,
}: ScrollContext) {
if (!scrollElement || !getScrollPosition) {
return;
}

/**
* максимальное значение сдвига влево
* крайнее значение сдвига
*/
const maxLeft = initialScrollWidth - scrollElement.offsetWidth;
const extremeScrollLeft =
(textDirection === 'ltr' ? 1 : -1) * (initialScrollWidth - scrollElement.offsetWidth);

let startLeft = roundUpElementScrollLeft(scrollElement);
let endLeft = getScrollPosition(startLeft);
let startScrollLeft = roundUpElementScrollLeft(scrollElement);
let endScrollLeft = getScrollPosition(startScrollLeft);

onScrollStart();

if (endLeft >= maxLeft) {
onScrollToRightBorder();
endLeft = maxLeft;
/**
* Если окончание прокрутки вышло за ноль
*/
if (startScrollLeft * endScrollLeft < 0) {
endScrollLeft = 0;
}

if (Math.abs(endScrollLeft) >= Math.abs(extremeScrollLeft)) {
onScrollToEndBorder();
endScrollLeft = extremeScrollLeft;
}

const startTime = now();
Expand All @@ -113,10 +140,12 @@ function doScroll({

const value = easeInOutSine(elapsed);

const currentLeft = startLeft + (endLeft - startLeft) * value;
scrollElement.scrollLeft = Math.ceil(currentLeft);
const currentScrollLeft = startScrollLeft + (endScrollLeft - startScrollLeft) * value;
scrollElement.scrollLeft = roundingAwayFromZero(currentScrollLeft);

if (roundUpElementScrollLeft(scrollElement) !== Math.max(0, endLeft) && elapsed !== 1) {
const scrollEnd =
textDirection === 'ltr' ? Math.max(0, endScrollLeft) : Math.min(0, endScrollLeft);
if (roundUpElementScrollLeft(scrollElement) !== scrollEnd && elapsed !== 1) {
requestAnimationFrame(scroll);
return;
}
Expand Down Expand Up @@ -146,10 +175,14 @@ export const HorizontalScroll = ({
}: HorizontalScrollProps) => {
const [canScrollLeft, setCanScrollLeft] = React.useState(false);
const [canScrollRight, setCanScrollRight] = React.useState(false);
const [directionRef, textDirection = 'ltr'] = useDirection<HTMLDivElement>();

const setCanScrollStart = textDirection === 'ltr' ? setCanScrollLeft : setCanScrollRight;
const setCanScrollEnd = textDirection === 'ltr' ? setCanScrollRight : setCanScrollLeft;

const isCustomScrollingRef = React.useRef(false);

const scrollerRef = useExternRef(getRef);
const scrollerRef = useExternRef(getRef, directionRef);

const animationQueue = React.useRef<VoidFunction[]>([]);

Expand All @@ -164,18 +197,19 @@ export const HorizontalScroll = ({
scrollElement,
getScrollPosition,
animationQueue: animationQueue.current,
onScrollToRightBorder: () => setCanScrollRight(false),
onScrollToEndBorder: () => setCanScrollEnd(false),
onScrollEnd: () => (isCustomScrollingRef.current = false),
onScrollStart: () => (isCustomScrollingRef.current = true),
initialScrollWidth: scrollElement?.firstElementChild?.scrollWidth || 0,
scrollAnimationDuration,
textDirection,
}),
);
if (animationQueue.current.length === 1) {
animationQueue.current[0]();
}
},
[scrollAnimationDuration, scrollerRef],
[scrollerRef, scrollAnimationDuration, textDirection, setCanScrollEnd],
);

const scrollToLeft = React.useCallback(() => {
Expand All @@ -194,13 +228,13 @@ export const HorizontalScroll = ({
if (showArrows && hasPointer && scrollerRef.current && !isCustomScrollingRef.current) {
const scrollElement = scrollerRef.current;

setCanScrollLeft(scrollElement.scrollLeft > 0);
setCanScrollRight(
roundUpElementScrollLeft(scrollElement) + scrollElement.offsetWidth <
setCanScrollStart(scrollElement.scrollLeft !== 0);
setCanScrollEnd(
Math.abs(roundUpElementScrollLeft(scrollElement)) + scrollElement.offsetWidth <
scrollElement.scrollWidth,
);
}
}, [hasPointer, scrollerRef, showArrows]);
}, [showArrows, hasPointer, scrollerRef, setCanScrollStart, setCanScrollEnd]);

const scrollEvent = useEventListener('scroll', calculateArrowsVisibility);
React.useEffect(
Expand Down
63 changes: 63 additions & 0 deletions packages/vkui/src/hooks/useDirection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as React from 'react';
import { useDOM } from '../lib/dom';
import { useIsomorphicLayoutEffect } from '../lib/useIsomorphicLayoutEffect';

type Direction = 'ltr' | 'rtl';
type WritingMode = 'horizontal-tb' | 'vertical-rl' | 'vertical-lr';

/**
* Определяет направление текста элемента.
*
* ## Ограничения
*
* - Не следит за изменением направлением.
* - Определяется только на второй рендер.
*
* ## Пример
*
* ```jsx
* import { strict as assert } from 'node:assert';
*
* const Component = () => {
* const [ref, direction, writingMode] = useDirection();
*
* React.useEffect(()=>{
* if (!direction || !writingMode) {
* return
* }
*
* assert.equal(direction, 'ltr')
* assert.equal(writingMode, 'vertical-rl')
* }, [direction, writingMode])
*
* return <div ref={ref} style={{writingMode: 'vertical-rl'}}>我家没有电脑。</div>
* }
* ```
*/
export function useDirection<T extends HTMLElement>(): [
React.RefObject<T>,
Direction | undefined,
WritingMode | undefined,
] {
const ref = React.useRef<T>(null);

const [direction, setDirection] = React.useState<Direction | undefined>(undefined);
const [writingMode, setWritingMode] = React.useState<WritingMode | undefined>(undefined);

const { window } = useDOM();

const update = () => {
if (!window || !ref.current) {
return;
}

const styleDeclaration = window.getComputedStyle(ref.current);

setDirection(styleDeclaration.direction as Direction);
setWritingMode(styleDeclaration.writingMode as WritingMode);
};

useIsomorphicLayoutEffect(update, [window]);

return [ref, direction, writingMode];
}