Skip to content

Commit 3ff0652

Browse files
slvvnPierre Poupin
authored andcommitted
feat: add support for stick-to-center in VirtualizedList
1 parent 3361881 commit 3ff0652

6 files changed

Lines changed: 200 additions & 4 deletions

File tree

docs/api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ It also ensures that the scroll event is propagated properly to parent ScrollVie
248248
| `orientation` | `'horizontal' \| 'vertical'` | The orientation of the list. Defaults to `'horizontal'`. |
249249
| `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. |
250250
| `scrollDuration` | `number` | The duration of a scrolling animation inside the VirtualizedList. Defaults to 200ms. |
251-
| `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. |
251+
| `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. |
252252
| `ascendingArrow` | `ReactElement` | For web TVs cursor handling. Optional component to display as the arrow to scroll on the ascending order. |
253253
| `ascendingArrowContainerStyle` | `ViewStyle` | For web TVs cursor handling. Style of the view which wraps the ascending arrow. Hover this view will trigger the scroll. |
254254
| `descendingArrow` | `ReactElement` | For web TVs cursor handling. Optional component to display as the arrow to scroll on the descending order. |

packages/lib/src/spatial-navigation/components/virtualizedList/SpatialNavigationVirtualizedList.test.tsx

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,143 @@ describe('SpatialNavigationVirtualizedList', () => {
325325
});
326326
});
327327

328+
329+
describe('stick-to-center', () => {
330+
it('handles correctly stick-to-center lists', async () => {
331+
const component = render(
332+
<SpatialNavigationRoot>
333+
<DefaultFocus>
334+
<SpatialNavigationVirtualizedList
335+
testID="test-list"
336+
renderItem={renderItem}
337+
data={data}
338+
itemSize={100}
339+
numberOfRenderedItems={5}
340+
numberOfItemsVisibleOnScreen={3}
341+
scrollBehavior="stick-to-center"
342+
/>
343+
</DefaultFocus>
344+
</SpatialNavigationRoot>,
345+
);
346+
act(() => jest.runAllTimers());
347+
348+
setComponentLayoutSize(listTestId, component, { width: 300, height: 300 });
349+
350+
const listElement = await component.findByTestId(listTestId);
351+
expectListToHaveScroll(listElement, 0);
352+
// The size of the list should be the sum of the item sizes (virtualized or not)
353+
expect(listElement).toHaveStyle({ width: 1000 });
354+
355+
// x x x X x x x x x x
356+
//|x[x x x]x|x x x x x
357+
358+
testRemoteControlManager.handleRight();
359+
expectButtonToHaveFocus(component, 'button 2');
360+
expectListToHaveScroll(listElement, 0);
361+
362+
expect(screen.getByText('button 1')).toBeTruthy();
363+
expect(screen.getByText('button 5')).toBeTruthy();
364+
expect(screen.queryByText('button 6')).toBeFalsy();
365+
366+
testRemoteControlManager.handleRight();
367+
expectButtonToHaveFocus(component, 'button 3');
368+
expectListToHaveScroll(listElement, -100);
369+
370+
expect(screen.getByText('button 1')).toBeTruthy();
371+
expect(screen.getByText('button 5')).toBeTruthy();
372+
expect(screen.queryByText('button 6')).toBeFalsy();
373+
374+
testRemoteControlManager.handleRight();
375+
expectButtonToHaveFocus(component, 'button 4');
376+
expectListToHaveScroll(listElement, -200);
377+
378+
expect(screen.queryByText('button 1')).toBeFalsy();
379+
expect(screen.getByText('button 2')).toBeTruthy();
380+
expect(screen.getByText('button 6')).toBeTruthy();
381+
expect(screen.queryByText('button 7')).toBeFalsy();
382+
383+
testRemoteControlManager.handleRight();
384+
expectButtonToHaveFocus(component, 'button 5');
385+
expectListToHaveScroll(listElement, -300);
386+
387+
testRemoteControlManager.handleRight();
388+
expectButtonToHaveFocus(component, 'button 6');
389+
expectListToHaveScroll(listElement, -400);
390+
391+
testRemoteControlManager.handleRight();
392+
expectButtonToHaveFocus(component, 'button 7');
393+
expectListToHaveScroll(listElement, -500);
394+
395+
testRemoteControlManager.handleRight();
396+
expectButtonToHaveFocus(component, 'button 8');
397+
expectListToHaveScroll(listElement, -600);
398+
399+
testRemoteControlManager.handleRight();
400+
expectButtonToHaveFocus(component, 'button 9');
401+
expectListToHaveScroll(listElement, -700);
402+
403+
testRemoteControlManager.handleRight();
404+
expectButtonToHaveFocus(component, 'button 10');
405+
expectListToHaveScroll(listElement, -700);
406+
});
407+
408+
it('handles correctly stick-to-center lists with elements < visible on screen', async () => {
409+
const component = render(
410+
<SpatialNavigationRoot>
411+
<DefaultFocus>
412+
<SpatialNavigationVirtualizedList
413+
testID="test-list"
414+
renderItem={renderItem}
415+
data={data.slice(0, 3)}
416+
itemSize={100}
417+
numberOfRenderedItems={5}
418+
numberOfItemsVisibleOnScreen={3}
419+
scrollBehavior="stick-to-center"
420+
/>
421+
</DefaultFocus>
422+
</SpatialNavigationRoot>,
423+
);
424+
act(() => jest.runAllTimers());
425+
426+
setComponentLayoutSize(listTestId, component, { width: 300, height: 300 });
427+
428+
const listElement = await component.findByTestId(listTestId);
429+
expectListToHaveScroll(listElement, 0);
430+
// The size of the list should be the sum of the item sizes (virtualized or not)
431+
expect(listElement).toHaveStyle({ width: 300 });
432+
433+
testRemoteControlManager.handleRight();
434+
expectButtonToHaveFocus(component, 'button 2');
435+
expectListToHaveScroll(listElement, 0);
436+
437+
expect(screen.queryByText('button 1')).toBeTruthy();
438+
expect(screen.getByText('button 2')).toBeTruthy();
439+
expect(screen.getByText('button 3')).toBeTruthy();
440+
441+
testRemoteControlManager.handleRight();
442+
expectButtonToHaveFocus(component, 'button 3');
443+
expectListToHaveScroll(listElement, 0);
444+
445+
expect(screen.queryByText('button 1')).toBeTruthy();
446+
expect(screen.getByText('button 2')).toBeTruthy();
447+
expect(screen.getByText('button 3')).toBeTruthy();
448+
449+
testRemoteControlManager.handleRight();
450+
expectListToHaveScroll(listElement, 0);
451+
452+
expect(screen.queryByText('button 1')).toBeTruthy();
453+
expect(screen.getByText('button 2')).toBeTruthy();
454+
expect(screen.getByText('button 3')).toBeTruthy();
455+
456+
// We just reached the max of the list
457+
testRemoteControlManager.handleRight();
458+
testRemoteControlManager.handleRight();
459+
testRemoteControlManager.handleRight();
460+
testRemoteControlManager.handleRight();
461+
expectListToHaveScroll(listElement, 0);
462+
});
463+
});
464+
328465
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 () => {
329466
const component = render(
330467
<SpatialNavigationRoot>

packages/lib/src/spatial-navigation/components/virtualizedList/VirtualizedList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { computeAllScrollOffsets } from './helpers/createScrollOffsetArray';
1212
import { getNumberOfItemsVisibleOnScreen } from './helpers/getNumberOfItemsVisibleOnScreen';
1313
import { getAdditionalNumberOfItemsRendered } from './helpers/getAdditionalNumberOfItemsRendered';
1414

15-
export type ScrollBehavior = 'stick-to-start' | 'stick-to-end' | 'jump-on-scroll';
15+
export type ScrollBehavior = 'stick-to-start' | 'stick-to-center' | 'stick-to-end' | 'jump-on-scroll';
1616
export interface VirtualizedListProps<T> {
1717
data: T[];
1818
renderItem: (args: { item: T; index: number }) => JSX.Element;

packages/lib/src/spatial-navigation/components/virtualizedList/helpers/computeTranslation.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,48 @@ const computeStickToStartTranslation = <T>({
1919
return -scrollOffset;
2020
};
2121

22+
const computeStickToCenterTranslation = <T>({
23+
currentlyFocusedItemIndex,
24+
itemSizeInPx,
25+
data,
26+
listSizeInPx,
27+
}: {
28+
currentlyFocusedItemIndex: number;
29+
itemSizeInPx: number | ((item: T) => number);
30+
data: T[];
31+
listSizeInPx: number;
32+
}) => {
33+
const currentlyFocusedItemSize =
34+
typeof itemSizeInPx === 'function'
35+
? itemSizeInPx(data[currentlyFocusedItemIndex])
36+
: itemSizeInPx;
37+
38+
const sizeOfListFromStartToCurrentlyFocusedItem = getSizeInPxFromOneItemToAnother(
39+
data,
40+
itemSizeInPx,
41+
0,
42+
currentlyFocusedItemIndex,
43+
);
44+
const sizeOfListFromEndToCurrentlyFocusedItem = getSizeInPxFromOneItemToAnother(
45+
data,
46+
itemSizeInPx,
47+
data.length - 1,
48+
currentlyFocusedItemIndex,
49+
);
50+
51+
if (sizeOfListFromStartToCurrentlyFocusedItem < listSizeInPx / 2) {
52+
return 0;
53+
}
54+
55+
if (sizeOfListFromEndToCurrentlyFocusedItem < listSizeInPx / 2) {
56+
return -sizeOfListFromStartToCurrentlyFocusedItem + listSizeInPx - sizeOfListFromEndToCurrentlyFocusedItem - currentlyFocusedItemSize;
57+
}
58+
59+
const scrollOffset =
60+
sizeOfListFromStartToCurrentlyFocusedItem - (listSizeInPx / 2) + (currentlyFocusedItemSize / 2);
61+
return -scrollOffset;
62+
};
63+
2264
const computeStickToEndTranslation = <T>({
2365
currentlyFocusedItemIndex,
2466
itemSizeInPx,
@@ -102,6 +144,13 @@ export const computeTranslation = <T>({
102144
data,
103145
maxPossibleLeftAlignedIndex,
104146
});
147+
case 'stick-to-center':
148+
return computeStickToCenterTranslation({
149+
currentlyFocusedItemIndex,
150+
itemSizeInPx,
151+
data,
152+
listSizeInPx,
153+
});
105154
case 'stick-to-end':
106155
return computeStickToEndTranslation({
107156
currentlyFocusedItemIndex,

packages/lib/src/spatial-navigation/components/virtualizedList/helpers/getRange.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ const getRawStartAndEndIndexes = ({
9090
1 +
9191
halfNumberOfItemsNotVisible,
9292
};
93+
case 'stick-to-center':
94+
return {
95+
rawStartIndex: currentlyFocusedItemIndex - (halfNumberOfItemsNotVisible + 1),
96+
rawEndIndex: currentlyFocusedItemIndex + (halfNumberOfItemsNotVisible + 1),
97+
};
9398
case 'stick-to-end':
9499
return {
95100
rawStartIndex:

packages/lib/src/spatial-navigation/components/virtualizedList/helpers/getSizeInPxFromOneItemToAnother.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* This function is used to compute the size in pixels of a range of items in a list.
33
* If you want the size taken by items from index 0 to 5, you can call this function with
44
* start = 0 and end = 5. The size is computed by summing the size of each item in the range.
5+
* Similarly, if you want to calculate from the other direct, you can call this function
6+
* with start = 5 and end = 0.
57
* @param data The list of items
68
* @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.
79
* @param start The start index of the range
@@ -14,8 +16,11 @@ export const getSizeInPxFromOneItemToAnother = <T>(
1416
start: number,
1517
end: number,
1618
): number => {
19+
const startIndex = start < end ? start : end;
20+
const endIndex = end > start ? end : start;
21+
1722
if (typeof itemSizeInPx === 'function') {
18-
return data.slice(start, end).reduce((acc, item) => acc + itemSizeInPx(item), 0);
23+
return data.slice(startIndex, endIndex).reduce((acc, item) => acc + itemSizeInPx(item), 0);
1924
}
20-
return data.slice(start, end).length * itemSizeInPx;
25+
return data.slice(startIndex, endIndex).length * itemSizeInPx;
2126
};

0 commit comments

Comments
 (0)