From 77eb96e6c5d268ad3a81a314cd9590d718d2cbda Mon Sep 17 00:00:00 2001 From: jmonsellier Date: Thu, 10 Sep 2026 14:46:43 +0200 Subject: [PATCH] feat(virtualized-list): follow the reading direction on a mirrored layout A horizontal virtualized list lays its items out from the right edge and scrolls leftwards when the layout is mirrored. Two signs, decided once per list from a new `rtl` prop that defaults to `I18nManager.isRTL`: the items stack leftwards from the anchor React Native already moved to the right, and the container slides the other way. The sign is flipped in the animation hook, the single point common to the three scroll behaviours. A horizontal list pinned inside a `direction: 'ltr'` subtree (playback controls, a time axis) passes `rtl={false}`, the library having no way to see the resolved direction of a subtree from JS. Vertical lists and the non-mirrored path are unchanged by construction. Closes #172 --- README.md | 4 + docs/api.md | 1 + docs/rtl.md | 89 ++++++ ...atialNavigationVirtualizedListRTL.test.tsx | 253 ++++++++++++++++++ .../virtualizedList/VirtualizedList.tsx | 47 +++- .../hooks/useVirtualizedListAnimation.ts | 17 +- .../types/TypeVirtualizedListAnimation.ts | 2 + 7 files changed, 408 insertions(+), 5 deletions(-) create mode 100644 docs/rtl.md create mode 100644 packages/lib/src/spatial-navigation/components/virtualizedList/SpatialNavigationVirtualizedListRTL.test.tsx diff --git a/README.md b/README.md index 484f6f7e..b7cac16a 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,10 @@ You should have a look at [the pitfalls and troubleshooting](./docs/pitfalls.md) Read the [state of accessibility](./docs/accessibility.md). +# Right-to-left layouts + +Read [right-to-left layouts](./docs/rtl.md) for Arabic, Hebrew, Farsi or Urdu apps. + # Contributing ## Publishing the package diff --git a/docs/api.md b/docs/api.md index ba59ab49..718e4371 100644 --- a/docs/api.md +++ b/docs/api.md @@ -254,6 +254,7 @@ It also ensures that the scroll event is propagated properly to parent ScrollVie | `descendingArrow` | `ReactElement` | For web TVs cursor handling. Optional component to display as the arrow to scroll on the descending order. | | `descendingArrowContainerStyle` | `ViewStyle` | For web TVs cursor handling. Style of the view which wraps the descending arrow. Hover this view will trigger the scroll. | | `scrollInterval` | `number` | For web TVs cursor handling. Speed of the pointer scroll. It represents the interval in ms between every item scrolled. Default value is set to 100. | +| `rtl` | `boolean` | Lays a horizontal list out for a mirrored layout: the first item sits at the right edge and the list scrolls leftwards. Defaults to `I18nManager.isRTL` on native, and to `false` on the web where the direction comes from the DOM. Pass `false` for a horizontal list that must stay left-to-right inside a mirrored app (a time axis, playback controls). Ignored on a vertical list. See [right-to-left layouts](./rtl.md). | The `SpatialNavigationVirtualizedList` component ref expose the following methods: diff --git a/docs/rtl.md b/docs/rtl.md new file mode 100644 index 00000000..77609d1c --- /dev/null +++ b/docs/rtl.md @@ -0,0 +1,89 @@ +# Right-to-left layouts + +A TV app translated into Arabic, Hebrew, Farsi or Urdu runs with a mirrored layout +(`I18nManager.isRTL === true`): Yoga lays every row out from the right, and React Native +rewrites `left` as `right` in the whole tree. + +Two things have to follow that direction in a spatially navigated app: the **layout** of the +horizontal virtualized lists, which this library owns, and the **remote control mapping**, which +your app owns. + +## What the library does + +A horizontal `SpatialNavigationVirtualizedList` lays its items out from the right edge and scrolls +leftwards when the layout is mirrored. This is automatic on native, where the list reads +`I18nManager.isRTL`. + +Nothing else in the library needs to know about the reading direction: + +- **vertical lists** translate on the Y axis and are never mirrored; +- the **rows of a grid** are flex rows, so Yoga mirrors them on its own; +- the **default focus** stays on index 0, which a mirrored layout draws at the right edge, the + first item in reading order. + +### On the web + +`I18nManager` carries no direction on react-native-web (the layout direction comes from the DOM), +so a list defaults to a left-to-right layout there. Pass the `rtl` prop explicitly if your web app +renders in a right-to-left direction. + +## What your app has to do: the remote control mapping + +LRUD, the engine under the library, navigates by **logical index**, and never measures a layout: + +```js +// @bam.tech/lrud +var offset = direction === Directions.LEFT || direction === Directions.UP ? -1 : 1; +``` + +`RIGHT` therefore means "next sibling in the tree", which a mirrored layout draws on the **left** of +the screen. Left as is, pressing right on the remote moves the focus to the left. + +Swap the two horizontal directions in your `configureRemoteControl` mapping, which is the single +place where a key becomes a direction: + +```jsx +const mapping = { + ArrowRight: I18nManager.isRTL ? Directions.LEFT : Directions.RIGHT, + ArrowLeft: I18nManager.isRTL ? Directions.RIGHT : Directions.LEFT, + ArrowUp: Directions.UP, + ArrowDown: Directions.DOWN, +}; +``` + +`UP` and `DOWN` are never swapped: the mirror is horizontal. + +## Lists that must stay left-to-right in a mirrored app + +Some surfaces are not mirrored even in Arabic, and are usually pinned with `direction: 'ltr'` on an +ancestor view: + +- **playback controls**, which follow the direction of the tape rather than the reading direction; +- a **time axis** (an EPG grid, a timeline), since time flows to the right in every culture. + +React Native decides the `left`/`right` rewriting **once, at the root of the surface**, so inside +such a subtree the items of a list keep their `left: 0` anchor on the left. The library cannot see +the resolved direction of a subtree from JavaScript: tell it with `rtl={false}`, otherwise its +items are pushed off screen. + +```jsx + + + +``` + +The same prop takes a `true` if you mirror a subtree of an otherwise left-to-right app. + +## Scope + +The mirrored layout covers the horizontal virtualized lists, on the three scroll behaviours. +Everything else is either direction-agnostic (vertical lists, the rows of a grid, which Yoga +mirrors on its own) or up to your app: the remote control mapping above, and the subtrees you +choose to pin left-to-right. diff --git a/packages/lib/src/spatial-navigation/components/virtualizedList/SpatialNavigationVirtualizedListRTL.test.tsx b/packages/lib/src/spatial-navigation/components/virtualizedList/SpatialNavigationVirtualizedListRTL.test.tsx new file mode 100644 index 00000000..d5be32af --- /dev/null +++ b/packages/lib/src/spatial-navigation/components/virtualizedList/SpatialNavigationVirtualizedListRTL.test.tsx @@ -0,0 +1,253 @@ +import { RenderResult, act, render, screen } from '@testing-library/react-native'; +import { I18nManager, Platform, StyleSheet, ViewStyle } from 'react-native'; +import { ReactTestInstance } from 'react-test-renderer'; +import { TestButton } from '../tests/TestButton'; +import { SpatialNavigationRoot } from '../Root'; +import '../tests/helpers/configureTestRemoteControl'; +import { SpatialNavigationVirtualizedList } from './SpatialNavigationVirtualizedList'; +import { ScrollBehavior } from './VirtualizedList'; +import { DefaultFocus } from '../../context/DefaultFocusContext'; +import testRemoteControlManager from '../tests/helpers/testRemoteControlManager'; +import { setComponentLayoutSize } from '../../../testing/setComponentLayoutSize'; +import { NodeOrientation } from '../../types/orientation'; + +/** + * A horizontal virtualized list on a mirrored layout. + * + * Every item is laid at `left: 0` plus a `translateX` of `index × size`, and + * the list scrolls by translating its container by `-offset`. React Native + * rewrites that `left: 0` anchor into `right: 0` on a mirrored tree, but the + * translations are geometric: without the two signs flipped, the items are + * pushed rightwards from the right anchor, off screen. + */ + +const ITEM_SIZE = 100; +const LIST_TEST_ID = 'test-list'; +const NUMBER_OF_ITEMS = 10; + +const setLayoutDirection = (isRTL: boolean) => + Object.defineProperty(I18nManager, 'isRTL', { + value: isRTL, + configurable: true, + writable: true, + }); + +/** Runs every test of the enclosing `describe` with the layout mirrored (or not). */ +const mockLayoutDirection = (isRTL: boolean) => { + const originalDirection = I18nManager.isRTL; + + beforeEach(() => setLayoutDirection(isRTL)); + afterEach(() => setLayoutDirection(originalDirection)); +}; + +const expectButtonToHaveFocus = (component: RenderResult, text: string) => { + const element = component.getByRole('button', { name: text }); + expect(element).toBeSelected(); +}; + +const expectListToHaveScroll = (listElement: ReactTestInstance, scrollValue: number) => + expect(listElement).toHaveStyle({ transform: [{ translateX: scrollValue }] }); + +const expectVerticalListToHaveScroll = (listElement: ReactTestInstance, scrollValue: number) => + expect(listElement).toHaveStyle({ transform: [{ translateY: scrollValue }] }); + +/** + * The `translateX` the list gave the container of the nth button: the closest + * ancestor of the button that the list translated, the item containers having + * no testID of their own. + */ +const getItemTranslateX = (index: number) => { + let node: ReactTestInstance | null = screen.getByText(`button ${index + 1}`); + + while (node) { + const style = StyleSheet.flatten(node.props?.style) as ViewStyle | undefined; + const transform = style?.transform as Array<{ translateX?: number }> | undefined; + const translateX = transform?.find((entry) => 'translateX' in entry)?.translateX; + if (translateX !== undefined) return translateX; + node = node.parent; + } + + return undefined; +}; + +describe('SpatialNavigationVirtualizedList on a mirrored layout', () => { + const data = Array.from({ length: NUMBER_OF_ITEMS }, () => ({ onSelect: () => undefined })); + + const renderItem = ({ item, index }: { item: { onSelect: () => void }; index: number }) => ( + + ); + + const renderList = ({ + rtl, + orientation = 'horizontal', + scrollBehavior, + }: { + rtl?: boolean; + orientation?: NodeOrientation; + scrollBehavior?: ScrollBehavior; + } = {}) => { + const component = render( + + + + + , + ); + act(() => jest.runAllTimers()); + setComponentLayoutSize(LIST_TEST_ID, component, { width: 300, height: 300 }); + + return component; + }; + + describe('when the layout is mirrored', () => { + mockLayoutDirection(true); + + it('stacks the items leftwards from the right anchor', async () => { + renderList(); + + expect(getItemTranslateX(0)).toBe(-0); + expect(getItemTranslateX(1)).toBe(-ITEM_SIZE); + expect(getItemTranslateX(2)).toBe(-2 * ITEM_SIZE); + }); + + it('slides the container rightwards when the focus moves to the next item', async () => { + const component = renderList(); + + const listElement = await component.findByTestId(LIST_TEST_ID); + expectListToHaveScroll(listElement, 0); + + testRemoteControlManager.handleRight(); + expectButtonToHaveFocus(component, 'button 2'); + expectListToHaveScroll(listElement, ITEM_SIZE); + + testRemoteControlManager.handleRight(); + expectButtonToHaveFocus(component, 'button 3'); + expectListToHaveScroll(listElement, 2 * ITEM_SIZE); + }); + + // The sign is flipped in the animation hook, the single point common to the + // three scroll behaviours, so each of them slides the other way. + it('slides the container rightwards with stick-to-end', async () => { + const component = renderList({ scrollBehavior: 'stick-to-end' }); + + const listElement = await component.findByTestId(LIST_TEST_ID); + testRemoteControlManager.handleRight(); + testRemoteControlManager.handleRight(); + expectButtonToHaveFocus(component, 'button 3'); + // The first three items fit on screen, so nothing has scrolled yet. + expectListToHaveScroll(listElement, 0); + + testRemoteControlManager.handleRight(); + expectButtonToHaveFocus(component, 'button 4'); + expectListToHaveScroll(listElement, ITEM_SIZE); + }); + + it('slides the container rightwards with jump-on-scroll', async () => { + const component = renderList({ scrollBehavior: 'jump-on-scroll' }); + + const listElement = await component.findByTestId(LIST_TEST_ID); + testRemoteControlManager.handleRight(); + testRemoteControlManager.handleRight(); + expectListToHaveScroll(listElement, 0); + + // Jumping to the next page of three items. + testRemoteControlManager.handleRight(); + expectButtonToHaveFocus(component, 'button 4'); + expectListToHaveScroll(listElement, 3 * ITEM_SIZE); + }); + + it('flips the same sign on the web animation path', async () => { + // The web hook returns the offset as a plain style rather than an + // Animated value, and has to mirror it the same way. + const originalOS = Platform.OS; + Object.defineProperty(Platform, 'OS', { value: 'web', configurable: true, writable: true }); + + try { + const component = renderList(); + + const listElement = await component.findByTestId(LIST_TEST_ID); + testRemoteControlManager.handleRight(); + expectButtonToHaveFocus(component, 'button 2'); + expectListToHaveScroll(listElement, ITEM_SIZE); + } finally { + Object.defineProperty(Platform, 'OS', { + value: originalOS, + configurable: true, + writable: true, + }); + } + }); + + it('renders and virtualizes the same items as on a left-to-right layout', async () => { + const component = renderList(); + + testRemoteControlManager.handleRight(); + testRemoteControlManager.handleRight(); + testRemoteControlManager.handleRight(); + expectButtonToHaveFocus(component, 'button 4'); + + expect(screen.queryByText('button 1')).toBeFalsy(); + expect(screen.getByText('button 2')).toBeTruthy(); + expect(screen.getByText('button 8')).toBeTruthy(); + expect(screen.queryByText('button 9')).toBeFalsy(); + }); + + it('keeps the left-to-right layout of a list told rtl={false}', async () => { + // A list pinned with `direction: 'ltr'` (a time axis, playback controls) + // lives in a subtree where React Native keeps the `left: 0` anchor on + // the left. The library cannot see the resolved direction of a subtree, + // so the app has to tell it. + const component = renderList({ rtl: false }); + + expect(getItemTranslateX(1)).toBe(ITEM_SIZE); + + const listElement = await component.findByTestId(LIST_TEST_ID); + testRemoteControlManager.handleRight(); + expectListToHaveScroll(listElement, -ITEM_SIZE); + }); + + it('leaves a vertical list untouched', async () => { + const component = renderList({ orientation: 'vertical' }); + + expect(getItemTranslateX(1)).toBeUndefined(); + + const listElement = await component.findByTestId(LIST_TEST_ID); + testRemoteControlManager.handleDown(); + expectButtonToHaveFocus(component, 'button 2'); + expectVerticalListToHaveScroll(listElement, -ITEM_SIZE); + }); + }); + + describe('when the layout is not mirrored', () => { + mockLayoutDirection(false); + + it('stacks the items rightwards and slides the container leftwards', async () => { + const component = renderList(); + + expect(getItemTranslateX(1)).toBe(ITEM_SIZE); + + const listElement = await component.findByTestId(LIST_TEST_ID); + testRemoteControlManager.handleRight(); + expectButtonToHaveFocus(component, 'button 2'); + expectListToHaveScroll(listElement, -ITEM_SIZE); + }); + + it('lays a list told rtl={true} out for a mirrored layout', async () => { + const component = renderList({ rtl: true }); + + expect(getItemTranslateX(1)).toBe(-ITEM_SIZE); + + const listElement = await component.findByTestId(LIST_TEST_ID); + testRemoteControlManager.handleRight(); + expectListToHaveScroll(listElement, ITEM_SIZE); + }); + }); +}); diff --git a/packages/lib/src/spatial-navigation/components/virtualizedList/VirtualizedList.tsx b/packages/lib/src/spatial-navigation/components/virtualizedList/VirtualizedList.tsx index 12a599de..f26ddec0 100644 --- a/packages/lib/src/spatial-navigation/components/virtualizedList/VirtualizedList.tsx +++ b/packages/lib/src/spatial-navigation/components/virtualizedList/VirtualizedList.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo } from 'react'; -import { Animated, StyleSheet, View, ViewStyle, Platform } from 'react-native'; +import { Animated, I18nManager, StyleSheet, View, ViewStyle, Platform } from 'react-native'; import { getRange } from './helpers/getRange'; import { useVirtualizedListAnimation, @@ -49,6 +49,23 @@ export interface VirtualizedListProps { listSizeInPx: number; scrollBehavior?: ScrollBehavior; testID?: string; + /** + * Lay a horizontal list out for a mirrored layout: the first item sits at the + * right edge and the list scrolls leftwards. + * + * Defaults to `I18nManager.isRTL` on native. On react-native-web the layout + * direction comes from the DOM and `I18nManager` exposes no `isRTL`, so the + * default there is left-to-right: pass this prop explicitly to mirror a list + * on the web. + * + * Pass `false` for a horizontal list that must stay left-to-right inside a + * mirrored app (a time axis, playback controls, anything pinned with + * `direction: 'ltr'`), since React Native keeps the `left: 0` anchor of the + * items on the left inside such a subtree. + * + * Ignored on a vertical list, which translates on the Y axis. + */ + rtl?: boolean; } const useOnEndReached = ({ @@ -90,6 +107,7 @@ const ItemContainerWithAnimatedStyle = typedMemo( renderItem, itemSize, vertical, + mirrored, data, }: { item: T; @@ -97,6 +115,8 @@ const ItemContainerWithAnimatedStyle = typedMemo( renderItem: VirtualizedListProps['renderItem']; itemSize: number | ((item: T) => number); vertical: boolean; + /** Horizontal list laid out for a mirrored layout. */ + mirrored: boolean; data: T[]; }) => { const computeOffset = useCallback( @@ -107,15 +127,26 @@ const ItemContainerWithAnimatedStyle = typedMemo( [data, itemSize], ); + // On a mirrored tree React Native rewrites the `left: 0` anchor of + // `styles.item` into `right: 0` on its own, but this translation is + // geometric: it would push every item rightwards from that anchor, off + // screen. Stack the items leftwards instead. Horizontal lists only, the + // non-mirrored path being unchanged. const style = useMemo( () => StyleSheet.flatten([ styles.item, vertical ? { transform: [{ translateY: computeOffset(item, index) }] } - : { transform: [{ translateX: computeOffset(item, index) }] }, + : { + transform: [ + { + translateX: mirrored ? -computeOffset(item, index) : computeOffset(item, index), + }, + ], + }, ]), - [computeOffset, item, index, vertical], + [computeOffset, item, index, vertical, mirrored], ); return {renderItem({ item, index })}; }, @@ -147,6 +178,7 @@ export const VirtualizedList = typedMemo( listSizeInPx, scrollBehavior = 'stick-to-start', testID, + rtl, }: VirtualizedListProps) => { const numberOfItemsVisibleOnScreen = getNumberOfItemsVisibleOnScreen({ data, @@ -169,6 +201,12 @@ export const VirtualizedList = typedMemo( }); const vertical = orientation === 'vertical'; + // The one place the reading direction is decided for this list. The item + // containers and the scroll animation only receive it. + // `I18nManager.isRTL` is undefined on react-native-web, where the direction + // comes from the DOM rather than from a native flag, so the default there is + // left-to-right and the app has to pass `rtl` explicitly. + const mirrored = !vertical && (rtl ?? Boolean(I18nManager.isRTL)); const totalVirtualizedListSize = useMemo( () => getSizeInPxFromOneItemToAnother(data, itemSize, 0, data.length), @@ -203,12 +241,14 @@ export const VirtualizedList = typedMemo( ? useWebVirtualizedListAnimation({ currentlyFocusedItemIndex, vertical, + mirrored, scrollDuration, scrollOffsetsArray: allScrollOffsets, }) : useVirtualizedListAnimation({ currentlyFocusedItemIndex, vertical, + mirrored, scrollDuration, scrollOffsetsArray: allScrollOffsets, }); @@ -274,6 +314,7 @@ export const VirtualizedList = typedMemo( index={index} itemSize={itemSize} vertical={vertical} + mirrored={mirrored} data={data} /> ); diff --git a/packages/lib/src/spatial-navigation/components/virtualizedList/hooks/useVirtualizedListAnimation.ts b/packages/lib/src/spatial-navigation/components/virtualizedList/hooks/useVirtualizedListAnimation.ts index c2c033f1..e9e47764 100644 --- a/packages/lib/src/spatial-navigation/components/virtualizedList/hooks/useVirtualizedListAnimation.ts +++ b/packages/lib/src/spatial-navigation/components/virtualizedList/hooks/useVirtualizedListAnimation.ts @@ -5,11 +5,18 @@ import { TypeVirtualizedListAnimation } from '../../../types/TypeVirtualizedList export const useVirtualizedListAnimation: TypeVirtualizedListAnimation = ({ currentlyFocusedItemIndex, vertical = false, + mirrored = false, scrollDuration, scrollOffsetsArray, }) => { const translation = useRef(new Animated.Value(0)).current; - const newTranslationValue = scrollOffsetsArray[currentlyFocusedItemIndex]; + // The scroll offsets are computed as `-offset`, a slide to the left that + // reveals the items on the right. On a mirrored layout the items stack + // leftwards (see `ItemContainerWithAnimatedStyle`), so the container slides + // to the right instead. Single injection point for the three scroll + // behaviours; the non-mirrored path is unchanged. + const scrollOffset = scrollOffsetsArray[currentlyFocusedItemIndex]; + const newTranslationValue = mirrored ? -scrollOffset : scrollOffset; useEffect(() => { Animated.timing(translation, { @@ -28,11 +35,17 @@ export const useVirtualizedListAnimation: TypeVirtualizedListAnimation = ({ export const useWebVirtualizedListAnimation: TypeVirtualizedListAnimation = ({ currentlyFocusedItemIndex, vertical = false, + mirrored = false, scrollDuration, scrollOffsetsArray, }) => { const animationDuration = `${scrollDuration}ms`; - const newTranslationValue = scrollOffsetsArray[currentlyFocusedItemIndex]; + // Same reasoning as the native hook above: react-native-web swaps `left` and + // `right` when the writing direction of the subtree is right-to-left, so the + // container slides the other way. The list is told through the `rtl` prop, + // `I18nManager` carrying no direction on the web. + const scrollOffset = scrollOffsetsArray[currentlyFocusedItemIndex]; + const newTranslationValue = mirrored ? -scrollOffset : scrollOffset; return { transitionDuration: animationDuration, diff --git a/packages/lib/src/spatial-navigation/types/TypeVirtualizedListAnimation.ts b/packages/lib/src/spatial-navigation/types/TypeVirtualizedListAnimation.ts index d1d778d1..fb48e8c5 100644 --- a/packages/lib/src/spatial-navigation/types/TypeVirtualizedListAnimation.ts +++ b/packages/lib/src/spatial-navigation/types/TypeVirtualizedListAnimation.ts @@ -3,6 +3,8 @@ import { ViewStyle, Animated } from 'react-native'; export type TypeVirtualizedListAnimation = (args: { currentlyFocusedItemIndex: number; vertical?: boolean; + /** Horizontal list laid out for a mirrored layout. */ + mirrored?: boolean; scrollDuration: number; scrollOffsetsArray: number[]; }) => Animated.WithAnimatedValue;