fix: performance degradation when scrolling

This commit is contained in:
Rainer Killinger
2021-05-04 11:33:48 +02:00
parent add690c842
commit f0a45d1b8e
15 changed files with 607 additions and 392 deletions

756
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,7 @@
"tslint": "tslint -p tsconfig.json -c tslint.json 'src/**/*.ts'" "tslint": "tslint -p tsconfig.json -c tslint.json 'src/**/*.ts'"
}, },
"dependencies": { "dependencies": {
"@angular/cdk": "12.0.0",
"@angular/common": "9.1.12", "@angular/common": "9.1.12",
"@angular/core": "9.1.12", "@angular/core": "9.1.12",
"@angular/forms": "9.1.12", "@angular/forms": "9.1.12",

View File

@@ -12,6 +12,7 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {ScrollingModule} from '@angular/cdk/scrolling';
import {CommonModule} from '@angular/common'; import {CommonModule} from '@angular/common';
import {HttpClientModule} from '@angular/common/http'; import {HttpClientModule} from '@angular/common/http';
import {NgModule} from '@angular/core'; import {NgModule} from '@angular/core';
@@ -135,6 +136,7 @@ import {VideoListItem} from './types/video/video-list-item.component';
'm': 59, 'm': 59,
}, },
}), }),
ScrollingModule,
StorageModule, StorageModule,
TranslateModule.forChild(), TranslateModule.forChild(),
ThingTranslateModule.forChild(), ThingTranslateModule.forChild(),

View File

@@ -20,6 +20,7 @@ import {Component} from '@angular/core';
@Component({ @Component({
selector: 'stapps-skeleton-list-item', selector: 'stapps-skeleton-list-item',
templateUrl: 'skeleton-list-item.html', templateUrl: 'skeleton-list-item.html',
styleUrls: ['skeleton-list-item.scss'],
}) })
export class SkeletonListItem { export class SkeletonListItem {
} }

View File

@@ -1,7 +1,7 @@
<ion-item> <ion-item>
<ion-thumbnail slot="start"> <ion-thumbnail slot="start" class="ion-margin-end">
<ion-skeleton-text animated></ion-skeleton-text> <ion-skeleton-text animated></ion-skeleton-text>
</ion-thumbnail> </ion-thumbnail>
<ion-grid> <ion-grid>
<ion-row> <ion-row>
<ion-col> <ion-col>

View File

@@ -0,0 +1,7 @@
::ng-deep {
ion-grid,ion-col {
padding-inline-start: 0px!important;
padding-top: 0px!important;
padding-bottom: 0px!important;
}
}

View File

