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;