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;