Skip to content

Commit 634a8d3

Browse files
committed
fix(cdk/scrolling): support multiple items per row
Add virtual scroll support to multiple columns per row Fixes angular#10114
1 parent ae83977 commit 634a8d3

File tree

1 file changed

+268
-0
lines changed

1 file changed

+268
-0
lines changed

Diff for: src/cdk/scrolling/multi-columns-virtual-scroll.ts

+268
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import { coerceNumberProperty, NumberInput } from "@angular/cdk/coercion";
2+
import { CdkVirtualScrollViewport, VirtualScrollStrategy, VIRTUAL_SCROLL_STRATEGY } from "@angular/cdk/scrolling";
3+
import { Directive, forwardRef, Input, OnChanges } from "@angular/core";
4+
import { distinctUntilChanged, Observable, Subject } from "rxjs";
5+
6+
// Only support vertical scroll
7+
export class MultiColumnVirtualScrollStrategy implements VirtualScrollStrategy {
8+
private readonly _scrolledIndexChange = new Subject<number>();
9+
10+
/** @docs-private Implemented as part of VirtualScrollStrategy. */
11+
scrolledIndexChange: Observable<number> = this._scrolledIndexChange.pipe(distinctUntilChanged());
12+
private _itemDimension: { width: number, height: number };
13+
private _minBufferPx: number;
14+
private _maxBufferPx: number;
15+
private _viewport: CdkVirtualScrollViewport | null = null;
16+
constructor(itemDimension: { width: number, height: number }, minBufferPx: number, maxBufferPx: number) {
17+
this._itemDimension = itemDimension;
18+
this._minBufferPx = minBufferPx;
19+
this._maxBufferPx = maxBufferPx;
20+
}
21+
/**
22+
* after viewPort size calculated, attach to the viewPort
23+
* @param viewport
24+
*/
25+
attach(viewport: CdkVirtualScrollViewport): void {
26+
this._viewport = viewport;
27+
this._updateTotalContentSize();
28+
this._updateRenderedRange();
29+
}
30+
31+
/**
32+
* detach on viewPort destroy
33+
*/
34+
detach(): void {
35+
this._scrolledIndexChange.complete();
36+
this._viewport = null;
37+
}
38+
/**
39+
* Update the item size and buffer size.
40+
* @param itemSize The size of the items in the virtually scrolling list.
41+
* @param minBufferPx The minimum amount of buffer (in pixels) before needing to render more
42+
* @param maxBufferPx The amount of buffer (in pixels) to render when rendering more.
43+
*/
44+
updateItemAndBufferSize(itemSize: { width: number, height: number }, minBufferPx: number, maxBufferPx: number) {
45+
if (maxBufferPx < minBufferPx) {
46+
throw Error('CDK virtual scroll: maxBufferPx must be greater than or equal to minBufferPx');
47+
}
48+
this._itemDimension = itemSize;
49+
this._minBufferPx = minBufferPx;
50+
this._maxBufferPx = maxBufferPx;
51+
this._updateTotalContentSize();
52+
this._updateRenderedRange();
53+
}
54+
/** @docs-private Implemented as part of VirtualScrollStrategy. */
55+
onContentScrolled(): void {
56+
this._updateRenderedRange()
57+
}
58+
/** @docs-private Implemented as part of VirtualScrollStrategy. */
59+
onDataLengthChanged(): void {
60+
this._updateTotalContentSize();
61+
this._updateRenderedRange();
62+
}
63+
/** @docs-private Implemented as part of VirtualScrollStrategy. */
64+
onContentRendered(): void {
65+
/* no-op */
66+
}
67+
/** @docs-private Implemented as part of VirtualScrollStrategy. */
68+
onRenderedOffsetChanged(): void {
69+
/* no-op */
70+
}
71+
/**
72+
* scroll to item by index
73+
* @param dataIndex index of the data item
74+
* @param behavior scroll behavior
75+
*/
76+
scrollToIndex(dataIndex: number, behavior: ScrollBehavior): void {
77+
if (this._viewport) {
78+
this._viewport.scrollToOffset(this.getScrollIndex(dataIndex) * this._itemDimension.height, behavior);
79+
}
80+
}
81+
/**
82+
*
83+
* @param dataIndex of the data
84+
* @returns offset of the index
85+
*/
86+
private getScrollIndex(dataIndex: number) {
87+
if (this._viewport) {
88+
if (this._viewport.orientation === 'vertical') {
89+
const viewPortWidth = this._viewport.elementRef.nativeElement.clientWidth;
90+
const colPerRow = this._itemDimension.width > 0 ? Math.floor(viewPortWidth / this._itemDimension.width) : 1;
91+
const rowIndex = Math.floor(dataIndex / colPerRow);
92+
return rowIndex;
93+
}
94+
else {
95+
console.warn('The horizontal mode is not support yet.')
96+
}
97+
}
98+
return 0;
99+
}
100+
101+
/**
102+
*
103+
* @returns range of data to render
104+
*/
105+
private _updateRenderedRange() {
106+
if (!this._viewport) {
107+
return;
108+
}
109+
// we support multiple column, so we need know the column number for calc
110+
// if not specific item width, treat as single column scroll
111+
const viewPortWidth = this._viewport.elementRef.nativeElement.clientWidth;
112+
// need determin col(item) per row to calculate actual scroll height
113+
// use Math.floor becuase if there not enough space for 3, we use 2 not 2.*
114+
const colPerRow = this._itemDimension.width > 0 && viewPortWidth > this._itemDimension.width ? Math.floor(viewPortWidth / this._itemDimension.width) : 1;
115+
// get the current rendered range {start,end} of the data, actually the start/end indexs of the array
116+
const renderedRange = this._viewport.getRenderedRange();
117+
// init new render range as current, start, end
118+
const newRange = { start: renderedRange.start, end: renderedRange.end };
119+
// actually the height of the view port, we calculated based on height
120+
const viewportSize = this._viewport.getViewportSize();
121+
122+
// total data length
123+
const dataLength = this._viewport.getDataLength();
124+
// current scrolloffset
125+
let scrollOffset = this._viewport.measureScrollOffset();
126+
127+
// Prevent NaN as result when dividing by zero.
128+
const itemSize = this._itemDimension.height;
129+
// Totally same as fixed size scrolling start here except dataLength->rowLength
130+
let firstVisibleIndex = itemSize > 0 ? scrollOffset / itemSize : 0;
131+
132+
// If user scrolls to the bottom of the list and data changes to a smaller list
133+
// use original range to check exceed condition
134+
if (newRange.end > dataLength) {
135+
// We have to recalculate the first visible index based on new data length and viewport size.
136+
const maxVisibleItems = Math.ceil(viewportSize / itemSize);
137+
const newVisibleIndex = Math.max(
138+
0,
139+
Math.min(firstVisibleIndex, dataLength - maxVisibleItems),
140+
) * colPerRow;
141+
142+
// If first visible index changed we must update scroll offset to handle start/end buffers
143+
// Current range must also be adjusted to cover the new position (bottom of new list).
144+
if (firstVisibleIndex != newVisibleIndex) {
145+
firstVisibleIndex = newVisibleIndex;
146+
scrollOffset = newVisibleIndex * itemSize;
147+
newRange.start = Math.floor(firstVisibleIndex) * colPerRow;
148+
}
149+
150+
newRange.end = Math.max(0, Math.min(dataLength, (newRange.start + maxVisibleItems) * colPerRow));
151+
}
152+
153+
const rowRange = { start: newRange.start / colPerRow, end: Math.ceil(newRange.end / colPerRow) };
154+
const startBuffer = scrollOffset - rowRange.start * itemSize;
155+
if (startBuffer < this._minBufferPx && rowRange.start != 0) {
156+
const expandStart = Math.ceil((this._maxBufferPx - startBuffer) / itemSize);
157+
rowRange.start = Math.max(0, rowRange.start - expandStart);
158+
rowRange.end = Math.min(
159+
dataLength,
160+
Math.ceil(firstVisibleIndex + (viewportSize + this._minBufferPx) / itemSize),
161+
);
162+
} else {
163+
const endBuffer = rowRange.end * itemSize - (scrollOffset + viewportSize);
164+
if (endBuffer < this._minBufferPx && rowRange.end != dataLength) {
165+
const expandEnd = Math.ceil((this._maxBufferPx - endBuffer) / itemSize);
166+
if (expandEnd > 0) {
167+
rowRange.end = Math.min(dataLength, rowRange.end + expandEnd);
168+
rowRange.start = Math.max(
169+
0,
170+
Math.floor(firstVisibleIndex - this._minBufferPx / itemSize),
171+
);
172+
}
173+
}
174+
}
175+
const updatedRange = { start: rowRange.start * colPerRow, end: Math.min(dataLength, rowRange.end * colPerRow) }
176+
// Totally same as fixed size scrolling above
177+
this._viewport.setRenderedRange(updatedRange);
178+
this._viewport.setRenderedContentOffset(this._itemDimension.height * rowRange.start);
179+
this._scrolledIndexChange.next(Math.floor(firstVisibleIndex));
180+
}
181+
/**
182+
*
183+
* @returns Total virtual scroll size based on datalength and itemDemension
184+
*/
185+
private _updateTotalContentSize() {
186+
if (!this._viewport) {
187+
return;
188+
}
189+
const viewPortWidth = this._viewport.elementRef.nativeElement.clientWidth;
190+
const colPerRow = this._itemDimension.width > 0 ? Math.floor(viewPortWidth / this._itemDimension.width) : 1;
191+
const rows = Math.ceil(this._viewport.getDataLength() / colPerRow);
192+
this._viewport.setTotalContentSize(rows * this._itemDimension.height);
193+
}
194+
195+
}
196+
197+
/**
198+
* Provider factory for `FixedSizeVirtualScrollStrategy` that simply extracts the already created
199+
* `FixedSizeVirtualScrollStrategy` from the given directive.
200+
* @param fixedSizeDir The instance of `CdkFixedSizeVirtualScroll` to extract the
201+
* `FixedSizeVirtualScrollStrategy` from.
202+
*/
203+
export function _multiColumnVirtualScrollStrategyFactory(fixedSizeDir: MultiColumnVirtualScrollDirective) {
204+
return fixedSizeDir._scrollStrategy;
205+
}
206+
207+
/** A virtual scroll strategy that supports fixed-size items. */
208+
@Directive({
209+
selector: 'cdk-virtual-scroll-viewport[itemDimension]',
210+
providers: [
211+
{
212+
provide: VIRTUAL_SCROLL_STRATEGY,
213+
useFactory: _multiColumnVirtualScrollStrategyFactory,
214+
deps: [forwardRef(() => MultiColumnVirtualScrollDirective)],
215+
},
216+
],
217+
})
218+
export class MultiColumnVirtualScrollDirective implements OnChanges {
219+
/**
220+
* For multiple columns virtual scroll, need to know how many column per row
221+
* Make sure the item dimension equal to the settings,
222+
* remember padding/margin/border are counter in as well
223+
*/
224+
@Input()
225+
get itemDimension() {
226+
return this._itemDimension;
227+
}
228+
set itemDimension(val: { width: number, height: number }) {
229+
this._itemDimension = val;
230+
}
231+
_itemDimension = { width: 240, height: 50 };
232+
233+
/**
234+
* The minimum amount of buffer rendered beyond the viewport (in pixels).
235+
* If the amount of buffer dips below this number, more items will be rendered. Defaults to 100px.
236+
*/
237+
@Input()
238+
get minBufferPx(): number {
239+
return this._minBufferPx;
240+
}
241+
set minBufferPx(value: NumberInput) {
242+
this._minBufferPx = coerceNumberProperty(value);
243+
}
244+
_minBufferPx = 100;
245+
246+
/**
247+
* The number of pixels worth of buffer to render for when rendering new items. Defaults to 200px.
248+
*/
249+
@Input()
250+
get maxBufferPx(): number {
251+
return this._maxBufferPx;
252+
}
253+
set maxBufferPx(value: NumberInput) {
254+
this._maxBufferPx = coerceNumberProperty(value);
255+
}
256+
_maxBufferPx = 200;
257+
258+
/** The scroll strategy used by this directive. */
259+
_scrollStrategy = new MultiColumnVirtualScrollStrategy(
260+
this.itemDimension,
261+
this.minBufferPx,
262+
this.maxBufferPx,
263+
);
264+
265+
ngOnChanges() {
266+
this._scrollStrategy.updateItemAndBufferSize(this.itemDimension, this.minBufferPx, this.maxBufferPx);
267+
}
268+
}

0 commit comments

Comments
 (0)