diff --git a/docs/api.md b/docs/api.md
index a5272d4d..88e2db66 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -249,7 +249,7 @@ It also ensures that the scroll event is propagated properly to parent ScrollVie
| `orientation` | `'horizontal' \| 'vertical'` | The orientation of the list. Defaults to `'horizontal'`. |
| `nbMaxOfItems` | `number` | The total number of expected items for infinite scroll. This helps with aligning items and is used for pagination. If not provided, it defaults to the length of the data array. |
| `scrollDuration` | `number` | The duration of a scrolling animation inside the VirtualizedList. Defaults to 200ms. |
-| `scrollBehavior` | `'stick-to-start' \| 'stick-to-end' \| 'jump-on-scroll'` | Determines the scrolling behavior. Defaults to `'stick-to-start'`. `'stick-to-start'` and `'stick-to-end'` fix the focused item at the beginning or the end of the visible items on screen. `jump-on-scroll` jumps from `numberOfItemsVisibleOnScreen` items when needed. Warning `jump-on-scroll` is not compatible with dynamic item size. |
+| `scrollBehavior` | `'stick-to-start' \| 'stick-to-center' \| 'stick-to-end' \| 'jump-on-scroll'` | Determines the scrolling behavior. Defaults to `'stick-to-start'`. `'stick-to-start'` and `'stick-to-end'` fix the focused item at the beginning or the end of the visible items on screen. `'stick-to-end'` fixes the item at the center of the screen when possible, otherwise sticking to the sides of the list instead. `jump-on-scroll` jumps from `numberOfItemsVisibleOnScreen` items when needed. Warning `jump-on-scroll` is not compatible with dynamic item size. |
| `ascendingArrow` | `ReactElement` | For web TVs cursor handling. Optional component to display as the arrow to scroll on the ascending order. |
| `ascendingArrowContainerStyle` | `ViewStyle` | For web TVs cursor handling. Style of the view which wraps the ascending arrow. Hover this view will trigger the scroll. |
| `descendingArrow` | `ReactElement` | For web TVs cursor handling. Optional component to display as the arrow to scroll on the descending order. |
diff --git a/packages/lib/src/spatial-navigation/components/virtualizedList/SpatialNavigationVirtualizedList.test.tsx b/packages/lib/src/spatial-navigation/components/virtualizedList/SpatialNavigationVirtualizedList.test.tsx
index 0d65b311..01a31329 100644
--- a/packages/lib/src/spatial-navigation/components/virtualizedList/SpatialNavigationVirtualizedList.test.tsx
+++ b/packages/lib/src/spatial-navigation/components/virtualizedList/SpatialNavigationVirtualizedList.test.tsx
@@ -324,6 +324,139 @@ describe('SpatialNavigationVirtualizedList', () => {
});
});
+ describe('stick-to-center', () => {
+ it('handles correctly stick-to-center lists', async () => {
+ const component = render(
+
+
+
+
+ ,
+ );
+ act(() => jest.runAllTimers());
+
+ setComponentLayoutSize(listTestId, component, { width: 300, height: 300 });
+
+ const listElement = await component.findByTestId(listTestId);
+ expectListToHaveScroll(listElement, 0);
+ // The size of the list should be the sum of the item sizes (virtualized or not)
+ expect(listElement).toHaveStyle({ width: 1000 });
+
+ testRemoteControlManager.handleRight();
+ expectButtonToHaveFocus(component, 'button 2');
+ expectListToHaveScroll(listElement, 0);
+
+ expect(screen.getByText('button 1')).toBeTruthy();
+ expect(screen.getByText('button 5')).toBeTruthy();
+ expect(screen.queryByText('button 6')).toBeFalsy();
+
+ testRemoteControlManager.handleRight();
+ expectButtonToHaveFocus(component, 'button 3');
+ expectListToHaveScroll(listElement, -100);
+
+ expect(screen.getByText('button 1')).toBeTruthy();
+ expect(screen.getByText('button 5')).toBeTruthy();
+ expect(screen.queryByText('button 6')).toBeFalsy();
+
+ testRemoteControlManager.handleRight();
+ expectButtonToHaveFocus(component, 'button 4');
+ expectListToHaveScroll(listElement, -200);
+
+ expect(screen.queryByText('button 1')).toBeFalsy();
+ expect(screen.getByText('button 2')).toBeTruthy();
+ expect(screen.getByText('button 6')).toBeTruthy();
+ expect(screen.queryByText('button 7')).toBeFalsy();
+
+ testRemoteControlManager.handleRight();
+ expectButtonToHaveFocus(component, 'button 5');
+ expectListToHaveScroll(listElement, -300);
+
+ testRemoteControlManager.handleRight();
+ expectButtonToHaveFocus(component, 'button 6');
+ expectListToHaveScroll(listElement, -400);
+
+ testRemoteControlManager.handleRight();
+ expectButtonToHaveFocus(component, 'button 7');
+ expectListToHaveScroll(listElement, -500);
+
+ testRemoteControlManager.handleRight();
+ expectButtonToHaveFocus(component, 'button 8');
+ expectListToHaveScroll(listElement, -600);
+
+ testRemoteControlManager.handleRight();
+ expectButtonToHaveFocus(component, 'button 9');
+ expectListToHaveScroll(listElement, -700);
+
+ testRemoteControlManager.handleRight();
+ expectButtonToHaveFocus(component, 'button 10');
+ expectListToHaveScroll(listElement, -700);
+ });
+
+ it('handles correctly stick-to-center lists with elements < visible on screen', async () => {
+ const component = render(
+
+
+
+
+ ,
+ );
+ act(() => jest.runAllTimers());
+
+ setComponentLayoutSize(listTestId, component, { width: 300, height: 300 });
+
+ const listElement = await component.findByTestId(listTestId);
+ expectListToHaveScroll(listElement, 0);
+ // The size of the list should be the sum of the item sizes (virtualized or not)
+ expect(listElement).toHaveStyle({ width: 300 });
+
+ testRemoteControlManager.handleRight();
+ expectButtonToHaveFocus(component, 'button 2');
+ expectListToHaveScroll(listElement, 0);
+
+ expect(screen.queryByText('button 1')).toBeTruthy();
+ expect(screen.getByText('button 2')).toBeTruthy();
+ expect(screen.getByText('button 3')).toBeTruthy();
+
+ testRemoteControlManager.handleRight();
+ expectButtonToHaveFocus(component, 'button 3');
+ expectListToHaveScroll(listElement, 0);
+
+ expect(screen.queryByText('button 1')).toBeTruthy();
+ expect(screen.getByText('button 2')).toBeTruthy();
+ expect(screen.getByText('button 3')).toBeTruthy();
+
+ testRemoteControlManager.handleRight();
+ expectListToHaveScroll(listElement, 0);
+
+ expect(screen.queryByText('button 1')).toBeTruthy();
+ expect(screen.getByText('button 2')).toBeTruthy();
+ expect(screen.getByText('button 3')).toBeTruthy();
+
+ // We just reached the max of the list
+ testRemoteControlManager.handleRight();
+ testRemoteControlManager.handleRight();
+ testRemoteControlManager.handleRight();
+ testRemoteControlManager.handleRight();
+ expectListToHaveScroll(listElement, 0);
+ });
+ });
+
it('handles correctly RIGHT and RENDERS new elements accordingly while deleting elements that are too far from scroll when on stick to end scroll', async () => {
const component = render(
diff --git a/packages/lib/src/spatial-navigation/components/virtualizedList/VirtualizedList.tsx b/packages/lib/src/spatial-navigation/components/virtualizedList/VirtualizedList.tsx
index b41610d7..a5e5e518 100644
--- a/packages/lib/src/spatial-navigation/components/virtualizedList/VirtualizedList.tsx
+++ b/packages/lib/src/spatial-navigation/components/virtualizedList/VirtualizedList.tsx
@@ -11,7 +11,7 @@ import { getLastLeftItemIndex, getLastRightItemIndex } from './helpers/getLastIt
import { getSizeInPxFromOneItemToAnother } from './helpers/getSizeInPxFromOneItemToAnother';
import { computeAllScrollOffsets } from './helpers/createScrollOffsetArray';
-export type ScrollBehavior = 'stick-to-start' | 'stick-to-end' | 'jump-on-scroll';
+export type ScrollBehavior = 'stick-to-start' | 'stick-to-center' | 'stick-to-end' | 'jump-on-scroll';
export interface VirtualizedListProps {
data: T[];
renderItem: (args: { item: T; index: number }) => JSX.Element;
diff --git a/packages/lib/src/spatial-navigation/components/virtualizedList/helpers/computeTranslation.ts b/packages/lib/src/spatial-navigation/components/virtualizedList/helpers/computeTranslation.ts
index 4dfeb1f3..73d37559 100644
--- a/packages/lib/src/spatial-navigation/components/virtualizedList/helpers/computeTranslation.ts
+++ b/packages/lib/src/spatial-navigation/components/virtualizedList/helpers/computeTranslation.ts
@@ -19,6 +19,48 @@ const computeStickToStartTranslation = ({
return -scrollOffset;
};
+const computeStickToCenterTranslation = ({
+ currentlyFocusedItemIndex,
+ itemSizeInPx,
+ data,
+ listSizeInPx,
+}: {
+ currentlyFocusedItemIndex: number;
+ itemSizeInPx: number | ((item: T) => number);
+ data: T[];
+ listSizeInPx: number;
+}) => {
+ const currentlyFocusedItemSize =
+ typeof itemSizeInPx === 'function'
+ ? itemSizeInPx(data[currentlyFocusedItemIndex])
+ : itemSizeInPx;
+
+ const sizeOfListFromStartToCurrentlyFocusedItem = getSizeInPxFromOneItemToAnother(
+ data,
+ itemSizeInPx,
+ 0,
+ currentlyFocusedItemIndex,
+ );
+ const sizeOfListFromEndToCurrentlyFocusedItem = getSizeInPxFromOneItemToAnother(
+ data,
+ itemSizeInPx,
+ data.length - 1,
+ currentlyFocusedItemIndex,
+ );
+
+ if (sizeOfListFromStartToCurrentlyFocusedItem < listSizeInPx / 2) {
+ return 0;
+ }
+
+ if (sizeOfListFromEndToCurrentlyFocusedItem < listSizeInPx / 2) {
+ return -sizeOfListFromStartToCurrentlyFocusedItem + listSizeInPx - sizeOfListFromEndToCurrentlyFocusedItem - currentlyFocusedItemSize;
+ }
+
+ const scrollOffset =
+ sizeOfListFromStartToCurrentlyFocusedItem - (listSizeInPx / 2) + (currentlyFocusedItemSize / 2);
+ return -scrollOffset;
+};
+
const computeStickToEndTranslation = ({
currentlyFocusedItemIndex,
itemSizeInPx,
@@ -102,6 +144,13 @@ export const computeTranslation = ({
data,
maxPossibleLeftAlignedIndex,
});
+ case 'stick-to-center':
+ return computeStickToCenterTranslation({
+ currentlyFocusedItemIndex,
+ itemSizeInPx,
+ data,
+ listSizeInPx,
+ });
case 'stick-to-end':
return computeStickToEndTranslation({
currentlyFocusedItemIndex,
diff --git a/packages/lib/src/spatial-navigation/components/virtualizedList/helpers/getRange.ts b/packages/lib/src/spatial-navigation/components/virtualizedList/helpers/getRange.ts
index bf1d9f49..22aeb376 100644
--- a/packages/lib/src/spatial-navigation/components/virtualizedList/helpers/getRange.ts
+++ b/packages/lib/src/spatial-navigation/components/virtualizedList/helpers/getRange.ts
@@ -108,6 +108,11 @@ const getRawStartAndEndIndexes = ({
1 +
halfNumberOfItemsNotVisible,
};
+ case 'stick-to-center':
+ return {
+ rawStartIndex: currentlyFocusedItemIndex - (halfNumberOfItemsNotVisible + 1),
+ rawEndIndex: currentlyFocusedItemIndex + (halfNumberOfItemsNotVisible + 1),
+ };
case 'stick-to-end':
return {
rawStartIndex:
diff --git a/packages/lib/src/spatial-navigation/components/virtualizedList/helpers/getSizeInPxFromOneItemToAnother.ts b/packages/lib/src/spatial-navigation/components/virtualizedList/helpers/getSizeInPxFromOneItemToAnother.ts
index d4b39631..9895015e 100644
--- a/packages/lib/src/spatial-navigation/components/virtualizedList/helpers/getSizeInPxFromOneItemToAnother.ts
+++ b/packages/lib/src/spatial-navigation/components/virtualizedList/helpers/getSizeInPxFromOneItemToAnother.ts
@@ -2,6 +2,8 @@
* This function is used to compute the size in pixels of a range of items in a list.
* If you want the size taken by items from index 0 to 5, you can call this function with
* start = 0 and end = 5. The size is computed by summing the size of each item in the range.
+ * Similarly, if you want to calculate from the other direct, you can call this function
+ * with start = 5 and end = 0.
* @param data The list of items
* @param itemSizeInPx The size of an item in pixels. It can be a number or a function that takes an item and returns a number.
* @param start The start index of the range
@@ -14,8 +16,11 @@ export const getSizeInPxFromOneItemToAnother = (
start: number,
end: number,
): number => {
+ const startIndex = start < end ? start : end;
+ const endIndex = end > start ? end : start;
+
if (typeof itemSizeInPx === 'function') {
- return data.slice(start, end).reduce((acc, item) => acc + itemSizeInPx(item), 0);
+ return data.slice(startIndex, endIndex).reduce((acc, item) => acc + itemSizeInPx(item), 0);
}
- return data.slice(start, end).length * itemSizeInPx;
+ return data.slice(startIndex, endIndex).length * itemSizeInPx;
};