Skip to content

Commit 51a24c4

Browse files
author
Brian Vaughn
committed
Merge FixedSizeList/FixedSizeGrid scroll alignment and visibility bugfixes (PR #274)
1 parent c71baad commit 51a24c4

14 files changed

+1214
-256
lines changed

src/FixedSizeGrid.js

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,13 @@ const FixedSizeGrid = createGridComponent({
3131
instanceProps: typeof undefined,
3232
scrollbarSize: number
3333
): number => {
34-
const maxOffset = Math.max(
34+
const lastColumnOffset = Math.max(
3535
0,
36-
Math.min(
37-
columnCount * ((columnWidth: any): number) - width,
38-
columnIndex * ((columnWidth: any): number)
39-
)
36+
columnCount * ((columnWidth: any): number) - width
37+
);
38+
const maxOffset = Math.min(
39+
lastColumnOffset,
40+
columnIndex * ((columnWidth: any): number)
4041
);
4142
const minOffset = Math.max(
4243
0,
@@ -60,7 +61,18 @@ const FixedSizeGrid = createGridComponent({
6061
case 'end':
6162
return minOffset;
6263
case 'center':
63-
return Math.round(minOffset + (maxOffset - minOffset) / 2);
64+
// "Centered" offset is usually the average of the min and max.
65+
// But near the edges of the list, this doesn't hold true.
66+
const middleOffset = Math.round(
67+
minOffset + (maxOffset - minOffset) / 2
68+
);
69+
if (middleOffset < Math.ceil(width / 2)) {
70+
return 0; // near the beginning
71+
} else if (middleOffset > lastColumnOffset + Math.floor(width / 2)) {
72+
return lastColumnOffset; // near the end
73+
} else {
74+
return middleOffset;
75+
}
6476
case 'auto':
6577
default:
6678
if (scrollLeft >= minOffset && scrollLeft <= maxOffset) {
@@ -69,7 +81,7 @@ const FixedSizeGrid = createGridComponent({
6981
// Because we only take into account the scrollbar size when calculating minOffset
7082
// this value can be larger than maxOffset when at the end of the list
7183
return minOffset;
72-
} else if (scrollLeft - minOffset < maxOffset - scrollLeft) {
84+
} else if (scrollLeft < minOffset) {
7385
return minOffset;
7486
} else {
7587
return maxOffset;
@@ -85,12 +97,13 @@ const FixedSizeGrid = createGridComponent({
8597
instanceProps: typeof undefined,
8698
scrollbarSize: number
8799
): number => {
88-
const maxOffset = Math.max(
100+
const lastRowOffset = Math.max(
89101
0,
90-
Math.min(
91-
rowCount * ((rowHeight: any): number) - height,
92-
rowIndex * ((rowHeight: any): number)
93-
)
102+
rowCount * ((rowHeight: any): number) - height
103+
);
104+
const maxOffset = Math.min(
105+
lastRowOffset,
106+
rowIndex * ((rowHeight: any): number)
94107
);
95108
const minOffset = Math.max(
96109
0,
@@ -114,7 +127,18 @@ const FixedSizeGrid = createGridComponent({
114127
case 'end':
115128
return minOffset;
116129
case 'center':
117-
return Math.round(minOffset + (maxOffset - minOffset) / 2);
130+
// "Centered" offset is usually the average of the min and max.
131+
// But near the edges of the list, this doesn't hold true.
132+
const middleOffset = Math.round(
133+
minOffset + (maxOffset - minOffset) / 2
134+
);
135+
if (middleOffset < Math.ceil(height / 2)) {
136+
return 0; // near the beginning
137+
} else if (middleOffset > lastRowOffset + Math.floor(height / 2)) {
138+
return lastRowOffset; // near the end
139+
} else {
140+
return middleOffset;
141+
}
118142
case 'auto':
119143
default:
120144
if (scrollTop >= minOffset && scrollTop <= maxOffset) {
@@ -123,7 +147,7 @@ const FixedSizeGrid = createGridComponent({
123147
// Because we only take into account the scrollbar size when calculating minOffset
124148
// this value can be larger than maxOffset when at the end of the list
125149
return minOffset;
126-
} else if (scrollTop - minOffset < maxOffset - scrollTop) {
150+
} else if (scrollTop < minOffset) {
127151
return minOffset;
128152
} else {
129153
return maxOffset;
@@ -149,14 +173,14 @@ const FixedSizeGrid = createGridComponent({
149173
scrollLeft: number
150174
): number => {
151175
const left = startIndex * ((columnWidth: any): number);
176+
const numVisibleColumns = Math.ceil(
177+
(width + scrollLeft - left) / ((columnWidth: any): number)
178+
);
152179
return Math.max(
153180
0,
154181
Math.min(
155182
columnCount - 1,
156-
startIndex +
157-
Math.floor(
158-
(width + (scrollLeft - left)) / ((columnWidth: any): number)
159-
)
183+
startIndex + numVisibleColumns - 1 // -1 is because stop index is inclusive
160184
)
161185
);
162186
},
@@ -175,13 +199,15 @@ const FixedSizeGrid = createGridComponent({
175199
startIndex: number,
176200
scrollTop: number
177201
): number => {
178-
const left = startIndex * ((rowHeight: any): number);
202+
const top = startIndex * ((rowHeight: any): number);
203+
const numVisibleRows = Math.ceil(
204+
(height + scrollTop - top) / ((rowHeight: any): number)
205+
);
179206
return Math.max(
180207
0,
181208
Math.min(
182209
rowCount - 1,
183-
startIndex +
184-
Math.floor((height + (scrollTop - left)) / ((rowHeight: any): number))
210+
startIndex + numVisibleRows - 1 // -1 is because stop index is inclusive
185211
)
186212
);
187213
},

src/FixedSizeList.js

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,13 @@ const FixedSizeList = createListComponent({
2323
// TODO Deprecate direction "horizontal"
2424
const isHorizontal = direction === 'horizontal' || layout === 'horizontal';
2525
const size = (((isHorizontal ? width : height): any): number);
26-
const maxOffset = Math.max(
26+
const lastItemOffset = Math.max(
2727
0,
28-
Math.min(
29-
itemCount * ((itemSize: any): number) - size,
30-
index * ((itemSize: any): number)
31-
)
28+
itemCount * ((itemSize: any): number) - size
29+
);
30+
const maxOffset = Math.min(
31+
lastItemOffset,
32+
index * ((itemSize: any): number)
3233
);
3334
const minOffset = Math.max(
3435
0,
@@ -51,13 +52,25 @@ const FixedSizeList = createListComponent({
5152
return maxOffset;
5253
case 'end':
5354
return minOffset;
54-
case 'center':
55-
return Math.round(minOffset + (maxOffset - minOffset) / 2);
55+
case 'center': {
56+
// "Centered" offset is usually the average of the min and max.
57+
// But near the edges of the list, this doesn't hold true.
58+
const middleOffset = Math.round(
59+
minOffset + (maxOffset - minOffset) / 2
60+
);
61+
if (middleOffset < Math.ceil(size / 2)) {
62+
return 0; // near the beginning
63+
} else if (middleOffset > lastItemOffset + Math.floor(size / 2)) {
64+
return lastItemOffset; // near the end
65+
} else {
66+
return middleOffset;
67+
}
68+
}
5669
case 'auto':
5770
default:
5871
if (scrollOffset >= minOffset && scrollOffset <= maxOffset) {
5972
return scrollOffset;
60-
} else if (scrollOffset - minOffset < maxOffset - scrollOffset) {
73+
} else if (scrollOffset < minOffset) {
6174
return minOffset;
6275
} else {
6376
return maxOffset;
@@ -83,14 +96,14 @@ const FixedSizeList = createListComponent({
8396
const isHorizontal = direction === 'horizontal' || layout === 'horizontal';
8497
const offset = startIndex * ((itemSize: any): number);
8598
const size = (((isHorizontal ? width : height): any): number);
99+
const numVisibleItems = Math.ceil(
100+
(size + scrollOffset - offset) / ((itemSize: any): number)
101+
);
86102
return Math.max(
87103
0,
88104
Math.min(
89105
itemCount - 1,
90-
startIndex +
91-
Math.floor(
92-
(size + (scrollOffset - offset)) / ((itemSize: any): number)
93-
)
106+
startIndex + numVisibleItems - 1 // -1 is because stop index is inclusive
94107
)
95108
);
96109
},

src/VariableSizeGrid.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ const getOffsetForIndexAndAlignment = (
278278
// Because we only take into account the scrollbar size when calculating minOffset
279279
// this value can be larger than maxOffset when at the end of the list
280280
return minOffset;
281-
} else if (scrollOffset - minOffset < maxOffset - scrollOffset) {
281+
} else if (scrollOffset < minOffset) {
282282
return minOffset;
283283
} else {
284284
return maxOffset;

src/VariableSizeList.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ const VariableSizeList = createListComponent({
227227
default:
228228
if (scrollOffset >= minOffset && scrollOffset <= maxOffset) {
229229
return scrollOffset;
230-
} else if (scrollOffset - minOffset < maxOffset - scrollOffset) {
230+
} else if (scrollOffset < minOffset) {
231231
return minOffset;
232232
} else {
233233
return maxOffset;

src/__tests__/FixedSizeGrid.js

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ describe('FixedSizeGrid', () => {
2929

3030
// JSdom does not do actual layout and so doesn't return meaningful values here.
3131
// For the purposes of our tests though, we can mock out semi-meaningful values.
32+
// This mock is required for e.g. "onScroll" tests to work properly.
3233
Object.defineProperties(HTMLElement.prototype, {
3334
clientWidth: {
3435
configurable: true,
@@ -86,7 +87,7 @@ describe('FixedSizeGrid', () => {
8687

8788
it('should render a grid of items', () => {
8889
ReactTestRenderer.create(<FixedSizeGrid {...defaultProps} />);
89-
expect(itemRenderer).toHaveBeenCalledTimes(24);
90+
expect(itemRenderer).toHaveBeenCalledTimes(15);
9091
expect(onItemsRendered.mock.calls).toMatchSnapshot();
9192
});
9293

@@ -493,9 +494,9 @@ describe('FixedSizeGrid', () => {
493494
expect(onItemsRendered).toHaveBeenLastCalledWith(
494495
expect.objectContaining({
495496
visibleColumnStartIndex: 1,
496-
visibleColumnStopIndex: 3,
497+
visibleColumnStopIndex: 2,
497498
visibleRowStartIndex: 4,
498-
visibleRowStopIndex: 8,
499+
visibleRowStopIndex: 7,
499500
})
500501
);
501502

@@ -505,7 +506,7 @@ describe('FixedSizeGrid', () => {
505506
expect(onItemsRendered).toHaveBeenLastCalledWith(
506507
expect.objectContaining({
507508
visibleColumnStartIndex: 1,
508-
visibleColumnStopIndex: 3,
509+
visibleColumnStopIndex: 2,
509510
})
510511
);
511512

@@ -515,7 +516,7 @@ describe('FixedSizeGrid', () => {
515516
expect(onItemsRendered).toHaveBeenLastCalledWith(
516517
expect.objectContaining({
517518
visibleRowStartIndex: 8,
518-
visibleRowStopIndex: 12,
519+
visibleRowStopIndex: 11,
519520
})
520521
);
521522
});
@@ -569,6 +570,51 @@ describe('FixedSizeGrid', () => {
569570
expect(onItemsRendered.mock.calls).toMatchSnapshot();
570571
});
571572

573+
it('scroll with align = "auto" should work with partially-visible items', () => {
574+
const rendered = ReactTestRenderer.create(
575+
// Create list where items don't fit exactly into container.
576+
// The container has space for 3 1/3 items.
577+
<FixedSizeGrid {...defaultProps} columnWidth={70} rowHeight={30} />
578+
);
579+
// Scroll down enough to show row 10 at the bottom a nd column 10 at the right.
580+
// Should show 4 rows: 3 full and one partial at the beginning
581+
// Should show 3 columns: 2 full and one partial at the beginning
582+
rendered
583+
.getInstance()
584+
.scrollToItem({ columnIndex: 10, rowIndex: 10, align: 'auto' });
585+
// No need to scroll again; row and column 9 are already visible.
586+
// Because there's no scrolling, it won't call onItemsRendered.
587+
rendered
588+
.getInstance()
589+
.scrollToItem({ columnIndex: 9, rowIndex: 9, align: 'auto' });
590+
// Scroll to near the end. row 96 and column 97 will be partly visible.
591+
rendered
592+
.getInstance()
593+
.scrollToItem({ columnIndex: 99, rowIndex: 99, align: 'auto' });
594+
// Scroll back to row 91 and column 97.
595+
// This will cause row 99 and column 99 to be partly viisble
596+
// Even though a scroll happened, none of the items rendered have changed.
597+
rendered
598+
.getInstance()
599+
.scrollToItem({ columnIndex: 97, rowIndex: 96, align: 'auto' });
600+
// Scroll forward again. Because row and column #99 were already partly visible,
601+
// all props of the onItemsRendered will be the same.
602+
rendered
603+
.getInstance()
604+
.scrollToItem({ columnIndex: 99, rowIndex: 99, align: 'auto' });
605+
// Scroll to the second row and column.
606+
// This should leave row 4 and column 3 partly visible.
607+
rendered
608+
.getInstance()
609+
.scrollToItem({ columnIndex: 1, rowIndex: 1, align: 'auto' });
610+
// Scroll to the first row and column.
611+
// This should leave row 3 and column 2 partly visible.
612+
rendered
613+
.getInstance()
614+
.scrollToItem({ columnIndex: 0, rowIndex: 0, align: 'auto' });
615+
expect(onItemsRendered.mock.calls).toMatchSnapshot();
616+
});
617+
572618
it('should scroll to the correct item for align = "auto" at the bottom of the grid', () => {
573619
getScrollbarSize.mockImplementation(() => 20);
574620

@@ -948,6 +994,45 @@ describe('FixedSizeGrid', () => {
948994
simulateScroll(instance, { scrollLeft: 200, scrollTop: 200 });
949995
expect(onScroll.mock.calls[0][0].scrollUpdateWasRequested).toBe(false);
950996
});
997+
998+
it('scrolling should report partial items correctly in onItemsRendered', () => {
999+
// Use ReactDOM renderer so the container ref works correctly.
1000+
const instance = ReactDOM.render(
1001+
<FixedSizeGrid
1002+
{...defaultProps}
1003+
initialScrollLeft={20}
1004+
initialScrollTop={10}
1005+
/>,
1006+
document.createElement('div')
1007+
);
1008+
// grid 200w x 100h
1009+
// columnWidth: 100, rowHeight: 25,
1010+
// columnCount: 100, rowCount: 100
1011+
// Scroll 2 items fwd, but thanks to the initialScrollOffset, we should
1012+
// still be showing partials on both ends.
1013+
instance.scrollTo({ scrollLeft: 150, scrollTop: 40 });
1014+
// Scroll a little fwd to cause partials to be hidden
1015+
instance.scrollTo({ scrollLeft: 200, scrollTop: 50 });
1016+
// Scroll backwards to show partials again
1017+
instance.scrollTo({ scrollLeft: 150, scrollTop: 40 });
1018+
// Scroll near the end so that the last item is shown
1019+
// as a partial.
1020+
instance.scrollTo({
1021+
scrollLeft: 98 * 100 - 5,
1022+
scrollTop: 96 * 25 - 5,
1023+
});
1024+
// Scroll to the end. No partials.
1025+
instance.scrollTo({
1026+
scrollLeft: 98 * 100,
1027+
scrollTop: 96 * 25,
1028+
});
1029+
// Verify that backwards scrolling near the end works OK.
1030+
instance.scrollTo({
1031+
scrollLeft: 98 * 100 - 5,
1032+
scrollTop: 96 * 25 - 5,
1033+
});
1034+
expect(onItemsRendered.mock.calls).toMatchSnapshot();
1035+
});
9511036
});
9521037

9531038
describe('itemKey', () => {

0 commit comments

Comments
 (0)