@@ -25,12 +25,17 @@ import {DataRoutingService} from '../data-routing.service';
templateUrl: 'data-list-item.html', templateUrl: 'data-list-item.html',
}) })
export class DataListItem { export class DataListItem {
/**
* Whether or not the list item should show a thumbnail
*/
@Input() hideThumbnail = false;
/** /**
* An item to show * An item to show
*/ */
@Input() item: SCThings; @Input() item: SCThings;
constructor(private dataRoutingService: DataRoutingService) {} constructor(private readonly dataRoutingService: DataRoutingService) {}
/** /**
* Emit event that an item was selected * Emit event that an item was selected

View File

@@ -1,37 +1,30 @@
<ion-item class="ion-text-wrap" button="true" lines="inset" (click)="notifySelect()"> <ion-item button="true" lines="inset" (click)="notifySelect()">
<ion-thumbnail slot="start"> <ion-thumbnail slot="start" *ngIf="!hideThumbnail" class="ion-margin-end">
<ion-icon color="medium" [attr.name]="item.type | dataIcon"></ion-icon> <ion-icon color="medium" [attr.name]="item.type | dataIcon"></ion-icon>
</ion-thumbnail> </ion-thumbnail>
<ion-label [ngSwitch]="true"> <ion-label class="ion-text-wrap" [ngSwitch]="true">
<div> <stapps-catalog-list-item [item]="item" *ngSwitchCase="item.type === 'catalog'"></stapps-catalog-list-item>
<stapps-catalog-list-item [item]="item" *ngSwitchCase="item.type === 'catalog'"></stapps-catalog-list-item> <stapps-date-series-list-item [item]="item" *ngSwitchCase="item.type === 'date series'"></stapps-date-series-list-item>
<stapps-date-series-list-item [item]="item" *ngSwitchCase="item.type === 'date series'"></stapps-date-series-list-item> <stapps-dish-list-item [item]="item" *ngSwitchCase="item.type === 'dish'"></stapps-dish-list-item>
<stapps-dish-list-item [item]="item" *ngSwitchCase="item.type === 'dish'"></stapps-dish-list-item> <stapps-event-list-item [item]="item" *ngSwitchCase="item.type === 'academic event'"></stapps-event-list-item>
<stapps-event-list-item [item]="item" *ngSwitchCase="item.type === 'academic event'"></stapps-event-list-item> <stapps-event-list-item [item]="item" *ngSwitchCase="item.type === 'sport course'"></stapps-event-list-item>
<stapps-event-list-item [item]="item" *ngSwitchCase="item.type === 'sport course'"></stapps-event-list-item> <stapps-favorite-list-item [item]="item" *ngSwitchCase="item.type === 'favorite'"></stapps-favorite-list-item>
<stapps-favorite-list-item [item]="item" *ngSwitchCase="item.type === 'favorite'"></stapps-favorite-list-item> <stapps-message-list-item [item]="item" *ngSwitchCase="item.type === 'message'"></stapps-message-list-item>
<stapps-message-list-item [item]="item" *ngSwitchCase="item.type === 'message'"></stapps-message-list-item> <stapps-organization-list-item [item]="item" *ngSwitchCase="item.type === 'organization'"></stapps-organization-list-item>
<stapps-organization-list-item [item]="item" *ngSwitchCase="item.type === 'organization'"></stapps-organization-list-item> <stapps-person-list-item [item]="item" *ngSwitchCase="item.type === 'person'"></stapps-person-list-item>
<stapps-person-list-item [item]="item" *ngSwitchCase="item.type === 'person'"></stapps-person-list-item> <stapps-place-list-item [item]="item" *ngSwitchCase="item.type === 'building'"></stapps-place-list-item>
<stapps-place-list-item [item]="item" *ngSwitchCase="item.type === 'building'"></stapps-place-list-item> <stapps-place-list-item [item]="item" *ngSwitchCase="item.type === 'floor'"></stapps-place-list-item>
<stapps-place-list-item [item]="item" *ngSwitchCase="item.type === 'floor'"></stapps-place-list-item> <stapps-place-list-item [item]="item" *ngSwitchCase="item.type === 'point of interest'"></stapps-place-list-item>
<stapps-place-list-item [item]="item" *ngSwitchCase="item.type === 'point of interest'"></stapps-place-list-item> <stapps-place-list-item [item]="item" *ngSwitchCase="item.type === 'room'"></stapps-place-list-item>
<stapps-place-list-item [item]="item" *ngSwitchCase="item.type === 'room'"></stapps-place-list-item> <stapps-semester-list-item [item]="item" *ngSwitchCase="item.type === 'semester'"></stapps-semester-list-item>
<stapps-semester-list-item [item]="item" *ngSwitchCase="item.type === 'semester'"></stapps-semester-list-item> <stapps-video-list-item [item]="item" *ngSwitchCase="item.type === 'video'"></stapps-video-list-item>
<stapps-video-list-item [item]="item" *ngSwitchCase="item.type === 'video'"></stapps-video-list-item> <div *ngSwitchDefault>
<ion-grid *ngSwitchDefault> <h2>
<ion-row> {{'name' | thingTranslate: item}}
<ion-col> </h2>
<div class="ion-text-wrap"> <p *ngIf="item.description">
<h2 class="name">{{'name' | thingTranslate: item}}</h2> <stapps-long-inline-text [text]="'description' | thingTranslate: item" [size]="80"></stapps-long-inline-text>
<p *ngIf="item.description"> </p>
<stapps-long-inline-text [text]="'description' | thingTranslate: item" [size]="80"></stapps-long-inline-text>
</p>
<ion-note>{{'type' | thingTranslate: item}}</ion-note>
</div>
</ion-col>
</ion-row>
</ion-grid>
</div> </div>
</ion-label> </ion-label>
</ion-item> </ion-item>

View File

@@ -0,0 +1,8 @@
::ng-deep {
ion-grid,ion-col {
padding-inline-start: 0px!important;
padding-top: 0px!important;
padding-bottom: 0px!important;
}
}

View File

@@ -1,6 +1,7 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {DataListComponent} from './data-list.component'; import {DataListComponent} from './data-list.component';
import {TranslateModule} from '@ngx-translate/core';
describe('DataListComponent', () => { describe('DataListComponent', () => {
let component: DataListComponent; let component: DataListComponent;
@@ -8,7 +9,10 @@ describe('DataListComponent', () => {
beforeEach(async(() => { beforeEach(async(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ DataListComponent ] declarations: [ DataListComponent ],
imports: [
TranslateModule.forRoot(),
]
}) })
.compileComponents(); .compileComponents();
})); }));

View File

@@ -12,8 +12,11 @@
* You should have received a copy of the GNU General Public License along with * You should have received a copy of the GNU General Public License along with
* this program. If not, see <https://www.gnu.org/licenses/>. * this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import {Component, EventEmitter, Input, OnInit, Output} from '@angular/core'; import {CdkVirtualScrollViewport} from '@angular/cdk/scrolling';
import {Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges, ViewChild} from '@angular/core';
import {SCThings} from '@openstapps/core'; import {SCThings} from '@openstapps/core';
import {BehaviorSubject, Observable, Subscription} from 'rxjs';
/** /**
* Shows the list of items * Shows the list of items
*/ */
@@ -22,16 +25,84 @@ import {SCThings} from '@openstapps/core';
templateUrl: 'data-list.html', templateUrl: 'data-list.html',
styleUrls: ['data-list.scss'], styleUrls: ['data-list.scss'],
}) })
export class DataListComponent implements OnInit {
// tslint:disable-next-line:completed-docs
@Input() items: SCThings[];
// tslint:disable-next-line:completed-docs
@Output('loadmore') loadMore = new EventEmitter<Event>();
ngOnInit(): void { export class DataListComponent implements OnChanges, OnInit {
/**
* Amount of list items left to show (in percent) that should trigger a data reload
*/
private readonly reloadThreshold = 0.2;
/**
* All SCThings to display
*/
@Input() items?: SCThings[];
/**
* Stream of SCThings for virtual scroll to consume
*/
itemStream = new BehaviorSubject<SCThings[]>([]);
/**
* Output binding to trigger pagination fetch
*/
@Output('loadmore') loadMore = new EventEmitter<DataSourceRefreshed>();
/**
* Indicates whether or not the list is to display SCThings of a single type
*/
@Input() singleType = false;
// tslint:disable-next-line: completed-docs
@ViewChild(CdkVirtualScrollViewport) viewPort: CdkVirtualScrollViewport;
/**
* Uniquely identifies item at a certain list index
*/
identifyItem(_index: number, item: SCThings) {
return item.uid;
} }
notifyLoadMore(e: Event) { // tslint:disable-next-line: completed-docs
this.loadMore.emit(e); ngOnChanges(_changes: SimpleChanges): void {
if (Array.isArray(this.items)) {
if (this.itemStream.getValue().length === 0) {
this.itemStream = new BehaviorSubject<SCThings[]>(this.items);
} else {
if ((this.items[0].uid ?? '') !== this.itemStream.getValue()[0].uid) {
this.itemStream = new BehaviorSubject<SCThings[]>(this.items);
}
}
}
}
// tslint:disable-next-line: completed-docs
ngOnInit(): void {
if (typeof this.queryChanged !== 'undefined') {
this.subscriptions.push(this.queryChanged.subscribe(() => {
this.viewPort.scrollToIndex(0);
}));
}
}
// tslint:disable-next-line: completed-docs
ngOnDestroy(): void {
for (const subscription of this.subscriptions) {
subscription.unsubscribe();
}
}
/**
* Component proxy for dataSource.finishedLoadMore
*/
notifyLoadMore() {
this.loadMore.emit((items) => {
this.itemStream.next(items);
});
}
/**
* Function to call whenever scroll view visible range changed
*/
scrolled(_event: Event) {
if ((this.items?.length ?? 0) - this.viewPort.getRenderedRange().end <= (this.items?.length ?? 0) * this.reloadThreshold) {
this.notifyLoadMore();
}
} }
} }
export type DataSourceRefreshed = (items: SCThings[]) => unknown;

