Ad

Take N Values From Observable Until Its Complete Based On An Event. Lazy Loading A Multi Select List

I'm new to rxjs, and I'm developing an angular multiselect list component that should render a long list of values (500+). I'm rendering the list based on an UL, I'm iterating over an observable that will render the LI's. I'm thinking about my options to avoid impacting the performance by rendering all the elements at once. But I don't know whether this is possible or not, and if it's possible what is the best operator to use.

The proposed solution:

  • On init I load all the data into an Observable. (src) and I'll take 100 elements from it and will put them on the target observable (The one that will be used to render the list)
  • Everytime that the user reaches the end of the list (the scrollEnd event fires) I'll load 100 elements more, until there are no more values in the src observable.

  • The emission of new values in the target observable will be triggered by the scrollEnd event.

Find my code below, I still need to implement the proposed solution, but I'm stuck at this point.

EDIT: I'm implementing @martin solution, but I'm still not able to make it work in my code. My first step was to replicate it in the code, to get the logged values, but the observable is completing immediately without producing any values. Instead of triggering an event, I've added a subject. Everytime the scrollindEnd output emits, I will push a new value to the subject. The template has been modified to support this.

multiselect.component.ts

import { Component, AfterViewInit } from '@angular/core';
import { zip, Observable, fromEvent, range } from 'rxjs';
import { map, bufferCount, startWith, scan } from 'rxjs/operators';
import { MultiSelectService, ProductCategory } from './multiselect.service';

@Component({
  selector: 'multiselect',
  templateUrl: './multiselect.component.html',
  styleUrls: ['./multiselect.component.scss']
})
export class MultiselectComponent implements AfterViewInit {

  SLICE_SIZE = 100;
  loadMore$: Observable<Event>;
  numbers$ = range(450);

  constructor() {}


  ngAfterViewInit() {
    this.loadMore$ = fromEvent(document.getElementsByTagName('button')[0], 'click');

    zip(
      this.numbers$.pipe(bufferCount(this.SLICE_SIZE)),
      this.loadMore$.pipe(),
    ).pipe(
      map(results => console.log(results)),
    ).subscribe({
      next: v => console.log(v),
      complete: () => console.log('complete ...'),
    });
  }

}

multiselect.component.html

<form action="#" class="multiselect-form">
  <h3>Categories</h3>
  <input type="text" placeholder="Search..." class="multiselect-form--search" tabindex="0"/>
  <multiselect-list [categories]="categories$ | async" (scrollingFinished)="lazySubject.next($event)">
  </multiselect-list>
  <button class="btn-primary--large">Proceed</button>
</form>

multiselect-list.component.ts

import { Component, Input, Output, EventEmitter } from '@angular/core';

@Component({
  selector: 'multiselect-list',
  templateUrl: './multiselect-list.component.html'
})
export class MultiselectListComponent {
  @Output() scrollingFinished = new EventEmitter<any>();
  @Input() categories: Array<string> = [];

  constructor() {}

  onScrollingFinished() {
    this.scrollingFinished.emit(null);
  }
}

multiselect-list.component.html

<ul class="multiselect-list" (scrollingFinished)="onScrollingFinished($event)">
  <li *ngFor="let category of categories; let idx=index" scrollTracker class="multiselect-list--option">
    <input type="checkbox" id="{{ category }}" tabindex="{{ idx + 1 }}"/>
    <label for="{{ category }}">{{ category }}</label>
  </li>
</ul>

NOTE: The scrollingFinished event is being triggered by the scrollTracker directive that holds the tracking logic. I'm bubbling the event from multiselect-list to the multiselect component.

Thanks in advance!

Ad

Answer

This example generates an array of 450 items and then splits them into chunks of 100. It first dumps the first 100 items and after every button click it takes another 100 and appends it to the previous results. This chain properly completes after loading all data.

I think you should be able to take this and use to for your problem. Just instead of button clicks use a Subject that will emit every time user scrolls to the bottom:

import { fromEvent, range, zip } from 'rxjs'; 
import { map, bufferCount, startWith, scan } from 'rxjs/operators';

const SLICE_SIZE = 100;

const loadMore$ = fromEvent(document.getElementsByTagName('button')[0], 'click');
const data$ = range(450);

zip(
  data$.pipe(bufferCount(SLICE_SIZE)),
  loadMore$.pipe(startWith(0)),
).pipe(
  map(results => results[0]),
  scan((acc, chunk) => [...acc, ...chunk], []),
).subscribe({
  next: v => console.log(v),
  complete: () => console.log('complete'),
});

Live demo: https://stackblitz.com/edit/rxjs-au9pt7?file=index.ts

If you're concerned about performance you should use trackBy for *ngFor to avoid re-rendering existing DOM elements but I guess you already know that.

Ad
source: stackoverflow.com
Ad