Angular Material 6 中自动完成的无限滚动

4
我正在尝试在Angular Material 6中为自动完成实现无限滚动。我的情况很简单,我有一个启用了自动完成的输入框。当用户键入时,我将使用输入框中的文本进行HTTP调用以显示结果作为建议。但是我希望仅显示25个建议,如果结果计数超过25,当用户滚动到底部时我想再添加25个。 像这样在Angular 2中。
我没有在网上找到相关资料。 请给予建议或帮助。先感谢您。

<mat-form-field>
  <input matInput placeholder="Experiment Name" formControlName="experimentName" [matAutocomplete]="expNamesAutocomplete">
</mat-form-field>
<mat-autocomplete #expNamesAutocomplete="matAutocomplete">
  <mat-option *ngFor="let option of suggestedExpNames" [value]="option">
              {{ option }}
  </mat-option>
</mat-autocomplete>

1个回答

19

我知道这篇文章已经有些年头了,但是我还是会在这里留下解决方案,以防有任何人需要它。

关键是要获取mat-autocomplete面板滚动条的引用。我使用了自定义指令来实现这一点:

import { Directive, ElementRef, EventEmitter, Input, Output, Host, Self, Optional, AfterViewInit, OnDestroy } from '@angular/core';
import { MatAutocomplete } from '@angular/material';
import { Observable, fromEvent, of, Subject, merge, combineLatest } from 'rxjs';
import { map, startWith, switchMap, tap, debounceTime, filter, scan, withLatestFrom, mergeMap, takeUntil, takeWhile, distinctUntilChanged, skipUntil, exhaustMap, endWith } from 'rxjs/operators';
import { takeWhileInclusive } from 'rxjs-take-while-inclusive';

export interface IAutoCompleteScrollEvent {
  autoComplete: MatAutocomplete;
  scrollEvent: Event;
}

@Directive({
  selector: 'mat-autocomplete[optionsScroll]'
})
export class OptionsScrollDirective implements OnDestroy {

  @Input() thresholdPercent = .8;
  @Output('optionsScroll') scroll = new EventEmitter<IAutoCompleteScrollEvent>();
  _onDestroy = new Subject();

  constructor(public autoComplete: MatAutocomplete) {
    this.autoComplete.opened.pipe(
      tap(() => {
        // Note: When autocomplete raises opened, panel is not yet created (by Overlay)
        // Note: The panel will be available on next tick
        // Note: The panel wil NOT open if there are no options to display
        setTimeout(() => {
          // Note: remove listner just for safety, in case the close event is skipped.
          this.removeScrollEventListener();
          this.autoComplete.panel.nativeElement
            .addEventListener('scroll', this.onScroll.bind(this))
        });
      }),
      takeUntil(this._onDestroy)).subscribe();

    this.autoComplete.closed.pipe(
      tap(() => this.removeScrollEventListener()),
      takeUntil(this._onDestroy)).subscribe();
  }

  private removeScrollEventListener() {
    this.autoComplete.panel.nativeElement
      .removeEventListener('scroll', this.onScroll);
  }

  ngOnDestroy() {
    this._onDestroy.next();
    this._onDestroy.complete();

    this.removeScrollEventListener();
  }

  onScroll(event: Event) {

    if (this.thresholdPercent === undefined) {
      this.scroll.next({ autoComplete: this.autoComplete, scrollEvent: event });
    } else {
      const threshold = this.thresholdPercent * 100 * event.target.scrollHeight / 100;
      const current = event.target.scrollTop + event.target.clientHeight;

      //console.log(`scroll ${current}, threshold: ${threshold}`)
      if (current > threshold) {
        //console.log('load next page');
        this.scroll.next({ autoComplete: this.autoComplete, scrollEvent: event });
      }
    }
  }
}

在此之后,当滚动条达到80%的阈值时,需要从服务器加载更多数据:

import { Component, OnInit } from '@angular/core';
import { FormControl } from '@angular/forms';
import { Observable, fromEvent, of, Subject, merge, combineLatest } from 'rxjs';
import { map, startWith, switchMap, tap, debounceTime, filter, scan, withLatestFrom, mergeMap, takeUntil, takeWhile, distinctUntilChanged, skipUntil, exhaustMap, endWith } from 'rxjs/operators';
import { MatAutocomplete } from '@angular/material/autocomplete';
import { takeWhileInclusive } from 'rxjs-take-while-inclusive';

