File

projects/angular/directives/ui-virtual-scroll-range-loader/src/ui-virtual-scroll-range-loader.directive.ts

Description

A directive that is designed to work alongside CdkVirtualScrollViewport which can be used to lazy load in chunks depending on what is in view

Implements

OnInit OnDestroy

Metadata

Index

Inputs
Outputs

Inputs

buffer
Type : number
Default value : 10

Used to extend the ListRange interval emmited by rangeLoad Output this will expand at both ends with the specified number, taking into account the status of the loading items available

isDown
Type : boolean
Default value : true

Flag used to indicate the direction of items set to false if virtual scroll events indexes need to be reversed

Outputs

rangeLoad
Type : EventEmitter

Output of ListRange events based on renderedRangeStream from CdkVirtualScrollViewport which takes into account direction of list (isDown), buffer and reduces interval to untouched indexes (items with loading: "initial")

import { Subject } from 'rxjs';
import {
    debounceTime,
    distinctUntilChanged,
    filter,
    map,
    startWith,
    takeUntil,
    tap,
    withLatestFrom,
} from 'rxjs/operators';

import { ListRange } from '@angular/cdk/collections';
import {
    CdkVirtualForOf,
    CdkVirtualScrollViewport,
} from '@angular/cdk/scrolling';
import {
    ContentChild,
    Directive,
    EventEmitter,
    Inject,
    Input,
    OnDestroy,
    OnInit,
    Output,
    Self,
} from '@angular/core';

/**
 * Used for marking the loading state of items
 * within a lazily loaded collection
 *
 * @export
 */
export enum VirtualScrollItemStatus {
    /**
     * Initial status of an item within the collection,
     * a placeholder with no content
     */
    initial = 'initial',
    /**
     * Item marked as pending to be loaded, a request was sent out,
     * but still no content, item data was not yet received
     */
    pending = 'pending',
    /**
     * Item with content, data of item has been loaded
     */
    loaded = 'loaded',
}

/**
 * Item with loading state to be used
 * if lazily loading results to reduce events emitted
 * and intervals emitted within events on `rangeLoad` Output
 *
 * @export
 */
export interface VirtualScrollItem {
    loading?: VirtualScrollItemStatus;
}

/**
 * A directive that is designed to work alongside CdkVirtualScrollViewport
 * which can be used to lazy load in chunks depending on what is in view
 *
 * @export
 */
@Directive({
    selector: '[uiVirtualScrollRangeLoader], ui-virtual-scroll-range-loader',
})
export class UiVirtualScrollRangeLoaderDirective implements OnInit, OnDestroy {

    /**
     * Used to extend the ListRange interval emmited by rangeLoad Output
     * this will expand at both ends with the specified number,
     * taking into account the status of the loading items
     * available
     *
     */
    @Input()
    buffer = 10;

    /**
     * Flag used to indicate the direction of items
     * set to `false` if virtual scroll events indexes need to be reversed
     */
    @Input()
    isDown = true;

    /**
     * Output of `ListRange` events based on renderedRangeStream from
     * CdkVirtualScrollViewport which takes into account
     * direction of list (`isDown`), `buffer`
     * and reduces interval to untouched indexes (items with `loading: "initial"`)
     *
     */
    @Output()
    rangeLoad = new EventEmitter<ListRange>();

    @ContentChild(CdkVirtualForOf, {
        static: true,
    })
    private readonly _cdkVirtualForOf!: CdkVirtualForOf<VirtualScrollItem>;

    private readonly _destroyed$ = new Subject<void>();

    /**
     * @ignore
     */
    constructor(
        @Self()
        @Inject(CdkVirtualScrollViewport)
        private readonly _viewport: CdkVirtualScrollViewport,
    ) { }

