Skip to content

Commit 477a418

Browse files
committed
fix(cdk/scrolling): support multiple items per row
Add virtual scroll support to multiple columns per row. The viewport will list items with flex-wrap styles, and the width of each item has two types of values: fixed pixel and percentage. For example, {width: 200, height: 50} and {width: '25%', height: 50}. Fixes angular#10114
1 parent e517789 commit 477a418

File tree

5 files changed

+344
-0
lines changed

5 files changed

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

src/cdk/scrolling/public-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
export * from './fixed-size-virtual-scroll';
10+
export * from './multi-columns-virtual-scroll';
1011
export * from './scroll-dispatcher';
1112
export * from './scrollable';
1213
export * from './scrolling-module';

src/cdk/scrolling/scrolling-module.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import {BidiModule} from '@angular/cdk/bidi';
1010
import {NgModule} from '@angular/core';
1111
import {CdkFixedSizeVirtualScroll} from './fixed-size-virtual-scroll';
12+
import {CdkMultiColumnsVirtualScroll} from './multi-columns-virtual-scroll';
1213
import {CdkScrollable} from './scrollable';
1314
import {CdkVirtualForOf} from './virtual-for-of';
1415
import {CdkVirtualScrollViewport} from './virtual-scroll-viewport';
@@ -30,6 +31,7 @@ export class CdkScrollableModule {}
3031
CdkScrollableModule,
3132
CdkVirtualScrollViewport,
3233
CdkFixedSizeVirtualScroll,
34+
CdkMultiColumnsVirtualScroll,
3335
CdkVirtualForOf,
3436
CdkVirtualScrollableWindow,
3537
CdkVirtualScrollableElement,
@@ -38,6 +40,7 @@ export class CdkScrollableModule {}
3840
BidiModule,
3941
CdkScrollableModule,
4042
CdkFixedSizeVirtualScroll,
43+
CdkMultiColumnsVirtualScroll,
4144
CdkVirtualForOf,
4245
CdkVirtualScrollViewport,
4346
CdkVirtualScrollableWindow,

src/dev-app/virtual-scroll/virtual-scroll-demo.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,20 @@ <h2>Virtual scroller with scrolling parent</h2>
183183
<cdk-virtual-scroll-parent-scrolling-example class="demo-resize-example">
184184
</cdk-virtual-scroll-parent-scrolling-example>
185185

186+
<h2>Multi Columns Virtual Scroll (width in pixel)</h2>
187+
<cdk-virtual-scroll-viewport class="demo-multicolumns-viewport" [itemDimension]="{width:160,height:50}">
188+
<div *cdkVirtualFor="let size of fixedSizeData; let i = index" class="demo-multicolumns-item">
189+
Item #{{i}}
190+
</div>
191+
</cdk-virtual-scroll-viewport>
192+
193+
<h2>Multi Columns Virtual Scroll (width in percentage 30%)</h2>
194+
<cdk-virtual-scroll-viewport class="demo-multicolumns-viewport" [itemDimension]="{width:'30%',height:50}">
195+
<div *cdkVirtualFor="let size of fixedSizeData; let i = index" class="demo-multicolumns-item-pct">
196+
Item #{{i}}
197+
</div>
198+
</cdk-virtual-scroll-viewport>
199+
186200
<h2>Virtual scroller with scrolling window</h2>
187201
<cdk-virtual-scroll-window-scrolling-example [shouldRun]="true">
188202
</cdk-virtual-scroll-window-scrolling-example>

src/dev-app/virtual-scroll/virtual-scroll-demo.scss

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,33 @@ cdk-virtual-scroll-window-scrolling-example {
6464
display: block;
6565
width: 500px;
6666
}
67+
68+
.demo-multicolumns-viewport {
69+
height: 500px;
70+
width: 500px;
71+
border: 1px solid black;
72+
73+
.cdk-virtual-scroll-content-wrapper {
74+
display: flex;
75+
flex-wrap: wrap;
76+
77+
.demo-multicolumns-item {
78+
width: 160px;
79+
height: 50px;
80+
border: 1px solid lightgray;
81+
box-sizing: border-box;
82+
display: flex;
83+
justify-content: center;
84+
align-items: center;
85+
}
86+
.demo-multicolumns-item-pct{
87+
width: 30%;
88+
height: 50px;
89+
border: 1px solid lightgray;
90+
box-sizing: border-box;
91+
display: flex;
92+
justify-content: center;
93+
align-items: center;
94+
}
95+
}
96+
}

0 commit comments

Comments
 (0)