igniteui-angular-sovn
Version:
Ignite UI for Angular is a dependency-free Angular toolkit for building modern web apps
1,305 lines (1,202 loc) • 71.8 kB
text/typescript
/* eslint-disable @angular-eslint/no-conflicting-lifecycle */
import { DOCUMENT, NgForOfContext } from '@angular/common';
import {
ChangeDetectorRef,
ComponentRef,
Directive,
DoCheck,
EmbeddedViewRef,
EventEmitter,
Input,
IterableChanges,
IterableDiffer,
IterableDiffers,
NgZone,
OnChanges,
OnDestroy,
OnInit,
Output,
SimpleChanges,
TemplateRef,
TrackByFunction,
ViewContainerRef,
AfterViewInit,
Inject
} from '@angular/core';
import { DisplayContainerComponent } from './display.container';
import { HVirtualHelperComponent } from './horizontal.virtual.helper.component';
import { VirtualHelperComponent } from './virtual.helper.component';
import { IgxForOfSyncService, IgxForOfScrollSyncService } from './for_of.sync.service';
import { Subject } from 'rxjs';
import { takeUntil, filter, throttleTime, first } from 'rxjs/operators';
import { getResizeObserver } from '../../core/utils';
import { IBaseEventArgs, PlatformUtil } from '../../core/utils';
import { VirtualHelperBaseDirective } from './base.helper.component';
const MAX_PERF_SCROLL_DIFF = 4;
/**
* @publicApi
*/
export class IgxForOfContext<T> {
constructor(
public $implicit: T,
public index: number,
public count: number
) { }
/**
* A function that returns whether the element is the first or not
*/
public get first(): boolean {
return this.index === 0;
}
/**
* A function that returns whether the element is the last or not
*/
public get last(): boolean {
return this.index === this.count - 1;
}
/**
* A function that returns whether the element is even or not
*/
public get even(): boolean {
return this.index % 2 === 0;
}
/**
* A function that returns whether the element is odd or not
*/
public get odd(): boolean {
return !this.even;
}
}
@Directive({
selector: '[igxFor][igxForOf]',
providers: [IgxForOfScrollSyncService],
standalone: true
})
// eslint-disable @angular-eslint/no-conflicting-lifecycle
export class IgxForOfDirective<T, U extends T[] = T[]> implements OnInit, OnChanges, DoCheck, OnDestroy, AfterViewInit {
/**
* An @Input property that sets the data to be rendered.
* ```html
* <ng-template igxFor let-item [igxForOf]="data" [igxForScrollOrientation]="'horizontal'"></ng-template>
* ```
*/
@Input()
public igxForOf: U&T[] | null;
/**
* An @Input property that sets the property name from which to read the size in the data object.
*/
@Input()
public igxForSizePropName;
/**
* An @Input property that specifies the scroll orientation.
* Scroll orientation can be "vertical" or "horizontal".
* ```html
* <ng-template igxFor let-item [igxForOf]="data" [igxForScrollOrientation]="'horizontal'"></ng-template>
* ```
*/
@Input()
public igxForScrollOrientation = 'vertical';
/**
* Optionally pass the parent `igxFor` instance to create a virtual template scrolling both horizontally and vertically.
* ```html
* <ng-template #scrollContainer igxFor let-rowData [igxForOf]="data"
* [igxForScrollOrientation]="'vertical'"
* [igxForContainerSize]="'500px'"
* [igxForItemSize]="'50px'"
* let-rowIndex="index">
* <div [style.display]="'flex'" [style.height]="'50px'">
* <ng-template #childContainer igxFor let-item [igxForOf]="data"
* [igxForScrollOrientation]="'horizontal'"
* [igxForScrollContainer]="parentVirtDir"
* [igxForContainerSize]="'500px'">
* <div [style.min-width]="'50px'">{{rowIndex}} : {{item.text}}</div>
* </ng-template>
* </div>
* </ng-template>
* ```
*/
@Input()
public igxForScrollContainer: any;
/**
* An @Input property that sets the px-affixed size of the container along the axis of scrolling.
* For "horizontal" orientation this value is the width of the container and for "vertical" is the height.
* ```html
* <ng-template igxFor let-item [igxForOf]="data" [igxForContainerSize]="'500px'"
* [igxForScrollOrientation]="'horizontal'">
* </ng-template>
* ```
*/
@Input()
public igxForContainerSize: any;
/**
* An @Input property that sets the px-affixed size of the item along the axis of scrolling.
* For "horizontal" orientation this value is the width of the column and for "vertical" is the height or the row.
* ```html
* <ng-template igxFor let-item [igxForOf]="data" [igxForScrollOrientation]="'horizontal'" [igxForItemSize]="'50px'"></ng-template>
* ```
*/
@Input()
public igxForItemSize: any;
/**
* An event that is emitted after a new chunk has been loaded.
* ```html
* <ng-template igxFor [igxForOf]="data" [igxForScrollOrientation]="'horizontal'" (chunkLoad)="loadChunk($event)"></ng-template>
* ```
* ```typescript
* loadChunk(e){
* alert("chunk loaded!");
* }
* ```
*/
@Output()
public chunkLoad = new EventEmitter<IForOfState>();
/**
* @hidden @internal
* An event that is emitted when scrollbar visibility has changed.
*/
@Output()
public scrollbarVisibilityChanged = new EventEmitter<any>();
/**
* An event that is emitted after the rendered content size of the igxForOf has been changed.
*/
@Output()
public contentSizeChange = new EventEmitter<any>();
/**
* An event that is emitted after data has been changed.
* ```html
* <ng-template igxFor [igxForOf]="data" [igxForScrollOrientation]="'horizontal'" (dataChanged)="dataChanged($event)"></ng-template>
* ```
* ```typescript
* dataChanged(e){
* alert("data changed!");
* }
* ```
*/
@Output()
public dataChanged = new EventEmitter<any>();
@Output()
public beforeViewDestroyed = new EventEmitter<EmbeddedViewRef<any>>();
/**
* An event that is emitted on chunk loading to emit the current state information - startIndex, endIndex, totalCount.
* Can be used for implementing remote load on demand for the igxFor data.
* ```html
* <ng-template igxFor [igxForOf]="data" [igxForScrollOrientation]="'horizontal'" (chunkPreload)="chunkPreload($event)"></ng-template>
* ```
* ```typescript
* chunkPreload(e){
* alert("chunk is loading!");
* }
* ```
*/
@Output()
public chunkPreload = new EventEmitter<IForOfState>();
/**
* @hidden
*/
public dc: ComponentRef<DisplayContainerComponent>;
/**
* The current state of the directive. It contains `startIndex` and `chunkSize`.
* state.startIndex - The index of the item at which the current visible chunk begins.
* state.chunkSize - The number of items the current visible chunk holds.
* These options can be used when implementing remote virtualization as they provide the necessary state information.
* ```typescript
* const gridState = this.parentVirtDir.state;
* ```
*/
public state: IForOfState = {
startIndex: 0,
chunkSize: 0
};
protected func;
protected _sizesCache: number[] = [];
protected scrollComponent: VirtualHelperBaseDirective;
protected _differ: IterableDiffer<T> | null = null;
protected _trackByFn: TrackByFunction<T>;
protected heightCache: number[] = [];
/** Internal track for scroll top that is being virtualized */
protected _virtScrollTop = 0;
/** If the next onScroll event is triggered due to internal setting of scrollTop */
protected _bScrollInternal = false;
// End properties related to virtual height handling
protected _embeddedViews: Array<EmbeddedViewRef<any>> = [];
protected contentResizeNotify = new Subject();
protected contentObserver: ResizeObserver;
/** Height that is being virtualized. */
protected _virtHeight = 0;
/**
* @hidden
*/
protected destroy$ = new Subject<any>();
private _totalItemCount: number = null;
private _adjustToIndex;
// Start properties related to virtual height handling due to browser limitation
/** Maximum height for an element of the browser. */
private _maxHeight;
/**
* Ratio for height that's being virtualizaed and the one visible
* If _virtHeightRatio = 1, the visible height and the virtualized are the same, also _maxHeight > _virtHeight.
*/
private _virtHeightRatio = 1;
/**
* The total count of the virtual data items, when using remote service.
* Similar to the property totalItemCount, but this will allow setting the data count into the template.
* ```html
* <ng-template igxFor let-item [igxForOf]="data | async" [igxForTotalItemCount]="count | async"
* [igxForContainerSize]="'500px'" [igxForItemSize]="'50px'"></ng-template>
* ```
*/
@Input()
public get igxForTotalItemCount(): number {
return this.totalItemCount;
}
public set igxForTotalItemCount(value: number) {
this.totalItemCount = value;
}
/**
* The total count of the virtual data items, when using remote service.
* ```typescript
* this.parentVirtDir.totalItemCount = data.Count;
* ```
*/
public get totalItemCount() {
return this._totalItemCount;
}
public set totalItemCount(val) {
if (this._totalItemCount !== val) {
this._totalItemCount = val;
// update sizes in case total count changes.
const newSize = this.initSizesCache(this.igxForOf);
const sizeDiff = this.scrollComponent.size - newSize;
this.scrollComponent.size = newSize;
const lastChunkExceeded = this.state.startIndex + this.state.chunkSize > val;
if (lastChunkExceeded) {
this.state.startIndex = val - this.state.chunkSize;
}
this._adjustScrollPositionAfterSizeChange(sizeDiff);
}
}
public get displayContainer(): HTMLElement | undefined {
return this.dc?.instance?._viewContainer?.element?.nativeElement;
}
public get virtualHelper() {
return this.scrollComponent.nativeElement;
}
/**
* @hidden
*/
public get isRemote(): boolean {
return this.totalItemCount !== null;
}
/**
*
* Gets/Sets the scroll position.
* ```typescript
* const position = directive.scrollPosition;
* directive.scrollPosition = value;
* ```
*/
public get scrollPosition(): number {
return this.scrollComponent.scrollAmount;
}
public set scrollPosition(val: number) {
if (val === this.scrollComponent.scrollAmount) {
return;
}
if (this.igxForScrollOrientation === 'horizontal' && this.scrollComponent) {
this.scrollComponent.nativeElement.scrollLeft = val;
} else if (this.scrollComponent) {
this.scrollComponent.nativeElement.scrollTop = val;
}
}
/**
* @hidden
*/
protected get isRTL() {
const dir = window.getComputedStyle(this.dc.instance._viewContainer.element.nativeElement).getPropertyValue('direction');
return dir === 'rtl';
}
protected get sizesCache(): number[] {
return this._sizesCache;
}
protected set sizesCache(value: number[]) {
this._sizesCache = value;
}
private get _isScrolledToBottom() {
if (!this.getScroll()) {
return true;
}
const scrollHeight = this.getScroll().scrollHeight;
// Use === and not >= because `scrollTop + container size` can't be bigger than `scrollHeight`, unless something isn't updated.
// Also use Math.round because Chrome has some inconsistencies and `scrollTop + container` can be float when zooming the page.
return Math.round(this.getScroll().scrollTop + this.igxForContainerSize) === scrollHeight;
}
private get _isAtBottomIndex() {
return this.igxForOf && this.state.startIndex + this.state.chunkSize > this.igxForOf.length;
}
constructor(
private _viewContainer: ViewContainerRef,
protected _template: TemplateRef<NgForOfContext<T>>,
protected _differs: IterableDiffers,
public cdr: ChangeDetectorRef,
protected _zone: NgZone,
protected syncScrollService: IgxForOfScrollSyncService,
protected platformUtil: PlatformUtil,
@Inject(DOCUMENT)
protected document: any,
) { }
public verticalScrollHandler(event) {
this.onScroll(event);
}
public isScrollable() {
return this.scrollComponent.size > parseInt(this.igxForContainerSize, 10);
}
/**
* @hidden
*/
public ngOnInit(): void {
let totalSize = 0;
const vc = this.igxForScrollContainer ? this.igxForScrollContainer._viewContainer : this._viewContainer;
this.igxForSizePropName = this.igxForSizePropName || 'width';
this.dc = this._viewContainer.createComponent(DisplayContainerComponent, { index: 0 });
this.dc.instance.scrollDirection = this.igxForScrollOrientation;
if (this.igxForOf && this.igxForOf.length) {
totalSize = this.initSizesCache(this.igxForOf);
this.scrollComponent = this.syncScrollService.getScrollMaster(this.igxForScrollOrientation);
this.state.chunkSize = this._calculateChunkSize();
this.dc.instance.notVirtual = !(this.igxForContainerSize && this.state.chunkSize < this.igxForOf.length);
if (this.scrollComponent && !this.scrollComponent.destroyed) {
this.state.startIndex = Math.min(this.getIndexAt(this.scrollPosition, this.sizesCache),
this.igxForOf.length - this.state.chunkSize);
}
for (let i = this.state.startIndex; i < this.state.startIndex + this.state.chunkSize &&
this.igxForOf[i] !== undefined; i++) {
const input = this.igxForOf[i];
const embeddedView = this.dc.instance._vcr.createEmbeddedView(
this._template,
new IgxForOfContext<T>(input, this.getContextIndex(input), this.igxForOf.length)
);
this._embeddedViews.push(embeddedView);
}
}
if (this.igxForScrollOrientation === 'vertical') {
this.dc.instance._viewContainer.element.nativeElement.style.top = '0px';
this.scrollComponent = this.syncScrollService.getScrollMaster(this.igxForScrollOrientation);
if (!this.scrollComponent || this.scrollComponent.destroyed) {
this.scrollComponent = vc.createComponent(VirtualHelperComponent).instance;
}
this._maxHeight = this._calcMaxBrowserHeight();
this.scrollComponent.size = this.igxForOf ? this._calcHeight() : 0;
this.syncScrollService.setScrollMaster(this.igxForScrollOrientation, this.scrollComponent);
this._zone.runOutsideAngular(() => {
this.verticalScrollHandler = this.verticalScrollHandler.bind(this);
this.scrollComponent.nativeElement.addEventListener('scroll', this.verticalScrollHandler);
this.dc.instance.scrollContainer = this.scrollComponent.nativeElement;
});
const destructor = takeUntil<any>(this.destroy$);
this.contentResizeNotify.pipe(
filter(() => this.igxForContainerSize && this.igxForOf && this.igxForOf.length > 0),
throttleTime(40, undefined, { leading: true, trailing: true }),
destructor
).subscribe(() => this._zone.runTask(() => this.updateSizes()));
}
if (this.igxForScrollOrientation === 'horizontal') {
this.func = (evt) => this.onHScroll(evt);
this.scrollComponent = this.syncScrollService.getScrollMaster(this.igxForScrollOrientation);
if (!this.scrollComponent) {
this.scrollComponent = vc.createComponent(HVirtualHelperComponent).instance;
this.scrollComponent.size = totalSize;
this.syncScrollService.setScrollMaster(this.igxForScrollOrientation, this.scrollComponent);
this._zone.runOutsideAngular(() => {
this.scrollComponent.nativeElement.addEventListener('scroll', this.func);
this.dc.instance.scrollContainer = this.scrollComponent.nativeElement;
});
} else {
this._zone.runOutsideAngular(() => {
this.scrollComponent.nativeElement.addEventListener('scroll', this.func);
this.dc.instance.scrollContainer = this.scrollComponent.nativeElement;
});
}
this._updateHScrollOffset();
}
}
public ngAfterViewInit(): void {
if (this.igxForScrollOrientation === 'vertical') {
this._zone.runOutsideAngular(() => {
this.contentObserver = new (getResizeObserver())(() => this.contentResizeNotify.next());
this.contentObserver.observe(this.dc.instance._viewContainer.element.nativeElement);
});
}
}
/**
* @hidden
*/
public ngOnDestroy() {
this.removeScrollEventListeners();
this.destroy$.next(true);
this.destroy$.complete();
if (this.contentObserver) {
this.contentObserver.disconnect();
}
}
/**
* @hidden @internal
* Asserts the correct type of the context for the template that `igxForOf` will render.
*
* The presence of this method is a signal to the Ivy template type-check compiler that the
* `IgxForOf` structural directive renders its template with a specific context type.
*/
public static ngTemplateContextGuard<T, U extends T[]>(dir: IgxForOfDirective<T, U>, ctx: any):
ctx is IgxForOfContext<T> {
return true;
}
/**
* @hidden
*/
public ngOnChanges(changes: SimpleChanges): void {
const forOf = 'igxForOf';
if (forOf in changes) {
const value = changes[forOf].currentValue;
if (!this._differ && value) {
try {
this._differ = this._differs.find(value).create(this.igxForTrackBy);
} catch (e) {
throw new Error(
`Cannot find a differ supporting object "${value}" of type "${getTypeNameForDebugging(value)}".
NgFor only supports binding to Iterables such as Arrays.`);
}
}
}
const defaultItemSize = 'igxForItemSize';
if (defaultItemSize in changes && !changes[defaultItemSize].firstChange &&
this.igxForScrollOrientation === 'vertical' && this.igxForOf) {
// handle default item size changed.
this.initSizesCache(this.igxForOf);
this._applyChanges();
}
const containerSize = 'igxForContainerSize';
if (containerSize in changes && !changes[containerSize].firstChange && this.igxForOf) {
this._recalcOnContainerChange();
}
}
/**
* @hidden
*/
public ngDoCheck(): void {
if (this._differ) {
const changes = this._differ.diff(this.igxForOf);
if (changes) {
// re-init cache.
if (!this.igxForOf) {
this.igxForOf = [] as U;
}
this._updateSizeCache();
this._zone.run(() => {
this._applyChanges();
this.cdr.markForCheck();
this._updateScrollOffset();
this.dataChanged.emit();
});
}
}
}
/**
* Shifts the scroll thumb position.
* ```typescript
* this.parentVirtDir.addScrollTop(5);
* ```
*
* @param addTop negative value to scroll up and positive to scroll down;
*/
public addScrollTop(addTop: number): boolean {
if (addTop === 0 && this.igxForScrollOrientation === 'horizontal') {
return false;
}
const originalVirtScrollTop = this._virtScrollTop;
const containerSize = parseInt(this.igxForContainerSize, 10);
const maxVirtScrollTop = this._virtHeight - containerSize;
this._bScrollInternal = true;
this._virtScrollTop += addTop;
this._virtScrollTop = this._virtScrollTop > 0 ?
(this._virtScrollTop < maxVirtScrollTop ? this._virtScrollTop : maxVirtScrollTop) :
0;
this.scrollPosition += addTop / this._virtHeightRatio;
if (Math.abs(addTop / this._virtHeightRatio) < 1) {
// Actual scroll delta that was added is smaller than 1 and onScroll handler doesn't trigger when scrolling < 1px
const scrollOffset = this.fixedUpdateAllElements(this._virtScrollTop);
// scrollOffset = scrollOffset !== parseInt(this.igxForItemSize, 10) ? scrollOffset : 0;
this.dc.instance._viewContainer.element.nativeElement.style.top = -(scrollOffset) + 'px';
}
const maxRealScrollTop = this.scrollComponent.nativeElement.scrollHeight - containerSize;
if ((this._virtScrollTop > 0 && this.scrollPosition === 0) ||
(this._virtScrollTop < maxVirtScrollTop && this.scrollPosition === maxRealScrollTop)) {
// Actual scroll position is at the top or bottom, but virtual one is not at the top or bottom (there's more to scroll)
// Recalculate actual scroll position based on the virtual scroll.
this.scrollPosition = this._virtScrollTop / this._virtHeightRatio;
} else if (this._virtScrollTop === 0 && this.scrollPosition > 0) {
// Actual scroll position is not at the top, but virtual scroll is. Just update the actual scroll
this.scrollPosition = 0;
} else if (this._virtScrollTop === maxVirtScrollTop && this.scrollPosition < maxRealScrollTop) {
// Actual scroll position is not at the bottom, but virtual scroll is. Just update the acual scroll
this.scrollPosition = maxRealScrollTop;
}
return this._virtScrollTop !== originalVirtScrollTop;
}
/**
* Scrolls to the specified index.
* ```typescript
* this.parentVirtDir.scrollTo(5);
* ```
*
* @param index
*/
public scrollTo(index) {
if (index < 0 || index > (this.isRemote ? this.totalItemCount : this.igxForOf.length) - 1) {
return;
}
const containerSize = parseInt(this.igxForContainerSize, 10);
const isPrevItem = index < this.state.startIndex || this.scrollPosition > this.sizesCache[index];
let nextScroll = isPrevItem ? this.sizesCache[index] : this.sizesCache[index + 1] - containerSize;
if (nextScroll < 0) {
return;
}
if (this.igxForScrollOrientation === 'horizontal') {
this.scrollPosition = this.isRTL ? -nextScroll : nextScroll;
} else {
const maxVirtScrollTop = this._virtHeight - containerSize;
if (nextScroll > maxVirtScrollTop) {
nextScroll = maxVirtScrollTop;
}
this._bScrollInternal = true;
this._virtScrollTop = nextScroll;
this.scrollPosition = this._virtScrollTop / this._virtHeightRatio;
this._adjustToIndex = !isPrevItem ? index : null;
}
}
/**
* Scrolls by one item into the appropriate next direction.
* For "horizontal" orientation that will be the right column and for "vertical" that is the lower row.
* ```typescript
* this.parentVirtDir.scrollNext();
* ```
*/
public scrollNext() {
const scr = Math.abs(Math.ceil(this.scrollPosition));
const endIndex = this.getIndexAt(scr + parseInt(this.igxForContainerSize, 10), this.sizesCache);
this.scrollTo(endIndex);
}
/**
* Scrolls by one item into the appropriate previous direction.
* For "horizontal" orientation that will be the left column and for "vertical" that is the upper row.
* ```typescript
* this.parentVirtDir.scrollPrev();
* ```
*/
public scrollPrev() {
this.scrollTo(this.state.startIndex - 1);
}
/**
* Scrolls by one page into the appropriate next direction.
* For "horizontal" orientation that will be one view to the right and for "vertical" that is one view to the bottom.
* ```typescript
* this.parentVirtDir.scrollNextPage();
* ```
*/
public scrollNextPage() {
if (this.igxForScrollOrientation === 'horizontal') {
this.scrollPosition += this.isRTL ? -parseInt(this.igxForContainerSize, 10) : parseInt(this.igxForContainerSize, 10);
} else {
this.addScrollTop(parseInt(this.igxForContainerSize, 10));
}
}
/**
* Scrolls by one page into the appropriate previous direction.
* For "horizontal" orientation that will be one view to the left and for "vertical" that is one view to the top.
* ```typescript
* this.parentVirtDir.scrollPrevPage();
* ```
*/
public scrollPrevPage() {
if (this.igxForScrollOrientation === 'horizontal') {
this.scrollPosition -= this.isRTL ? -parseInt(this.igxForContainerSize, 10) : parseInt(this.igxForContainerSize, 10);
} else {
const containerSize = (parseInt(this.igxForContainerSize, 10));
this.addScrollTop(-containerSize);
}
}
/**
* @hidden
*/
public getColumnScrollLeft(colIndex) {
return this.sizesCache[colIndex];
}
/**
* Returns the total number of items that are fully visible.
* ```typescript
* this.parentVirtDir.getItemCountInView();
* ```
*/
public getItemCountInView() {
let startIndex = this.getIndexAt(this.scrollPosition, this.sizesCache);
if (this.scrollPosition - this.sizesCache[startIndex] > 0) {
// fisrt item is not fully in view
startIndex++;
}
const endIndex = this.getIndexAt(this.scrollPosition + parseInt(this.igxForContainerSize, 10), this.sizesCache);
return endIndex - startIndex;
}
/**
* Returns a reference to the scrollbar DOM element.
* This is either a vertical or horizontal scrollbar depending on the specified igxForScrollOrientation.
* ```typescript
* dir.getScroll();
* ```
*/
public getScroll() {
return this.scrollComponent?.nativeElement;
}
/**
* Returns the size of the element at the specified index.
* ```typescript
* this.parentVirtDir.getSizeAt(1);
* ```
*/
public getSizeAt(index: number) {
return this.sizesCache[index + 1] - this.sizesCache[index];
}
/**
* @hidden
* Function that is called to get the native scrollbar size that the browsers renders.
*/
public getScrollNativeSize() {
return this.scrollComponent ? this.scrollComponent.scrollNativeSize : 0;
}
/**
* Returns the scroll offset of the element at the specified index.
* ```typescript
* this.parentVirtDir.getScrollForIndex(1);
* ```
*/
public getScrollForIndex(index: number, bottom?: boolean) {
const containerSize = parseInt(this.igxForContainerSize, 10);
const scroll = bottom ? Math.max(0, this.sizesCache[index + 1] - containerSize) : this.sizesCache[index];
return scroll;
}
/**
* Returns the index of the element at the specified offset.
* ```typescript
* this.parentVirtDir.getIndexAtScroll(100);
* ```
*/
public getIndexAtScroll(scrollOffset: number) {
return this.getIndexAt(scrollOffset, this.sizesCache);
}
/**
* Returns whether the target index is outside the view.
* ```typescript
* this.parentVirtDir.isIndexOutsideView(10);
* ```
*/
public isIndexOutsideView(index: number) {
const targetNode = index >= this.state.startIndex && index <= this.state.startIndex + this.state.chunkSize ?
this._embeddedViews.map(view =>
view.rootNodes.find(node => node.nodeType === Node.ELEMENT_NODE) || view.rootNodes[0].nextElementSibling)[index - this.state.startIndex] : null;
const rowHeight = this.getSizeAt(index);
const containerSize = parseInt(this.igxForContainerSize, 10);
const containerOffset = -(this.scrollPosition - this.sizesCache[this.state.startIndex]);
const endTopOffset = targetNode ? targetNode.offsetTop + rowHeight + containerOffset : containerSize + rowHeight;
return !targetNode || targetNode.offsetTop < Math.abs(containerOffset)
|| containerSize && endTopOffset - containerSize > 5;
}
/**
* @hidden
* Function that recalculates and updates cache sizes.
*/
public recalcUpdateSizes() {
const dimension = this.igxForScrollOrientation === 'horizontal' ?
this.igxForSizePropName : 'height';
const diffs = [];
let totalDiff = 0;
const l = this._embeddedViews.length;
const rNodes = this._embeddedViews.map(view =>
view.rootNodes.find(node => node.nodeType === Node.ELEMENT_NODE) || view.rootNodes[0].nextElementSibling);
for (let i = 0; i < l; i++) {
const rNode = rNodes[i];
if (rNode) {
const height = window.getComputedStyle(rNode).getPropertyValue('height');
const h = parseFloat(height) || parseInt(this.igxForItemSize, 10);
const index = this.state.startIndex + i;
if (!this.isRemote && !this.igxForOf[index]) {
continue;
}
const margin = this.getMargin(rNode, dimension);
const oldVal = dimension === 'height' ? this.heightCache[index] : this.igxForOf[index][dimension];
const newVal = (dimension === 'height' ? h : rNode.clientWidth) + margin;
if (dimension === 'height') {
this.heightCache[index] = newVal;
} else {
this.igxForOf[index][dimension] = newVal;
}
const currDiff = newVal - oldVal;
diffs.push(currDiff);
totalDiff += currDiff;
this.sizesCache[index + 1] += totalDiff;
}
}
// update cache
if (Math.abs(totalDiff) > 0) {
for (let j = this.state.startIndex + this.state.chunkSize + 1; j < this.sizesCache.length; j++) {
this.sizesCache[j] += totalDiff;
}
// update scrBar heights/widths
if (this.igxForScrollOrientation === 'horizontal') {
const firstScrollChild = this.scrollComponent.nativeElement.children.item(0) as HTMLElement;
const totalWidth = parseInt(firstScrollChild.style.width, 10) + totalDiff;
firstScrollChild.style.width = `${totalWidth}px`;
}
const reducer = (acc, val) => acc + val;
if (this.igxForScrollOrientation === 'vertical') {
const scrToBottom = this._isScrolledToBottom && !this.dc.instance.notVirtual;
const hSum = this.heightCache.reduce(reducer);
if (hSum > this._maxHeight) {
this._virtHeightRatio = hSum / this._maxHeight;
}
this.scrollComponent.size = Math.min(this.scrollComponent.size + totalDiff, this._maxHeight);
this._virtHeight = hSum;
if (!this.scrollComponent.destroyed) {
this.scrollComponent.cdr.detectChanges();
}
if (scrToBottom && !this._isAtBottomIndex) {
const containerSize = parseInt(this.igxForContainerSize, 10);
const maxVirtScrollTop = this._virtHeight - containerSize;
this._bScrollInternal = true;
this._virtScrollTop = maxVirtScrollTop;
this.scrollPosition = maxVirtScrollTop;
return;
}
if (this._adjustToIndex) {
// in case scrolled to specific index where after scroll heights are changed
// need to adjust the offsets so that item is last in view.
const updatesToIndex = this._adjustToIndex - this.state.startIndex + 1;
const sumDiffs = diffs.slice(0, updatesToIndex).reduce(reducer);
if (sumDiffs !== 0) {
this.addScrollTop(sumDiffs);
}
this._adjustToIndex = null;
}
}
}
}
/**
* @hidden
* Reset scroll position.
* Needed in case scrollbar is hidden/detached but we still need to reset it.
*/
public resetScrollPosition() {
this.scrollPosition = 0;
this.scrollComponent.scrollAmount = 0;
this.state.startIndex = 0;
}
/**
* @hidden
*/
protected removeScrollEventListeners() {
if (this.igxForScrollOrientation === 'horizontal') {
this._zone.runOutsideAngular(() => this.scrollComponent?.nativeElement?.removeEventListener('scroll', this.func));
} else {
this._zone.runOutsideAngular(() =>
this.scrollComponent?.nativeElement?.removeEventListener('scroll', this.verticalScrollHandler)
);
}
}
/**
* @hidden
* Function that is called when scrolling vertically
*/
protected onScroll(event) {
/* in certain situations this may be called when no scrollbar is visible */
if (!parseInt(this.scrollComponent.nativeElement.style.height, 10)) {
return;
}
if (!this._bScrollInternal) {
this._calcVirtualScrollTop(event.target.scrollTop);
} else {
this._bScrollInternal = false;
}
const prevStartIndex = this.state.startIndex;
const scrollOffset = this.fixedUpdateAllElements(this._virtScrollTop);
this.dc.instance._viewContainer.element.nativeElement.style.top = -(scrollOffset) + 'px';
this._zone.onStable.pipe(first()).subscribe(this.recalcUpdateSizes.bind(this));
this.dc.changeDetectorRef.detectChanges();
if (prevStartIndex !== this.state.startIndex) {
this.chunkLoad.emit(this.state);
}
}
protected updateSizes() {
if (!this.scrollComponent.nativeElement.isConnected) return;
const scrollable = this.isScrollable();
this.recalcUpdateSizes();
this._applyChanges();
this._updateScrollOffset();
if (scrollable !== this.isScrollable()) {
this.scrollbarVisibilityChanged.emit();
} else {
this.contentSizeChange.emit();
}
}
/**
* @hidden
*/
protected fixedUpdateAllElements(inScrollTop: number): number {
const count = this.isRemote ? this.totalItemCount : this.igxForOf.length;
let newStart = this.getIndexAt(inScrollTop, this.sizesCache);
if (newStart + this.state.chunkSize > count) {
newStart = count - this.state.chunkSize;
}
const prevStart = this.state.startIndex;
const diff = newStart - this.state.startIndex;
this.state.startIndex = newStart;
if (diff) {
this.chunkPreload.emit(this.state);
if (!this.isRemote) {
// recalculate and apply page size.
if (diff && Math.abs(diff) <= MAX_PERF_SCROLL_DIFF) {
if (diff > 0) {
this.moveApplyScrollNext(prevStart);
} else {
this.moveApplyScrollPrev(prevStart);
}
} else {
this.fixedApplyScroll();
}
}
}
return inScrollTop - this.sizesCache[this.state.startIndex];
}
/**
* @hidden
* The function applies an optimized state change for scrolling down/right employing context change with view rearrangement
*/
protected moveApplyScrollNext(prevIndex: number): void {
const start = prevIndex + this.state.chunkSize;
const end = start + this.state.startIndex - prevIndex;
const container = this.dc.instance._vcr as ViewContainerRef;
for (let i = start; i < end && this.igxForOf[i] !== undefined; i++) {
const embView = this._embeddedViews.shift();
if (!embView.destroyed) {
this.scrollFocus(embView.rootNodes.find(node => node.nodeType === Node.ELEMENT_NODE)
|| embView.rootNodes[0].nextElementSibling);
const view = container.detach(0);
this.updateTemplateContext(embView.context, i);
container.insert(view);
this._embeddedViews.push(embView);
}
}
}
/**
* @hidden
* The function applies an optimized state change for scrolling up/left employing context change with view rearrangement
*/
protected moveApplyScrollPrev(prevIndex: number): void {
const container = this.dc.instance._vcr as ViewContainerRef;
for (let i = prevIndex - 1; i >= this.state.startIndex && this.igxForOf[i] !== undefined; i--) {
const embView = this._embeddedViews.pop();
if (!embView.destroyed) {
this.scrollFocus(embView.rootNodes.find(node => node.nodeType === Node.ELEMENT_NODE)
|| embView.rootNodes[0].nextElementSibling);
const view = container.detach(container.length - 1);
this.updateTemplateContext(embView.context, i);
container.insert(view, 0);
this._embeddedViews.unshift(embView);
}
}
}
/**
* @hidden
*/
protected getContextIndex(input) {
return this.isRemote ? this.state.startIndex + this.igxForOf.indexOf(input) : this.igxForOf.indexOf(input);
}
/**
* @hidden
* Function which updates the passed context of an embedded view with the provided index
* from the view container.
* Often, called while handling a scroll event.
*/
protected updateTemplateContext(context: any, index = 0): void {
context.$implicit = this.igxForOf[index];
context.index = this.getContextIndex(this.igxForOf[index]);
context.count = this.igxForOf.length;
}
/**
* @hidden
* The function applies an optimized state change through context change for each view
*/
protected fixedApplyScroll(): void {
let j = 0;
const endIndex = this.state.startIndex + this.state.chunkSize;
for (let i = this.state.startIndex; i < endIndex && this.igxForOf[i] !== undefined; i++) {
const embView = this._embeddedViews[j++];
this.updateTemplateContext(embView.context, i);
}
}
/**
* @hidden
* @internal
*
* Clears focus inside the virtualized container on small scroll swaps.
*/
protected scrollFocus(node?: HTMLElement): void {
const document = node.getRootNode() as Document | ShadowRoot;
const activeElement = document.activeElement as HTMLElement;
// Remove focus in case the the active element is inside the view container.
// Otherwise we hit an exception while doing the 'small' scrolls swapping.
// For more information:
//
// https://developer.mozilla.org/en-US/docs/Web/API/Node/removeChild
// https://bugs.chromium.org/p/chromium/issues/detail?id=432392
if (node && node.contains(activeElement)) {
activeElement.blur();
}
}
/**
* @hidden
* Function that is called when scrolling horizontally
*/
protected onHScroll(event) {
/* in certain situations this may be called when no scrollbar is visible */
const firstScrollChild = this.scrollComponent.nativeElement.children.item(0) as HTMLElement;
if (!parseInt(firstScrollChild.style.width, 10)) {
return;
}
const prevStartIndex = this.state.startIndex;
const scrLeft = event.target.scrollLeft;
// Updating horizontal chunks
const scrollOffset = this.fixedUpdateAllElements(Math.abs(event.target.scrollLeft));
if (scrLeft < 0) {
// RTL
this.dc.instance._viewContainer.element.nativeElement.style.left = scrollOffset + 'px';
} else {
this.dc.instance._viewContainer.element.nativeElement.style.left = -scrollOffset + 'px';
}
this.dc.changeDetectorRef.detectChanges();
if (prevStartIndex !== this.state.startIndex) {
this.chunkLoad.emit(this.state);
}
}
/**
* Gets the function used to track changes in the items collection.
* By default the object references are compared. However this can be optimized if you have unique identifier
* value that can be used for the comparison instead of the object ref or if you have some other property values
* in the item object that should be tracked for changes.
* This option is similar to ngForTrackBy.
* ```typescript
* const trackFunc = this.parentVirtDir.igxForTrackBy;
* ```
*/
@Input()
public get igxForTrackBy(): TrackByFunction<T> {
return this._trackByFn;
}
/**
* Sets the function used to track changes in the items collection.
* This function can be set in scenarios where you want to optimize or
* customize the tracking of changes for the items in the collection.
* The igxForTrackBy function takes the index and the current item as arguments and needs to return the unique identifier for this item.
* ```typescript
* this.parentVirtDir.igxForTrackBy = (index, item) => {
* return item.id + item.width;
* };
* ```
*/
public set igxForTrackBy(fn: TrackByFunction<T>) {
this._trackByFn = fn;
}
/**
* @hidden
*/
protected _applyChanges() {
const prevChunkSize = this.state.chunkSize;
this.applyChunkSizeChange();
this._recalcScrollBarSize();
if (this.igxForOf && this.igxForOf.length && this.dc) {
const embeddedViewCopy = Object.assign([], this._embeddedViews);
let startIndex = this.state.startIndex;
let endIndex = this.state.chunkSize + this.state.startIndex;
if (this.isRemote) {
startIndex = 0;
endIndex = this.igxForOf.length;
}
for (let i = startIndex; i < endIndex && this.igxForOf[i] !== undefined; i++) {
const embView = embeddedViewCopy.shift();
this.updateTemplateContext(embView.context, i);
}
if (prevChunkSize !== this.state.chunkSize) {
this.chunkLoad.emit(this.state);
}
}
}
/**
* @hidden
*/
protected _calcMaxBrowserHeight(): number {
if (!this.platformUtil.isBrowser) {
return 0;
}
const div = this.document.createElement('div');
const style = div.style;
style.position = 'absolute';
style.top = '9999999999999999px';
this.document.body.appendChild(div);
const size = Math.abs(div.getBoundingClientRect()['top']);
this.document.body.removeChild(div);
return size;
}
/**
* @hidden
* Recalculates the chunkSize based on current startIndex and returns the new size.
* This should be called after this.state.startIndex is updated, not before.
*/
protected _calculateChunkSize(): number {
let chunkSize = 0;
if (this.igxForContainerSize !== null && this.igxForContainerSize !== undefined) {
if (!this.sizesCache) {
this.initSizesCache(this.igxForOf);
}
chunkSize = this._calcMaxChunkSize();
if (this.igxForOf && chunkSize > this.igxForOf.length) {
chunkSize = this.igxForOf.length;
}
} else {
if (this.igxForOf) {
chunkSize = this.igxForOf.length;
}
}
return chunkSize;
}
/**
* @hidden
*/
protected getElement(viewref, nodeName) {
const elem = viewref.element.nativeElement.parentNode.getElementsByTagName(nodeName);
return elem.length > 0 ? elem[0] : null;
}
/**
* @hidden
*/
protected initSizesCache(items: U): number {
let totalSize = 0;
let size = 0;
const dimension = this.igxForSizePropName || 'height';
let i = 0;
this.sizesCache = [];
this.heightCache = [];
this.sizesCache.push(0);
const count = this.isRemote ? this.totalItemCount : items.length;
for (i; i < count; i++) {
size = this._getItemSize(items[i], dimension);
if (this.igxForScrollOrientation === 'vertical') {
this.heightCache.push(size);
}
totalSize += size;
this.sizesCache.push(totalSize);
}
return totalSize;
}
protected _updateSizeCache() {
if (this.igxForScrollOrientation === 'horizontal') {
this.initSizesCache(this.igxForOf);
return;
}
const oldHeight = this.heightCache.length > 0 ? this.heightCache.reduce((acc, val) => acc + val) : 0;
const newHeight = this.initSizesCache(this.igxForOf);
const diff = oldHeight - newHeight;
this._adjustScrollPositionAfterSizeChange(diff);
}
/**
* @hidden
*/
protected _calcMaxChunkSize(): number {
let i = 0;
let length = 0;
let maxLength = 0;
const arr = [];
let sum = 0;
const availableSize = parseInt(this.igxForContainerSize, 10);
if (!availableSize) {
return 0;
}
const dimension = this.igxForScrollOrientation === 'horizontal' ?
this.igxForSizePropName : 'height';
const reducer = (accumulator, currentItem) => accumulator + this._getItemSize(currentItem, dimension);
for (i; i < this.igxForOf.length; i++) {
let item: T | { value: T, height: number} = this.igxForOf[i];
if (dimension === 'height') {
item = { value: this.igxForOf[i], height: this.heightCache[i] };
}
const size = dimension === 'height' ?
this.heightCache[i] :
this._getItemSize(item, dimension);
sum = arr.reduce(reducer, size);
if (sum < availableSize) {
arr.push(item);
length = arr.length;
if (i === this.igxForOf.length - 1) {
// reached end without exceeding
// include prev items until size is filled or first item is reached.
let curItem = dimension === 'height' ? arr[0].value : arr[0];
let prevIndex = this.igxForOf.indexOf(curItem) - 1;
while (prevIndex >= 0 && sum <= availableSize) {
curItem = dimension === 'height' ? arr[0].value : arr[0];
prevIndex = this.igxForOf.indexOf(curItem) - 1;
const prevItem = this.igxForOf[prevIndex];
const prevSize = dimension === 'height' ?
this.heightCache[prevIndex] :
parseInt(prevItem[dimension], 10);
sum = arr.reduce(reducer, prevSize);
arr.unshift(prevItem);
length = arr.length;
}
}
} else {
arr.push(item);
length = arr.length + 1;
arr.shift();
}
if (length > maxLength) {
maxLength = length;
}
}
return maxLength;
}
/**
* @hidden
*/
protected getIndexAt(left, set) {
let start = 0;
let end = set.length - 1;
if (left === 0) {
return 0;
}
while (start <= end) {
const midIdx = Math.floor((start + end) / 2);
const midLeft = set[midIdx];
const cmp = left - midLeft;
if (cmp > 0) {
start = midIdx + 1;
} else if (cmp < 0) {
end = midIdx - 1;
} else {
return midIdx;
}
}
return end;
}
protected _recalcScrollBarSize() {
const count = this.isRemote ? this.totalItemCount : (this.igxForOf ? this.igxForOf.length : 0);
this.dc.instance.notVirtual = !(this.igxForContainerSize && this.dc && this.state.chunkSize < count);
const scrollable = this.isScrollable();
if (this.igxForScrollOrientation === 'horizontal') {
const totalWidth = parseInt(this.igxForContainerSize, 10) > 0 ? this.initSizesCache(this.igxForOf) : 0;
this.scrollComponent.nativeElement.style.width = this.igxForContainerSize + 'px';
this.scrollComponent.size = totalWidth;
if (totalWidth <= pars