export interface ILookup {
  id: number,
  name: string
}
@Component({
  selector: 'autocomplete-filter-example',
  templateUrl: 'autocomplete-filter-example.html',
  styleUrls: ['autocomplete-filter-example.scss'],
})
export class AutocompleteFilterExample implements OnInit {

  searchText = new FormControl({ id: 2, name: 'ana' });
  filteredLookups$: Observable<ILookup[]>;
  private lookups: ILookup[] = [];
  private nextPage$ = new Subject();
  private _onDestroy = new Subject();

  // Fake backend api
  private getProducts(startsWith: string, page: number): Observable<ILookup[]> {
    console.log(`api call filter: ${startsWith}`);

    const take = 10;
    const skip = page > 0 ? (page - 1) * take : 0;

    const filtered = this.lookups
      .filter(option => option.name.toLowerCase().startsWith(startsWith.toLowerCase()))

    console.log(`skip: ${skip}, take: ${take}`);

    return of(filtered.slice(skip, skip + take));
  }

  ngOnInit() {

    // Note: Generate some mock data
    this.lookups = [{ id: 1994, name: 'ana' }, { id: 1989, name: 'narcis' }]
    for (let i = 1; i < 100; i++) {
      this.lookups.push({ id: i, name: 'test' + i })
    }

    // Note: listen for search text changes
    const filter$ = this.searchText.valueChanges.pipe(
      startWith(''),
      debounceTime(200),
      // Note: If the option valye is bound to object, after selecting the option
      // Note: the value will change from string to {}. We want to perform search 
      // Note: only when the type is string (no match)
      filter(q => typeof q === "string"));

    // Note: There are 2 stream here: the search text changes stream and the nextPage$ (raised by directive at 80% scroll)
    // Note: On every search text change, we issue a backend request starting the first page
    // Note: While the backend is processing our request we ignore any other NextPage emitts (exhaustMap).
    // Note: If in this time the search text changes, we don't need those results anymore (switchMap)
    this.filteredLookups$ = filter$.pipe(
      switchMap(filter => {
        //Note: Reset the page with every new seach text
        let currentPage = 1;
        return this.nextPage$.pipe(
          startWith(currentPage),
          //Note: Until the backend responds, ignore NextPage requests.
          exhaustMap(_ => this.getProducts(filter, currentPage)),
          tap(() => currentPage++),
          //Note: This is a custom operator because we also need the last emitted value.
          //Note: Stop if there are no more pages, or no results at all for the current search text.
          takeWhileInclusive(p => p.length > 0),
          scan((allProducts, newProducts) => allProducts.concat(newProducts), []),
        );
      })); // Note: We let asyncPipe subscribe.

  }

  displayWith(lookup) {
    return lookup ? lookup.name : null;
  }

  onScroll() {
    //Note: This is called multiple times after the scroll has reached the 80% threshold position.
    this.nextPage$.next();
  }

  ngOnDestroy() {
    this._onDestroy.next();
    this._onDestroy.complete();
  }
}

注意:我正在使用自定义的rxjs操作符rxjs-take-while-inclusive。
你可以在这里看到它的实际应用:演示

2
非常感谢您的回复,我已经实现了这种方式。 - Krishna
如果有时间,你能帮我将Angular Material 2的虚拟滚动集成到这篇文章中吗?文章链接为https://netbasal.com/a-taste-of-angular-material-virtual-scroll-f173c5c70a1,示例链接为https://stackblitz.com/edit/angular-9ocpnq?file=app%2Fautocomplete-overview-example.ts。谢谢! - Krishna
我认为通过扩展mat-autocomplete无法实现。我们只是在这里进行了惰性加载,为了实现虚拟滚动(容器重用、墓碑等),您需要使用不同类型的面板,该面板使用cdk虚拟滚动功能。 - Narcis
@Zapacila 但是MAT_AUTOCOMPLETE_SCROLL_STRATEGY怎么使用呢? - Janne Harju
rxjs-take-while-inclusive不再需要,因为原生的“takeWhile”操作符现在可以使用额外的布尔值来切换包含变体。 - Dominik Brázdil

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接