src/virtual-scroll.component.ts
Properties |
end |
end:
|
Type : number
|
Defined in src/virtual-scroll.component.ts:25
|
start |
start:
|
Type : number
|
Defined in src/virtual-scroll.component.ts:24
|
import {
Component,
ContentChild,
ElementRef,
EventEmitter,
HostListener,
Input,
NgZone,
OnChanges,
OnDestroy,
OnInit,
Output,
Renderer2,
SimpleChanges,
ViewChild,
Inject,
} from '@angular/core';
import { WindowRef } from './window-ref';
const tween = require('@tweenjs/tween.js');
export interface ChangeEvent {
start?: number;
end?: number;
}
@Component({
selector: 'virtual-scroll,[virtualScroll]',
exportAs: 'virtualScroll',
template: `
<div class="total-padding" #shim></div>
<div class="scrollable-content" #content>
<ng-content></ng-content>
</div>
`,
host: {
'[style.overflow-y]': "parentScroll ? 'hidden' : 'auto'"
},
styles: [`
:host {
overflow: hidden;
position: relative;
display: block;
-webkit-overflow-scrolling: touch;
}
.scrollable-content {
top: 0;
left: 0;
width: 100%;
height: 100%;
position: absolute;
}
.total-padding {
width: 1px;
opacity: 0;
}
`]
})
export class VirtualScrollComponent implements OnInit, OnChanges, OnDestroy {
@Input()
public items: any[] = [];
@Input()
public scrollbarWidth: number;
@Input()
public scrollbarHeight: number;
@Input()
public childWidth: number;
@Input()
public childHeight: number;
@Input()
public bufferAmount: number = 0;
@Input()
public scrollAnimationTime: number = 1500;
@Output()
public update: EventEmitter<any[]> = new EventEmitter<any[]>();
public viewPortItems: any[];
@Output()
public change: EventEmitter<ChangeEvent> = new EventEmitter<ChangeEvent>();
@Output()
public start: EventEmitter<ChangeEvent> = new EventEmitter<ChangeEvent>();
@Output()
public end: EventEmitter<ChangeEvent> = new EventEmitter<ChangeEvent>();
@ViewChild('content', { read: ElementRef })
private contentElementRef: ElementRef;
@ViewChild('shim', { read: ElementRef })
private shimElementRef: ElementRef;
@ContentChild('container')
private containerElementRef: ElementRef;
private previousStart?: number;
private previousEnd?: number;
private startupLoop: boolean = true;
private currentTween: any;
private _parentScroll: ElementRef | WindowRef;
@Input()
set parentScroll(element: ElementRef | WindowRef) {
if (this._parentScroll === element) {
return;
}
this._parentScroll = element;
this.addParentEventHandlers(this._parentScroll);
}
get parentScroll(): ElementRef | WindowRef {
return this._parentScroll;
}
private disposeScrollHandler?: () => void | undefined;
private disposeResizeHandler?: () => void | undefined;
/** Cache of the last scroll height to prevent setting CSS when not needed. */
private lastScrollHeight = -1;
/** Cache of the last top padding to prevent setting CSS when not needed. */
private lastTopPadding = -1;
constructor(
private windowRef: WindowRef,
private readonly element: ElementRef,
private readonly renderer: Renderer2,
private readonly zone: NgZone
) { }
public ngOnInit() {
this.scrollbarWidth = 0; // this.element.nativeElement.offsetWidth - this.element.nativeElement.clientWidth;
this.scrollbarHeight = 0; // this.element.nativeElement.offsetHeight - this.element.nativeElement.clientHeight;
if (!this.parentScroll) {
this.addParentEventHandlers(this.element.nativeElement);
}
}
public ngOnDestroy() {
this.removeParentEventHandlers();
}
public ngOnChanges(changes: SimpleChanges) {
this.previousStart = undefined;
this.previousEnd = undefined;
const items = (changes as any).items || {};
if ((changes as any).items != undefined && items.previousValue == undefined || (items.previousValue != undefined && items.previousValue.length === 0)) {
this.startupLoop = true;
}
this.refresh();
}
public refresh() {
this.zone.runOutsideAngular(() => {
if (typeof requestAnimationFrame !== 'undefined') {
requestAnimationFrame(() => this.calculateItems());
}
});
}
public scrollInto(item: any) {
let el: Element = this._parentElement;
let offsetTop = this.getElementsOffset();
let index: number = (this.items || []).indexOf(item);
if (index < 0 || index >= (this.items || []).length) return;
let d = this.calculateDimensions();
let scrollTop = (Math.floor(index / d.itemsPerRow) * d.childHeight)
- (d.childHeight * Math.min(index, this.bufferAmount));
if (this.currentTween != undefined) this.currentTween.stop();
this.currentTween = new tween.Tween({ scrollTop: el.scrollTop })
.to({ scrollTop }, this.scrollAnimationTime)
.easing(tween.Easing.Quadratic.Out)
.onUpdate((data: any) => {
this.renderer.setProperty(el, 'scrollTop', data.scrollTop);
this.refresh();
})
.start();
const animate = (time?: any) => {
this.currentTween.update(time);
if (this.currentTween._object.scrollTop !== scrollTop) {
this.zone.runOutsideAngular(() => {
requestAnimationFrame(animate);
});
}
};
animate();
}
private addParentEventHandlers(parentScroll: ElementRef | WindowRef) {
this.removeParentEventHandlers();
if (parentScroll) {
this.zone.runOutsideAngular(() => {
this.disposeScrollHandler =
this.renderer.listen(parentScroll, 'scroll', this.refreshHandler);
if (parentScroll instanceof WindowRef) {
this.disposeScrollHandler =
this.renderer.listen('window', 'resize', this.refreshHandler);
}
});
}
}
private removeParentEventHandlers() {
if (this.disposeScrollHandler) {
this.disposeScrollHandler();
this.disposeScrollHandler = undefined;
}
if (this.disposeResizeHandler) {
this.disposeResizeHandler();
this.disposeResizeHandler = undefined;
}
}
private countItemsPerRow() {
let offsetTop;
let itemsPerRow;
let children = this.contentElementRef.nativeElement.children;
for (itemsPerRow = 0; itemsPerRow < children.length; itemsPerRow++) {
if (offsetTop != undefined && offsetTop !== children[itemsPerRow].offsetTop) break;
offsetTop = children[itemsPerRow].offsetTop;
}
return itemsPerRow;
}
private getElementsOffset(): number {
let offsetTop = 0;
if (this.containerElementRef && this.containerElementRef.nativeElement) {
offsetTop += this.containerElementRef.nativeElement.offsetTop;
}
if (this.parentScroll) {
offsetTop += this.element.nativeElement.offsetTop;
}
return offsetTop;
}
private get _parentElement(): Element {
if (this.parentScroll instanceof WindowRef) {
return document.body;
}
if (this.parentScroll) {
return this.parentScroll.nativeElement;
}
return this.element.nativeElement;
}
private calculateDimensions() {
let el: Element = this._parentElement;
let items = this.items || [];
let itemCount = items.length;
let viewWidth = el.clientWidth - this.scrollbarWidth;
let viewHeight = el.clientHeight - this.scrollbarHeight;
let contentDimensions;
if (this.childWidth == undefined || this.childHeight == undefined) {
let content = this.contentElementRef.nativeElement;
if (this.containerElementRef && this.containerElementRef.nativeElement) {
content = this.containerElementRef.nativeElement;
}
contentDimensions = content.children[0] ? content.children[0].getBoundingClientRect() : {
width: viewWidth,
height: viewHeight
};
}
let childWidth = this.childWidth || contentDimensions.width;
let childHeight = this.childHeight || contentDimensions.height;
let itemsPerRow = Math.max(1, this.countItemsPerRow());
let itemsPerRowByCalc = Math.max(1, Math.floor(viewWidth / childWidth));
let itemsPerCol = Math.max(1, Math.floor(viewHeight / childHeight));
let elScrollTop = this.parentScroll instanceof WindowRef
? (window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0)
: el.scrollTop;
let scrollTop = Math.max(0, elScrollTop);
const scrollHeight = childHeight * itemCount / itemsPerRow;
if (itemsPerCol === 1 && Math.floor(scrollTop / scrollHeight * itemCount) + itemsPerRowByCalc >= itemCount) {
itemsPerRow = itemsPerRowByCalc;
}
if (scrollHeight !== this.lastScrollHeight) {
this.renderer.setStyle(this.shimElementRef.nativeElement, 'height', `${scrollHeight}px`);
this.lastScrollHeight = scrollHeight;
}
return {
itemCount: itemCount,
viewWidth: viewWidth,
viewHeight: viewHeight,
childWidth: childWidth,
childHeight: childHeight,
itemsPerRow: itemsPerRow,
itemsPerCol: itemsPerCol,
itemsPerRowByCalc: itemsPerRowByCalc,
scrollHeight,
};
}
private calculateItems() {
NgZone.assertNotInAngularZone();
let el: Element = this._parentElement;
let d = this.calculateDimensions();
let items = this.items || [];
let offsetTop = this.getElementsOffset();
let elScrollTop: number = (this.parentScroll instanceof WindowRef)
? (this.windowRef.nativeWindow.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0)
: el.scrollTop;
if (elScrollTop > d.scrollHeight) {
elScrollTop = d.scrollHeight + offsetTop;
}
let scrollTop = Math.max(0, elScrollTop - offsetTop);
let indexByScrollTop = scrollTop / d.scrollHeight * d.itemCount / d.itemsPerRow;
let end = Math.min(d.itemCount, Math.ceil(indexByScrollTop) * d.itemsPerRow + d.itemsPerRow * (d.itemsPerCol + 1));
let maxStartEnd = end;
const modEnd = end % d.itemsPerRow;
if (modEnd) {
maxStartEnd = end + d.itemsPerRow - modEnd;
}
let maxStart = Math.max(0, maxStartEnd - d.itemsPerCol * d.itemsPerRow - d.itemsPerRow);
let start = Math.min(maxStart, Math.floor(indexByScrollTop) * d.itemsPerRow);
const topPadding = (items == undefined || items.length === 0) ? 0 : (d.childHeight * Math.ceil(start / d.itemsPerRow) - (d.childHeight * Math.min(start, this.bufferAmount)));
if (topPadding !== this.lastTopPadding) {
this.renderer.setStyle(this.contentElementRef.nativeElement, 'transform', `translateY(${topPadding}px)`);
this.renderer.setStyle(this.contentElementRef.nativeElement, 'webkitTransform', `translateY(${topPadding}px)`);
this.lastTopPadding = topPadding;
}
start = !isNaN(start) ? start : -1;
end = !isNaN(end) ? end : -1;
start -= this.bufferAmount;
start = Math.max(0, start);
end += this.bufferAmount;
end = Math.min(items.length, end);
if (start !== this.previousStart || end !== this.previousEnd) {
this.zone.run(() => {
// update the scroll list
this.viewPortItems = items.slice(start, end);
this.update.emit(this.viewPortItems);
// emit 'start' event
if (start !== this.previousStart && this.startupLoop === false) {
this.start.emit({ start, end });
}
// emit 'end' event
if (end !== this.previousEnd && this.startupLoop === false) {
this.end.emit({ start, end });
}
this.previousStart = start;
this.previousEnd = end;
if (this.startupLoop === true) {
this.refresh();
} else {
this.change.emit({ start, end });
}
});
} else if (this.startupLoop === true) {
this.startupLoop = false;
this.refresh();
}
}
private refreshHandler = () => {
this.refresh();
}
}