Skip to content

fix(cdk/scrolling): support multiple items per row #29453

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
296 changes: 296 additions & 0 deletions src/cdk/scrolling/multi-columns-virtual-scroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
import {coerceNumberProperty, NumberInput} from '@angular/cdk/coercion';
import {Directive, forwardRef, Input, OnChanges} from '@angular/core';
import {Observable, Subject} from 'rxjs';
import {distinctUntilChanged} from 'rxjs/operators';
import {VIRTUAL_SCROLL_STRATEGY, VirtualScrollStrategy} from './virtual-scroll-strategy';
import {CdkVirtualScrollViewport} from './virtual-scroll-viewport';

export class ItemDimension {
width: number | string;
height: number;
}

// Only support vertical scroll
export class MultiColumnVirtualScrollStrategy implements VirtualScrollStrategy {
private readonly _scrolledIndexChange = new Subject<number>();

/** @docs-private Implemented as part of VirtualScrollStrategy. */
scrolledIndexChange: Observable<number> = this._scrolledIndexChange.pipe(distinctUntilChanged());
private _itemDimension: ItemDimension;
private _minBufferPx: number;
private _maxBufferPx: number;
private _viewport: CdkVirtualScrollViewport | null = null;
constructor(itemDimension: ItemDimension, minBufferPx: number, maxBufferPx: number) {
this._itemDimension = itemDimension;
this._minBufferPx = minBufferPx;
this._maxBufferPx = maxBufferPx;
}
/**
* after viewPort size calculated, attach to the viewPort
* @param viewport
*/
attach(viewport: CdkVirtualScrollViewport): void {
this._viewport = viewport;
this._updateTotalContentSize();
this._updateRenderedRange();
}

/**
* detach on viewPort destroy
*/
detach(): void {
this._scrolledIndexChange.complete();
this._viewport = null;
}
/**
* Update the item size and buffer size.
* @param itemSize The size of the items in the virtually scrolling list.
* @param minBufferPx The minimum amount of buffer (in pixels) before needing to render more
* @param maxBufferPx The amount of buffer (in pixels) to render when rendering more.
*/
updateItemAndBufferSize(itemSize: ItemDimension, minBufferPx: number, maxBufferPx: number) {
if (maxBufferPx < minBufferPx) {
throw Error('CDK virtual scroll: maxBufferPx must be greater than or equal to minBufferPx');
}
this._itemDimension = itemSize;
this._minBufferPx = minBufferPx;
this._maxBufferPx = maxBufferPx;
this._updateTotalContentSize();
this._updateRenderedRange();
}
/** @docs-private Implemented as part of VirtualScrollStrategy. */
onContentScrolled(): void {
this._updateRenderedRange();
}
/** @docs-private Implemented as part of VirtualScrollStrategy. */
onDataLengthChanged(): void {
this._updateTotalContentSize();
this._updateRenderedRange();
}
/** @docs-private Implemented as part of VirtualScrollStrategy. */
onContentRendered(): void {
/* no-op */
}
/** @docs-private Implemented as part of VirtualScrollStrategy. */
onRenderedOffsetChanged(): void {
/* no-op */
}
/**
* scroll to item by index
* @param dataIndex index of the data item
* @param behavior scroll behavior
*/
scrollToIndex(dataIndex: number, behavior: ScrollBehavior): void {
if (this._viewport) {
this._viewport.scrollToOffset(
this.getScrollIndex(dataIndex) * this._itemDimension.height,
behavior,
);
}
}
/**
*
* @param dataIndex of the data
* @returns offset of the index
*/
private getScrollIndex(dataIndex: number) {
if (this._viewport) {
if (this._viewport.orientation === 'vertical') {
const colPerRow = this.getColPerRow();
const rowIndex = Math.floor(dataIndex / colPerRow);
return rowIndex;
} else {
console.warn('The horizontal mode is not support yet.');
}
}
return 0;
}

/**
*
* @returns range of data to render
*/
private _updateRenderedRange() {
if (!this._viewport) {
return;
}
const colPerRow = this.getColPerRow();
// get the current rendered range {start,end} of the data, actually the start/end indexs of the array
const renderedRange = this._viewport.getRenderedRange();
// init new render range as current, start, end
const newRange = {start: renderedRange.start, end: renderedRange.end};
// actually the height of the view port, we calculated based on height
const viewportSize = this._viewport.getViewportSize();

// total data length
const dataLength = this._viewport.getDataLength();
// current scrolloffset
let scrollOffset = this._viewport.measureScrollOffset();

// Prevent NaN as result when dividing by zero.
const itemSize = this._itemDimension.height;
// Totally same as fixed size scrolling start here except dataLength->rowLength
let firstVisibleIndex = itemSize > 0 ? scrollOffset / itemSize : 0;

// If user scrolls to the bottom of the list and data changes to a smaller list
// use original range to check exceed condition
if (newRange.end > dataLength) {
// We have to recalculate the first visible index based on new data length and viewport size.
const maxVisibleItems = Math.ceil(viewportSize / itemSize);
const newVisibleIndex =
Math.max(0, Math.min(firstVisibleIndex, dataLength - maxVisibleItems)) * colPerRow;

// If first visible index changed we must update scroll offset to handle start/end buffers
// Current range must also be adjusted to cover the new position (bottom of new list).
if (firstVisibleIndex != newVisibleIndex) {
firstVisibleIndex = newVisibleIndex;
scrollOffset = newVisibleIndex * itemSize;
newRange.start = Math.floor(firstVisibleIndex) * colPerRow;
}

newRange.end = Math.max(
0,
Math.min(dataLength, (newRange.start + maxVisibleItems) * colPerRow),
);
}

const rowRange = {start: newRange.start / colPerRow, end: Math.ceil(newRange.end / colPerRow)};
const startBuffer = scrollOffset - rowRange.start * itemSize;
if (startBuffer < this._minBufferPx && rowRange.start != 0) {
const expandStart = Math.ceil((this._maxBufferPx - startBuffer) / itemSize);
rowRange.start = Math.max(0, rowRange.start - expandStart);
rowRange.end = Math.min(
dataLength,
Math.ceil(firstVisibleIndex + (viewportSize + this._minBufferPx) / itemSize),
);
} else {
const endBuffer = rowRange.end * itemSize - (scrollOffset + viewportSize);
if (endBuffer < this._minBufferPx && rowRange.end != dataLength) {
const expandEnd = Math.ceil((this._maxBufferPx - endBuffer) / itemSize);
if (expandEnd > 0) {
rowRange.end = Math.min(dataLength, rowRange.end + expandEnd);
rowRange.start = Math.max(
0,
Math.floor(firstVisibleIndex - this._minBufferPx / itemSize),
);
}
}
}
const updatedRange = {
start: rowRange.start * colPerRow,
end: Math.min(dataLength, rowRange.end * colPerRow),
};
// Totally same as fixed size scrolling above
this._viewport.setRenderedRange(updatedRange);
this._viewport.setRenderedContentOffset(this._itemDimension.height * rowRange.start);
this._scrolledIndexChange.next(Math.floor(firstVisibleIndex));
}
private getColPerRow() {
// we support multiple columns, so we need know the column number for calc
// if not specific item width, treat as single column scroll
const viewPortWidth = this._viewport?.elementRef.nativeElement.clientWidth || 0;
let itemWidth = 0;
if (typeof this._itemDimension.width == 'string') {
itemWidth =
viewPortWidth *
(Number(coerceNumberProperty(this._itemDimension.width.replace('%', ''))) / 100);
} else {
itemWidth = coerceNumberProperty(this._itemDimension.width);
}
const colPerRow =
itemWidth > 0 && viewPortWidth > itemWidth ? Math.floor(viewPortWidth / itemWidth) : 1;
return colPerRow;
}
/**
*
* @returns Total virtual scroll size based on datalength and itemDemension
*/
private _updateTotalContentSize() {
if (!this._viewport) {
return;
}
const colPerRow = this.getColPerRow();
const rows = Math.ceil(this._viewport.getDataLength() / colPerRow);
this._viewport.setTotalContentSize(rows * this._itemDimension.height);
}
}