View File

@@ -1,20 +1,15 @@
<ion-content> <cdk-virtual-scroll-viewport itemSize="80" minBufferPx="1500" maxBufferPx="2000" (scrolledIndexChange)="scrolled($event)"
<div *ngIf="items; else loading"> [style.display]="items && items.length ? 'block': 'none'">
<ion-list *ngIf='items.length > 0; else empty'> <ion-list>
<stapps-data-list-item [item]="item" *ngFor="let item of items"></stapps-data-list-item> <stapps-data-list-item *cdkVirtualFor="let item of itemStream;trackBy: identifyItem" [item]="item"
</ion-list> [hideThumbnail]="singleType"></stapps-data-list-item>
<ng-template #empty> </ion-list>
<div> </cdk-virtual-scroll-viewport>
<ion-label class='notFoundContainer'> <div [style.display]="items && items.length === 0 ? 'block': 'none'">
{{'search.nothing_found' | translate}} <ion-label class='notFoundContainer'>
</ion-label> {{'search.nothing_found' | translate | titlecase}}
</div> </ion-label>
</ng-template> </div>
</div> <ion-list [style.display]="items ? 'none': 'block'">
<ng-template #loading> <stapps-skeleton-list-item *ngFor="let skeleton of [1, 2, 3, 4, 5]"></stapps-skeleton-list-item>
<stapps-skeleton-list-item *ngFor="let skeleton of [1, 2, 3, 4, 5]"></stapps-skeleton-list-item> </ion-list>
</ng-template>
<ion-infinite-scroll (ionInfinite)="notifyLoadMore($event)">
<ion-infinite-scroll-content></ion-infinite-scroll-content>
</ion-infinite-scroll>
</ion-content>

