Skip to content

Commit d9509dc

Browse files
authored
fix(lists): range rendered in jump-on-scroll and stick-to-end (#148)
* refactor: improve readability of formula * refactor: export to a function the computing of start and end indexes of range to render * fix: compute correct range for every type of scroll * doc: add doc on nbOfRenderedItems * refactor: simplify getRawStartAndEndIndexes * chore: fix test readability * chore: add TSDoc for getRawIndexes * chore: remove throw and log error instead to avoid breaking change * fix: use user input to compute range in jump on scroll
1 parent 1bafa52 commit d9509dc

6 files changed

Lines changed: 119 additions & 31 deletions

File tree

docs/api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ It also ensures that the scroll event is propagated properly to parent ScrollVie
240240
| `data` | `Array<T>` | The array of data items to render. ⚠️ You should memoize this array for maximum performance. A costly memo depends on it. |
241241
| `renderItem` | `(args: { item: T }) => JSX.Element` | A function that returns the JSX element to render for each item in the data array. The function receives an object with the item as a parameter. |
242242
| `itemSize` | `number \| ((item: T) => number)` | In case you specify a number it will behave like this : ff vertical, the height of an item; otherwise, the width. You can also specify a function which needs to return for each item of `data` its size in pixel in order for the list to handle various item sizes. ⚠️ You should memoize this function for maximal performances. An important memo depends on it. |
243-
| `numberOfRenderedItems` | `number` | The number of items to be rendered (virtualization size). |
243+
| `numberOfRenderedItems` | `number` | The number of items to be rendered (virtualization size). ⚠️ It must be at least equal to `numberOfItemsVisibleOnScreen +2` or when using jump-on-scroll : `(2 * numberOfItemsVisibleOnScreen) + 1` to ensure correct rendering. |
244244
| `numberOfItemsVisibleOnScreen` | `number` | The number of items visible on the screen. This helps determine how to slice the data and when to stop the scroll at the end of the list. |
245245
| `onEndReached` | `() => void` | An optional callback function that is called when the user reaches the end of the list. Helps with pagination. |
246246
| `onEndReachedThresholdItemsNumber` | `number` | The number of items left to display before triggering the `onEndReached` callback. Defaults to 3. |

packages/lib/src/spatial-navigation/components/virtualizedGrid/SpatialNavigationVirtualizedGrid.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ describe('SpatialNavigationVirtualizedGrid', () => {
231231
renderItem={renderItem}
232232
data={createDataArray(19)}
233233
itemHeight={100}
234-
numberOfRenderedRows={5}
234+
numberOfRenderedRows={7}
235235
numberOfRowsVisibleOnScreen={3}
236236
numberOfColumns={3}
237237
testID="test-grid"

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

Lines changed: 42 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -351,24 +351,27 @@ describe('SpatialNavigationVirtualizedList', () => {
351351
expectButtonToHaveFocus(component, 'button 3');
352352
expectListToHaveScroll(listElement, 0);
353353

354-
expect(screen.queryByText('button 1')).toBeFalsy();
355-
expect(screen.getByText('button 2')).toBeTruthy();
356-
expect(screen.getByText('button 6')).toBeTruthy();
357-
expect(screen.queryByText('button 7')).toBeFalsy();
354+
expect(screen.getByText('button 1')).toBeTruthy();
355+
expect(screen.getByText('button 5')).toBeTruthy();
356+
expect(screen.queryByText('button 6')).toBeFalsy();
358357

359358
testRemoteControlManager.handleRight();
360359
expectButtonToHaveFocus(component, 'button 4');
361360
expectListToHaveScroll(listElement, -100);
362361

363-
expect(screen.queryByText('button 2')).toBeFalsy();
364-
expect(screen.getByText('button 3')).toBeTruthy();
365-
expect(screen.getByText('button 7')).toBeTruthy();
366-
expect(screen.queryByText('button 8')).toBeFalsy();
362+
expect(screen.getByText('button 1')).toBeTruthy();
363+
expect(screen.getByText('button 5')).toBeTruthy();
364+
expect(screen.queryByText('button 6')).toBeFalsy();
367365

368366
testRemoteControlManager.handleRight();
369367
expectButtonToHaveFocus(component, 'button 5');
370368
expectListToHaveScroll(listElement, -200);
371369

370+
expect(screen.queryByText('button 1')).toBeFalsy();
371+
expect(screen.getByText('button 2')).toBeTruthy();
372+
expect(screen.getByText('button 6')).toBeTruthy();
373+
expect(screen.queryByText('button 7')).toBeFalsy();
374+
372375
testRemoteControlManager.handleRight();
373376
expectButtonToHaveFocus(component, 'button 6');
374377
expectListToHaveScroll(listElement, -300);
@@ -399,7 +402,7 @@ describe('SpatialNavigationVirtualizedList', () => {
399402
renderItem={renderItem}
400403
data={data}
401404
itemSize={100}
402-
numberOfRenderedItems={5}
405+
numberOfRenderedItems={7}
403406
numberOfItemsVisibleOnScreen={3}
404407
scrollBehavior="jump-on-scroll"
405408
/>
@@ -420,31 +423,34 @@ describe('SpatialNavigationVirtualizedList', () => {
420423
expectListToHaveScroll(listElement, 0);
421424

422425
expect(screen.getByText('button 1')).toBeTruthy();
423-
expect(screen.getByText('button 5')).toBeTruthy();
424-
expect(screen.queryByText('button 6')).toBeFalsy();
426+
expect(screen.getByText('button 7')).toBeTruthy();
427+
expect(screen.queryByText('button 8')).toBeFalsy();
425428

426429
testRemoteControlManager.handleRight();
427430
expectButtonToHaveFocus(component, 'button 3');
428431
expectListToHaveScroll(listElement, 0);
429432

430-
expect(screen.queryByText('button 1')).toBeFalsy();
431-
expect(screen.getByText('button 2')).toBeTruthy();
432-
expect(screen.getByText('button 6')).toBeTruthy();
433-
expect(screen.queryByText('button 7')).toBeFalsy();
433+
expect(screen.getByText('button 1')).toBeTruthy();
434+
// expect(screen.getByText('button 7')).toBeTruthy();
435+
expect(screen.queryByText('button 8')).toBeFalsy();
434436

435437
testRemoteControlManager.handleRight();
436438
expectButtonToHaveFocus(component, 'button 4');
437439
expectListToHaveScroll(listElement, -300);
438440

439-
expect(screen.queryByText('button 2')).toBeFalsy();
440-
expect(screen.getByText('button 3')).toBeTruthy();
441+
expect(screen.getByText('button 1')).toBeTruthy();
441442
expect(screen.getByText('button 7')).toBeTruthy();
442443
expect(screen.queryByText('button 8')).toBeFalsy();
443444

444445
testRemoteControlManager.handleRight();
445446
expectButtonToHaveFocus(component, 'button 5');
446447
expectListToHaveScroll(listElement, -300);
447448

449+
expect(screen.queryByText('button 1')).toBeFalsy();
450+
expect(screen.getByText('button 2')).toBeTruthy();
451+
expect(screen.getByText('button 8')).toBeTruthy();
452+
expect(screen.queryByText('button 9')).toBeFalsy();
453+
448454
testRemoteControlManager.handleRight();
449455
expectButtonToHaveFocus(component, 'button 6');
450456
expectListToHaveScroll(listElement, -300);
@@ -464,6 +470,10 @@ describe('SpatialNavigationVirtualizedList', () => {
464470
testRemoteControlManager.handleRight();
465471
expectButtonToHaveFocus(component, 'button 10');
466472
expectListToHaveScroll(listElement, -700);
473+
474+
expect(screen.queryByText('button 3')).toBeFalsy();
475+
expect(screen.getByText('button 4')).toBeTruthy();
476+
expect(screen.getByText('button 10')).toBeTruthy();
467477
});
468478

469479
it('handles correctly different item sizes', async () => {
@@ -552,19 +562,26 @@ describe('SpatialNavigationVirtualizedList', () => {
552562
expectButtonToHaveFocus(component, 'button 3');
553563
expectListToHaveScroll(listElement, -100);
554564

555-
expect(screen.queryByText('button 1')).toBeFalsy();
556-
expect(screen.getByText('button 2')).toBeTruthy();
557-
expect(screen.getByText('button 6')).toBeTruthy();
558-
expect(screen.queryByText('button 7')).toBeFalsy();
565+
expect(screen.getByText('button 1')).toBeTruthy();
566+
expect(screen.getByText('button 5')).toBeTruthy();
567+
expect(screen.queryByText('button 6')).toBeFalsy();
559568

560569
testRemoteControlManager.handleRight();
561570
expectButtonToHaveFocus(component, 'button 4');
562571
expectListToHaveScroll(listElement, -300);
563572

564-
expect(screen.queryByText('button 2')).toBeFalsy();
565-
expect(screen.getByText('button 3')).toBeTruthy();
566-
expect(screen.getByText('button 7')).toBeTruthy();
567-
expect(screen.queryByText('button 8')).toBeFalsy();
573+
expect(screen.getByText('button 1')).toBeTruthy();
574+
expect(screen.getByText('button 5')).toBeTruthy();
575+
expect(screen.queryByText('button 6')).toBeFalsy();
576+
577+
testRemoteControlManager.handleRight();
578+
expectButtonToHaveFocus(component, 'button 5');
579+
expectListToHaveScroll(listElement, -400);
580+
581+
expect(screen.queryByText('button 1')).toBeFalsy();
582+
expect(screen.getByText('button 5')).toBeTruthy();
583+
expect(screen.getByText('button 5')).toBeTruthy();
584+
expect(screen.queryByText('button 7')).toBeFalsy();
568585
});
569586

570587
it('jumps to first element on go to first button press', async () => {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export const VirtualizedList = typedMemo(
146146
currentlyFocusedItemIndex,
147147
numberOfRenderedItems,
148148
numberOfItemsVisibleOnScreen,
149+
scrollBehavior,
149150
});
150151

151152
const vertical = orientation === 'vertical';

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ describe('getRange for custom virtualized list', () => {
3636
currentlyFocusedItemIndex: focusIndex,
3737
numberOfRenderedItems,
3838
numberOfItemsVisibleOnScreen,
39+
scrollBehavior: 'stick-to-start',
3940
});
4041

4142
expect(expectedResult).toEqual(result);
@@ -51,6 +52,7 @@ describe('getRange for custom virtualized list', () => {
5152
currentlyFocusedItemIndex: 5,
5253
numberOfRenderedItems: -1,
5354
numberOfItemsVisibleOnScreen: defaultNumberOfItemsVisibleOnScreen,
55+
scrollBehavior: 'stick-to-start',
5456
});
5557

5658
expect(expectedResult).toEqual(result);
@@ -66,6 +68,7 @@ describe('getRange for custom virtualized list', () => {
6668
currentlyFocusedItemIndex: 5,
6769
numberOfRenderedItems: 6,
6870
numberOfItemsVisibleOnScreen: 8,
71+
scrollBehavior: 'stick-to-start',
6972
}),
7073
).toThrowError();
7174
});

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

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { ScrollBehavior } from '../VirtualizedList';
2+
13
const positiveValueOrZero = (x: number): number => Math.max(x, 0);
24

35
/**
@@ -20,11 +22,13 @@ const getRangeWithoutFloatHandling = ({
2022
currentlyFocusedItemIndex,
2123
numberOfRenderedItems = 8,
2224
numberOfItemsVisibleOnScreen,
25+
scrollBehavior,
2326
}: {
2427
data: Array<unknown>;
2528
currentlyFocusedItemIndex: number;
2629
numberOfRenderedItems?: number;
2730
numberOfItemsVisibleOnScreen: number;
31+
scrollBehavior: ScrollBehavior;
2832
}) => {
2933
const numberOfItemsNotVisible = numberOfRenderedItems - numberOfItemsVisibleOnScreen;
3034

@@ -37,12 +41,23 @@ const getRangeWithoutFloatHandling = ({
3741
);
3842
}
3943

40-
const halfNumberOfItemsNotVisible = numberOfItemsNotVisible / 2;
44+
if (
45+
scrollBehavior === 'jump-on-scroll' &&
46+
numberOfRenderedItems < 2 * numberOfItemsVisibleOnScreen + 1
47+
) {
48+
console.error(
49+
'You have set a numberOfRenderedItems inferior to 2 * numberOfItemsVisibleOnScreen + 1 in your SpatialNavigationVirtualizedList with the jump-on-scroll scroll behavior. You must change it.',
50+
);
51+
}
52+
4153
const lastDataIndex = data.length - 1;
4254

43-
const rawStartIndex = currentlyFocusedItemIndex - halfNumberOfItemsNotVisible;
44-
const rawEndIndex =
45-
currentlyFocusedItemIndex + halfNumberOfItemsNotVisible - 1 + numberOfItemsVisibleOnScreen;
55+
const { rawStartIndex, rawEndIndex } = getRawStartAndEndIndexes({
56+
currentlyFocusedItemIndex,
57+
numberOfItemsVisibleOnScreen,
58+
numberOfItemsNotVisible,
59+
scrollBehavior,
60+
});
4661

4762
/*
4863
* if sum does not fit the window size, then we are in of these cases:
@@ -52,17 +67,66 @@ const getRangeWithoutFloatHandling = ({
5267
*/
5368
if (rawStartIndex < 0) {
5469
const finalEndIndex = numberOfRenderedItems - 1;
70+
5571
return { start: 0, end: positiveValueOrZero(Math.min(finalEndIndex, lastDataIndex)) };
5672
}
5773

5874
if (rawEndIndex > data.length - 1) {
5975
const finalStartIndex = lastDataIndex - numberOfRenderedItems + 1;
76+
6077
return { start: positiveValueOrZero(finalStartIndex), end: positiveValueOrZero(lastDataIndex) };
6178
}
6279

6380
return { start: rawStartIndex, end: rawEndIndex };
6481
};
6582

83+
/**
84+
* Computes the raw start and end indexes for the virtualization.
85+
* "raw" means that the indexes are subject to be out of bounds
86+
* which will be handled in the getRange function.
87+
*/
88+
const getRawStartAndEndIndexes = ({
89+
currentlyFocusedItemIndex,
90+
numberOfItemsVisibleOnScreen,
91+
numberOfItemsNotVisible,
92+
scrollBehavior,
93+
}: {
94+
currentlyFocusedItemIndex: number;
95+
numberOfItemsVisibleOnScreen: number;
96+
numberOfItemsNotVisible: number;
97+
scrollBehavior: ScrollBehavior;
98+
}) => {
99+
const halfNumberOfItemsNotVisible = numberOfItemsNotVisible / 2;
100+
101+
switch (scrollBehavior) {
102+
case 'stick-to-start':
103+
return {
104+
rawStartIndex: currentlyFocusedItemIndex - halfNumberOfItemsNotVisible,
105+
rawEndIndex:
106+
currentlyFocusedItemIndex +
107+
numberOfItemsVisibleOnScreen -
108+
1 +
109+
halfNumberOfItemsNotVisible,
110+
};
111+
case 'stick-to-end':
112+
return {
113+
rawStartIndex:
114+
currentlyFocusedItemIndex -
115+
numberOfItemsVisibleOnScreen +
116+
1 -
117+
halfNumberOfItemsNotVisible,
118+
rawEndIndex: currentlyFocusedItemIndex + halfNumberOfItemsNotVisible,
119+
};
120+
case 'jump-on-scroll':
121+
return {
122+
rawStartIndex: currentlyFocusedItemIndex - (halfNumberOfItemsNotVisible + 1),
123+
rawEndIndex: currentlyFocusedItemIndex + (halfNumberOfItemsNotVisible + 1),
124+
};
125+
default:
126+
throw new Error(`Unknown scroll behavior: ${scrollBehavior}`);
127+
}
128+
};
129+
66130
/**
67131
* Computes an array slice for virtualization
68132
* Have a look at the tests to get examples!
@@ -75,11 +139,13 @@ export const getRange = ({
75139
currentlyFocusedItemIndex,
76140
numberOfRenderedItems = 8,
77141
numberOfItemsVisibleOnScreen,
142+
scrollBehavior,
78143
}: {
79144
data: Array<unknown>;
80145
currentlyFocusedItemIndex: number;
81146
numberOfRenderedItems?: number;
82147
numberOfItemsVisibleOnScreen: number;
148+
scrollBehavior: ScrollBehavior;
83149
}): { start: number; end: number } => {
84150
if (numberOfRenderedItems <= 0) {
85151
console.error(
@@ -93,6 +159,7 @@ export const getRange = ({
93159
currentlyFocusedItemIndex,
94160
numberOfRenderedItems,
95161
numberOfItemsVisibleOnScreen,
162+
scrollBehavior,
96163
});
97164

98165
return { start: Math.ceil(result.start), end: Math.ceil(result.end) };

0 commit comments

Comments
 (0)