/**
* Provider factory for `MultiColumnsVirtualScrollStrategy` that simply extracts the already created
* `MultiColumnsVirtualScrollStrategy` from the given directive.
* @param multiColumnsDir The instance of `CdkMultiColumnsVirtualScroll` to extract the
* `MultiColumnsVirtualScrollStrategy` from.
*/
export function _multiColumnVirtualScrollStrategyFactory(
multiColumnsDir: CdkMultiColumnsVirtualScroll,
) {
return multiColumnsDir._scrollStrategy;
}

/** A virtual scroll strategy that supports multi-column items. */
@Directive({
selector: 'cdk-virtual-scroll-viewport[itemDimension]',
standalone: true,
providers: [
{
provide: VIRTUAL_SCROLL_STRATEGY,
useFactory: _multiColumnVirtualScrollStrategyFactory,
deps: [forwardRef(() => CdkMultiColumnsVirtualScroll)],
},
],
})
export class CdkMultiColumnsVirtualScroll implements OnChanges {
/**
* For multiple columns virtual scroll, need to know how many column per row
* Make sure the item dimension equal to the settings,
* remember padding/margin/border are counter in as well
*/
@Input()
get itemDimension() {
return this._itemDimension;
}
set itemDimension(val: ItemDimension) {
this._itemDimension = val;
}
_itemDimension: ItemDimension = {width: 240, height: 50};

/**
* The minimum amount of buffer rendered beyond the viewport (in pixels).
* If the amount of buffer dips below this number, more items will be rendered. Defaults to 100px.
*/
@Input()
get minBufferPx(): number {
return this._minBufferPx;
}
set minBufferPx(value: NumberInput) {
this._minBufferPx = coerceNumberProperty(value);
}
_minBufferPx = 100;

/**
* The number of pixels worth of buffer to render for when rendering new items. Defaults to 200px.
*/
@Input()
get maxBufferPx(): number {
return this._maxBufferPx;
}
set maxBufferPx(value: NumberInput) {
this._maxBufferPx = coerceNumberProperty(value);
}
_maxBufferPx = 200;

/** The scroll strategy used by this directive. */
_scrollStrategy = new MultiColumnVirtualScrollStrategy(
this.itemDimension,
this.minBufferPx,
this.maxBufferPx,
);

ngOnChanges() {
this._scrollStrategy.updateItemAndBufferSize(
this.itemDimension,
this.minBufferPx,
this.maxBufferPx,
);
}
}
1 change: 1 addition & 0 deletions src/cdk/scrolling/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