View File

@@ -0,0 +1,10 @@
cdk-virtual-scroll-viewport {
min-height: 100%;
width: 100%;
}
::ng-deep {
.cdk-virtual-scroll-content-wrapper {
width: 100%;
}
}

View File

@@ -29,6 +29,7 @@ import {ContextMenuService} from '../../menu/context/context-menu.service';
import {SettingsProvider} from '../../settings/settings.provider'; import {SettingsProvider} from '../../settings/settings.provider';
import {DataRoutingService} from '../data-routing.service'; import {DataRoutingService} from '../data-routing.service';
import {DataProvider} from '../data.provider'; import {DataProvider} from '../data.provider';
import {DataSourceRefreshed} from './data-list.component';
/** /**
* SearchPageComponent queries things and shows list of things as search results and filter as context menu * SearchPageComponent queries things and shows list of things as search results and filter as context menu
@@ -71,6 +72,10 @@ export class SearchPageComponent implements OnInit {
* Time to wait for search query if search text is changing * Time to wait for search query if search text is changing
*/ */
searchQueryDueTime = 1000; searchQueryDueTime = 1000;
/**
* Search response only ever contains a single SCThingType
*/
singleTypeResponse = false;
/** /**
* Api query sorting * Api query sorting
*/ */
@@ -183,6 +188,7 @@ export class SearchPageComponent implements OnInit {
return this.dataProvider.search(searchOptions) return this.dataProvider.search(searchOptions)
.then(async (result) => { .then(async (result) => {
this.singleTypeResponse = result.facets.find(facet => facet.field === 'type')?.buckets.length === 1;
if (append) { if (append) {
let items = await this.items; let items = await this.items;
// append results // append results
@@ -219,10 +225,10 @@ export class SearchPageComponent implements OnInit {
* Loads next page of things * Loads next page of things
*/ */
// tslint:disable-next-line:no-any // tslint:disable-next-line:no-any
async loadMore(event: any): Promise<void> { async loadMore(finished: DataSourceRefreshed): Promise<void> {
this.from += this.pageSize; this.from += this.pageSize;
await this.fetchAndUpdateItems(true); await this.fetchAndUpdateItems(true);
event.target.complete(); finished(await this.items);
} }
/** /**

View File

@@ -1,4 +1,4 @@
<stapps-context contentId="data-list"></stapps-context> <stapps-context></stapps-context>
<ion-header> <ion-header>
<ion-toolbar> <ion-toolbar>
<ion-buttons slot="start"> <ion-buttons slot="start">
@@ -15,5 +15,5 @@
</ion-header> </ion-header>
<ion-content> <ion-content>
<stapps-data-list id="data-list" [items]="items | async" (loadmore)="loadMore($event)"></stapps-data-list> <stapps-data-list id="data-list" [items]="items | async" [singleType]="singleTypeResponse" (loadmore)="loadMore($event)"></stapps-data-list>
</ion-content> </ion-content>