Skip to content

feat(virtualized-list): follow the reading direction on a mirrored layout - #228

Open
jmonsellier wants to merge 1 commit into
bamlab:mainfrom
jmonsellier:feat/rtl-virtualized-list
Open

jmonsellier wants to merge 1 commit into
bamlab:mainfrom
jmonsellier:feat/rtl-virtualized-list

Conversation

@jmonsellier

@jmonsellier jmonsellier commented Sep 10, 2026

Copy link
Copy Markdown

Closes #172.

The problem

On a mirrored layout (I18nManager.isRTL === true, an app translated into Arabic, Hebrew, Farsi
or Urdu), a horizontal SpatialNavigationVirtualizedList lays its items out off screen, and the
row scrolls the wrong way.

As you noted in #172, the list positions everything in JS: each item is position: 'absolute', left: 0 plus a translateX of index × size, and the list scrolls by translating its container
by -offset. On a mirrored tree React Native rewrites that left: 0 anchor into right: 0 on
its own, but the two translations are geometric: the items are pushed rightwards from the right
anchor, and the container slides away from the focused item.

The change

Two signs, decided once per list.

  1. The items stack leftwards from the right anchor: translateX: -(index × size).
  2. The container slides rightwards to reveal them: +offset instead of -offset.

The reading direction is read in one place, VirtualizedList, from a new rtl prop that defaults
to I18nManager.isRTL; ItemContainerWithAnimatedStyle and useVirtualizedListAnimation only
receive it. The animation hook is the single point common to the three scrollBehaviors, which is
why the sign is flipped there rather than in computeTranslation, whose helpers do not know the
orientation of the list.

The non-mirrored path is unchanged by construction, and a vertical list is never touched: it
translates on the Y axis, which no reading direction mirrors.

The default focus needs nothing: index 0 is drawn at the right edge on a mirrored layout, which is
the first item in reading order: the 😬 in your answer to #172 solves itself once the layout
follows the direction.

Why a prop and not the global flag alone

A horizontal list can live in a subtree pinned with direction: 'ltr': playback controls, which
follow the direction of the tape, or a time axis (an EPG grid, a timeline), which 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 keep their left: 0 anchor on the left. The library
cannot see the resolved direction of a subtree from JS, so the app tells it with rtl={false},
same contract as @legendapp/list. We hit this on the date strip of a channel timeline, and it is
the only reason the decision is not a plain global read.

Web

I18nManager carries no direction on react-native-web (it comes from the DOM), so a list defaults
to a left-to-right layout there and a web app passes rtl explicitly. useWebVirtualizedListAnimation
flips the same sign, since react-native-web swaps left and right when the writing direction of
the subtree is right-to-left.

Tests

SpatialNavigationVirtualizedListRTL.test.tsx, 10 cases through the public component and the real
remote control path: items stacked leftwards, container sliding the other way on each of the three
scrollBehaviors and on the web animation path, virtualization unchanged, rtl={false} keeping
the left-to-right layout inside a mirrored app, vertical list untouched, and the two non-mirrored
cases including rtl={true} in a left-to-right app.

Six of them fail on main and pass with the change; the others are there to pin the paths that
must not move. yarn test is green (lint, types, lib, example).

Docs

docs/rtl.md, linked from the README and from the rtl row of the API table. It also covers the
half this library does not own: LRUD navigates by logical index, so RIGHT means "next sibling",
which a mirrored layout draws on the left; the app has to swap the two horizontal directions in
its configureRemoteControl mapping. Snippet included.

Out of scope

A horizontal SpatialNavigationScrollView derives its scroll from measureLayout, whose
coordinates are physical, so it likely needs its own fix on a mirrored layout. I left it alone: I
have no mirrored surface using one to check a fix against, and #172 is about the lists. Happy to
open a separate issue if you want it tracked.

Open question

styles.item keeps its left: 0 anchor, so this relies on the default
I18nManager.swapLeftAndRightInRTL(true). Moving it to start: 0 would make the anchor follow the
direction resolved by Yoga for the subtree, independently of that flag, and would keep working
inside an ltr island. Happy to make that change if you prefer it; I kept left as the smaller
change, and the one this branch has been exercised with.

I can also add an RTL toggle to the example app if that helps you review.

…yout

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 bamlab#172
@jmonsellier
jmonsellier force-pushed the feat/rtl-virtualized-list branch from 3ec55b7 to 77eb96e Compare September 10, 2026 12:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Is the project ready for RTL, when switching to RTL will the lists be switched and the focus will be from right to left?

1 participant