export * from './fixed-size-virtual-scroll';
export * from './multi-columns-virtual-scroll';
export * from './scroll-dispatcher';
export * from './scrollable';
export * from './scrolling-module';
Expand Down
3 changes: 3 additions & 0 deletions src/cdk/scrolling/scrolling-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import {BidiModule} from '@angular/cdk/bidi';
import {NgModule} from '@angular/core';
import {CdkFixedSizeVirtualScroll} from './fixed-size-virtual-scroll';
import {CdkMultiColumnsVirtualScroll} from './multi-columns-virtual-scroll';
import {CdkScrollable} from './scrollable';
import {CdkVirtualForOf} from './virtual-for-of';
import {CdkVirtualScrollViewport} from './virtual-scroll-viewport';
Expand All @@ -30,6 +31,7 @@ export class CdkScrollableModule {}
CdkScrollableModule,
CdkVirtualScrollViewport,
CdkFixedSizeVirtualScroll,
CdkMultiColumnsVirtualScroll,
CdkVirtualForOf,
CdkVirtualScrollableWindow,
CdkVirtualScrollableElement,
Expand All @@ -38,6 +40,7 @@ export class CdkScrollableModule {}
BidiModule,
CdkScrollableModule,
CdkFixedSizeVirtualScroll,
CdkMultiColumnsVirtualScroll,
CdkVirtualForOf,
CdkVirtualScrollViewport,
CdkVirtualScrollableWindow,
Expand Down
14 changes: 14 additions & 0 deletions src/dev-app/virtual-scroll/virtual-scroll-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,20 @@ <h2>Virtual scroller with scrolling parent</h2>
<cdk-virtual-scroll-parent-scrolling-example class="demo-resize-example">
</cdk-virtual-scroll-parent-scrolling-example>

<h2>Multi Columns Virtual Scroll (width in pixel)</h2>
<cdk-virtual-scroll-viewport class="demo-multicolumns-viewport" [itemDimension]="{width:160,height:50}">
<div *cdkVirtualFor="let size of fixedSizeData; let i = index" class="demo-multicolumns-item">
Item #{{i}}
</div>
</cdk-virtual-scroll-viewport>

<h2>Multi Columns Virtual Scroll (width in percentage 30%)</h2>
<cdk-virtual-scroll-viewport class="demo-multicolumns-viewport" [itemDimension]="{width:'30%',height:50}">
<div *cdkVirtualFor="let size of fixedSizeData; let i = index" class="demo-multicolumns-item-pct">
Item #{{i}}
</div>
</cdk-virtual-scroll-viewport>

<h2>Virtual scroller with scrolling window</h2>
<cdk-virtual-scroll-window-scrolling-example [shouldRun]="true">
</cdk-virtual-scroll-window-scrolling-example>
31 changes: 31 additions & 0 deletions src/dev-app/virtual-scroll/virtual-scroll-demo.scss
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,34 @@ cdk-virtual-scroll-window-scrolling-example {
display: block;
width: 500px;
}

.demo-multicolumns-viewport {
height: 500px;
width: 500px;
border: 1px solid black;

.cdk-virtual-scroll-content-wrapper {
display: flex;
flex-wrap: wrap;

.demo-multicolumns-item {
width: 160px;
height: 50px;
border: 1px solid lightgray;
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
}

.demo-multicolumns-item-pct {
width: 30%;
height: 50px;
border: 1px solid lightgray;
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
}
}
}
Loading
Loading