Skip to content

Commit

Permalink
fix(HorizontalScroll): support rtl (#5842)
Browse files Browse the repository at this point in the history
- Fixes #5837
  • Loading branch information
SevereCloud authored Oct 3, 2023
1 parent fe17db7 commit b15428d
Show file tree
Hide file tree
Showing 2 changed files with 117 additions and 20 deletions.
74 changes: 54 additions & 20 deletions packages/vkui/src/components/HorizontalScroll/HorizontalScroll.tsx
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];
}

0 comments on commit b15428d

Please sign in to comment.