Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,139 @@ describe('SpatialNavigationVirtualizedList', () => {
});
});

describe('stick-to-center', () => {
it('handles correctly stick-to-center lists', async () => {
const component = render(
<SpatialNavigationRoot>
<DefaultFocus>
<SpatialNavigationVirtualizedList
testID="test-list"
renderItem={renderItem}
data={data}
itemSize={100}
numberOfRenderedItems={5}
numberOfItemsVisibleOnScreen={3}
scrollBehavior="stick-to-center"
/>
</DefaultFocus>
</SpatialNavigationRoot>,
);
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(
<SpatialNavigationRoot>
<DefaultFocus>
<SpatialNavigationVirtualizedList
testID="test-list"
renderItem={renderItem}
data={data.slice(0, 3)}
itemSize={100}
numberOfRenderedItems={5}
numberOfItemsVisibleOnScreen={3}
scrollBehavior="stick-to-center"
/>
</DefaultFocus>
</SpatialNavigationRoot>,
);
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(
<SpatialNavigationRoot>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> {
data: T[];
renderItem: (args: { item: T; index: number }) => JSX.Element;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,48 @@ const computeStickToStartTranslation = <T>({
return -scrollOffset;
};

const computeStickToCenterTranslation = <T>({
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 = <T>({
currentlyFocusedItemIndex,
itemSizeInPx,
Expand Down Expand Up @@ -102,6 +144,13 @@ export const computeTranslation = <T>({
data,
maxPossibleLeftAlignedIndex,
});
case 'stick-to-center':
return computeStickToCenterTranslation({
currentlyFocusedItemIndex,
itemSizeInPx,
data,
listSizeInPx,
});
case 'stick-to-end':
return computeStickToEndTranslation({
currentlyFocusedItemIndex,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,8 +16,11 @@ export const getSizeInPxFromOneItemToAnother = <T>(
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;
};