    /**
     * @ignore
     */
    ngOnInit() {
        this._viewport.renderedRangeStream
            .pipe(
                withLatestFrom(
                    this._cdkVirtualForOf.dataStream.pipe(
                        startWith([] as VirtualScrollItem[]),
                    ),
                ),
                distinctUntilChanged(([list1], [list2]) => `${list1.start}${list1.end}` === `${list2.start}${list2.end}`),
                debounceTime(100),
                // filter early, in case of false emissions like { 0,0 }
                filter(([{ start, end }]) => this._isValidRange({
                    start,
                    end,
                })),
                filter(this._filterTouchedRange),
                map(([{ start, end }, items]) => ({
                    ...this._adjustLoadingRange(start, end, this.buffer, items),
                    items,
                })),
                // eslint-disable-next-line sonarjs/no-identical-functions
                filter(({ start, end }) => this._isValidRange({
                    start,
                    end,
                })),
                map(({ start, end, items }) =>
                    this.isDown ?
                        {
                            start,
                            end,
                        } :
                        this._reverseIndex({
                            start,
                            end,
                        }, items.length),
                ),
                filter(this._isValidRange),
                tap(range => this.rangeLoad.emit(range)),
            )
            .pipe(takeUntil(this._destroyed$))
            .subscribe();
    }

    /**
     * @ignore
     */
    ngOnDestroy() {
        this._destroyed$.next();
        this._destroyed$.complete();
    }

    private _filterTouchedRange = (
        [{ start, end }, items]: [ListRange, VirtualScrollItem[] | readonly VirtualScrollItem[]],
    ) =>
        items
            .slice(start, end)
            .some(({ loading }) => loading === VirtualScrollItemStatus.initial);

    private _isValidRange = ({ start, end }: ListRange) => end >= 0 && start >= 0 && end - start >= 0;
    private _reverseIndex({ start, end }: ListRange, count: number) {
        return {
            start: count - 1 - end,
            end: count - 1 - start,
        };
    }

    private _adjustLoadingRange(
        start: number,
        end: number,
        buffer: number,
        items: VirtualScrollItem[] | readonly VirtualScrollItem[]): ListRange {
        (
            { start, end } = this._addSafeBuffer(start, end, buffer, items)
        );

        let isTrimmedBefore;
        let isTrimmedAfter;

        (
            { start, end, isTrimmedBefore, isTrimmedAfter } = this._trimInterval(start, end, items)
        );

        if (start > end) {
            // kill the request
            return {
                start,
                end: -1,
            };
        }

        const isNotTrimmedAtBothEnds = !isTrimmedBefore || !isTrimmedAfter;
        if (
            end - start < buffer &&
            isNotTrimmedAtBothEnds
        ) {
            if (!isTrimmedBefore) {
                start -= buffer;
            }
            if (!isTrimmedAfter) {
                end += buffer;
            }

            (
                { start, end } = this._trimInterval(
                    Math.max(0, start),
                    Math.min(items.length - 1, end),
                    items,
                )
            );
        }

        if (start > end) {
            // kill the request
            end = -1;
        }

        return {
            start,
            end,
        };
    }

    private _addSafeBuffer(start: number, end: number, buffer: number, items: VirtualScrollItem[] | readonly VirtualScrollItem[]) {
        end = Math.min(end + buffer, items.length - 1);
        start = Math.max(start - buffer, 0);
        return {
            start,
            end,
        };
    }

    private _trimInterval(start: number, end: number, items: VirtualScrollItem[] | readonly VirtualScrollItem[]) {
        let isTrimmedBefore = false;
        let isTrimmedAfter = false;
        while (start <= end &&
            items[start].loading !== VirtualScrollItemStatus.initial) {
            isTrimmedBefore = true;
            start += 1;
        }
        while (end >= start &&
            items[end].loading !== VirtualScrollItemStatus.initial) {
            isTrimmedAfter = true;
            end -= 1;
        }
        return {
            start,
            end,
            isTrimmedBefore,
            isTrimmedAfter,
        };
    }
}

results matching ""

    No results matching ""