Предыдущая статья https://habr.com/ru/articles/923120/ была посвящена инструменту ng-virtual-list. С тех пор инструмент обрел богатый функционал, внесен ряд существенных улучшений в алгоритмы виртуализации и трекинга, улучшена стабильность и производительность. Также был реализован порт на React. Кому интересны тесты, бенчмарки и цифры, чем отличается данный инструмент от аналогов и коробочных решений для Angular CDKVirtualFor, смотрите комменты к предыдущей статье.

Хочется отметить, что ng-virtual-list не просто виртуализированный список, он опционально может работать как виртуализированный select и multi-select; умеет работать с группированными списками и в дальнейшем будет добавлена возможность collapsableGroups и работа в многопоточном режиме.

Проектирование

Теперь настало время опробовать всю силу виртуализации списков на практике.

Определимся с условиями для проектирования вьюпорта:

  • Список сообщений. Сообщения будут автоматически создаваться в начале и конце списка; реализовать возможность редактирования и удаления сообщений.

  • Поиск сообщения по подстроке.

  • Боковой список поставщиков сообщений, который будет отображаться в левом доке. Док открывается по клику на соответствующую кнопку. При выборе поставщика, отображается сгенерированный список сообщений и док закрывается.

  • Заголовок вьюпорта в котором будет отображаться название поставщика сообщений и элементы управления поиском сообщений и кнопка открытия дока поставщиков.

Макет будущего вьюпорта для мессенджера
Макет будущего вьюпорта для мессенджера

Будем использовать Angular 19.x и ng-virtual-list@19

Реализация

В данной статье не буду приводить весь код проекта, т. к. Вы сможете его найти ниже по ссылке. Сконцентрирую лишь на основных моментах.

Детальное описание шаблона:

Скрытый текст
<div class="container">
    <!-- Панель инструментов -->
    <div class="toolbar">
      <div>
        <!-- Кнопка открывающая док с поставщиками сообщений -->
        <app-menu-button (click)="onOpenMenuHandler()" [opened]="menuOpened()" />
      </div>
      <!-- Заголовок поставщика сообщений -->
      <div class="title">{{title()}}</div>
      <!-- Элемент управления для поиска сообщений по подстроке -->
      <app-search (search)="onSearchHandler($event)" />
    </div>

    <div class="list-container">
      @let doc = dockMode();
      <app-drawer [dock]="doc" [dockLeftSize]="240">
        <!-- Док поставщика сообщений -->
        <dock-left>
          <div class="list-rooms__container">
            <!-- Виртуальный список поставщиков сообщений -->
            <ng-virtual-list class="list rooms" [items]="items" [itemRenderer]="itemRenderer" [trackBy]="'id'"
              [itemSize]="40" [dynamicSize]="true" [bufferSize]="60"
              (onItemClick)="onRoomClickHandler($event)"></ng-virtual-list>
          </div>
        </dock-left>
        <!-- Вьюпорт сообщений -->
        <div class="list-wrapper">
          <!-- Виртуальный список сообщений -->
          <ng-virtual-list #dynamicList class="list" [items]="groupDynamicItems" [itemRenderer]="groupItemRenderer"
            [trackBy]="'id'" [itemSize]="40" [bufferSize]="30" [bufferSize]="120"
            [itemConfigMap]="groupDynamicItemsConfigMap" [dynamicSize]="true" [snap]="true"
            (onScroll)="onScrollHandler($event)" snappingMethod="advanced" methodForSelecting="multi-select"
            (onScrollEnd)="onScrollEndHandler($event)" [enabledBufferOptimization]="false"
            (onItemClick)="onClickHandler($event)"></ng-virtual-list>
        </div>
      </app-drawer>
    </div>
  </div>

<!-- Шаблон сообщения -->
  <ng-template #groupItemRenderer let-data="data" let-measures="measures" let-config="config">
    @if (data) {
      @switch (data.type) {
        <!-- Индикатор формирования сообщения -->
        @case ("write-indicator") {
          <div class="list__windicator-container">
            <!-- Тут располагается пиктограмма для индикатора формирования сообщения -->
          </div>
        }
        <!-- Заголовок группы сообщений -->
        @case ("group-header") {
          <div class="list__group-container" [ngClass]="{'snapped': config.snapped, 'snapped-out': config.snappedOut}">
            <span>{{data.name}}</span>
          </div>
        }
        <!-- Cобщение -->
        @default {
          @let isIn = data.incomType === 'in';
          @let isOut = data.incomType === 'out';
          @let class = {'in': isIn, 'out': isOut, 'edited': data.edited, 'selected': config.selected, focused: config.focus};
          <div class="list__container" [ngClass]="class" [longPress]="1000">
            <div class="message__container" [ngClass]="class">
              <div class="message" [ngClass]="class">
                @if (data.edited) {
                  <!-- Редактированние сообщения с изображением -->
                  @if (data.image) {
                    <div class="complex-message">
                      <img [src]="data.image" />
                      <textarea clickOutside [clickOutsideItem]="data" (keydown)="onKeyDownHandler($event)"
                    (onClickOutside)="onOutsideClickHandler($event, data, config.selected)"
                    [ngStyle]="{height: getContentHeight(measures.height, true) + 'px'}" [value]="data.name"
                    (click)="onTAClickHandler($event)" (onOutsideClose)="onEditingCloseHandler($event)"
                    (change)="onEditedHandler($event, data)"></textarea>
                    </div>
                  } @else {
                    <!-- Редактированние сообщения без изображения -->
                    <textarea clickOutside [clickOutsideItem]="data" (keydown)="onKeyDownHandler($event)"
                    (onClickOutside)="onOutsideClickHandler($event, data, config.selected)"
                    [ngStyle]="{height: getContentHeight(measures.height) + 'px'}" [value]="data.name"
                    (click)="onTAClickHandler($event)" (onOutsideClose)="onEditingCloseHandler($event)"
                    (change)="onEditedHandler($event, data)"></textarea>
                  }
                } @else {
                  <!-- Сообщение с изображением -->
                  @if (data.image) {
                    <div class="complex-message">
                      <img [src]="data.image" />
                      <span searchHighlight substringClass="search-substring" [text]="data.name" [searchedWords]="searchedWords()"
                        (click)="onEditItemHandler($event, data, config.selected)">{{data.name}}</span>
                    </div>
                  } @else {
                    <!-- Сообщение без изображения -->
                    <span searchHighlight substringClass="search-substring" [text]="data.name" [searchedWords]="searchedWords()"
                      (click)="onEditItemHandler($event, data, config.selected)">{{data.name}}</span>
                  }
                }
              </div>
                <!-- При выборе сообщения отображается элемент управления для его удаления -->
                @if (config.selected) {
                  <div class="flex"></div>
                  <div class="message__controls">
                    <div class="ctrl__button del-icon" (click)="onDeleteItemHandler($event, data)">
                      <!-- Иконка для кнопки удаления -->
                    </div>
                  </div>
                }
              </div>
            </div>
          }
        }
      }
  </ng-template>

  <!-- Шаблон элемента списка поставщика сообщений -->
  <ng-template #itemRenderer let-data="data" let-config="config">
    @if (data) {
      @switch (data.type) {
        @case ("group-header") {
          <div class="list__item-container">
            <div class="content">
              <span>{{data.name}}</span>
            </div>
          </div>
        }
        @default {
          <div class="list__item-container">
            <div class="message">
              <span>
                <!-- Тут располагается пиктограмма для поставщика -->
              </span>
              <span>{{data.name}}</span>
            </div>
          </div>
        }
      }
    }
  </ng-template>
</div>

Детальное описание компонента App:

Скрытый текст
constructor(private _service: ClickOutsideService) {
    const list = this._listContainerRef;

    this.dockMode = computed(() => {
      const menuOpened = this.menuOpened();
      return menuOpened ? DockMode.LEFT : DockMode.NONE;
    });

    const $virtualList = toObservable(list).pipe(
      filter(list => !!list),
      switchMap(list => combineLatest([of(list), list?.$initialized])),
      filter(([, init]) => !!init),
      map(([list]) => list),
    );

    // В момент инициализации и обновления списка сообщений
    // проверяет, если нужно проскроллить до конца списка, то выполняет скролл
    combineLatest([this.$version, $virtualList]).pipe(
      map(([version, list]) => ({ version, list })),
      filter(({ list }) => !!list),
      debounceTime(50),
      tap(({ version, list }) => {
        if (version === 0) {
          list!.scrollToEnd('instant');
        }

        if (this._$isEndOfListPosition.getValue()) {
          list!.scrollToEnd('instant');
        }
      }),
    ).subscribe();

    // Поиск и скролл до искомого сообщения во вьюпорте
    combineLatest([$virtualList, toObservable(this.search)]).pipe(
      map(([list, search]) => ({ list, search })),
      filter(({ list }) => !!list),
      debounceTime(0),
      tap(({ list, search }) => {
        this.searchedWords.set(search.split(' '));
        for (let i = 0, l = this.groupDynamicItems.length; i < l; i++) {
          const item = this.groupDynamicItems[i], name: string = item['name'];
          if (name) {
            const index = name?.indexOf(search);
            if (index > -1) {
              list!.scrollTo(item.id, 'instant');
              break;
            }
          }
        }
      }),
    ).subscribe();

    // При инициализации генерируется первое сообщение
    $virtualList.pipe(
      delay(100),
      mergeMap(() => this.write()),
    ).subscribe();

    // Далее генерация новых сообщений выполняется с интервалом в 2сек
    from(interval(2000)).pipe(
      mergeMap(() => this.write()),
    ).subscribe();

    // Вычисление, является ли позиция скролла конечной
    combineLatest([toObservable(this._scrollParams), $virtualList, this.$version]).pipe(
      delay(10),
      switchMap(([{ viewportEndY, scrollWeight }, list]) => {
        let bounds: ISize | undefined;
        if (list) {
          bounds = list.getItemBounds(this.groupDynamicItems[this.groupDynamicItems.length - 1].id);
        }
        const height = (bounds?.height ?? 0);
        return of((viewportEndY + height + SNAP_HEIGHT) >= scrollWeight);
      }),
      tap(v => {
        this._$isEndOfListPosition.next(v);
      }),
    ).subscribe();

    const appHeightHandler = () => document.documentElement.style.setProperty('--app-height', `${window.innerHeight}px`);
    window.addEventListener('resize', appHeightHandler);

    $virtualList.pipe(
      tap(() => {
        appHeightHandler();
      }),
      delay(100),
      tap(() => {
        document.documentElement.style.setProperty('--viewport-alpha', '1');
      }),
    ).subscribe();
  }

  /**
   * Вычисляет высоту для редактируемого текстового поля
   */
  getContentHeight(v: number, hasImage: boolean = false) {
    return Math.ceil(v) - 34 - (hasImage ? 72 : 0);
  }

  /**
   * Обработчик поиска по подстроке
   */
  onSearchHandler(pattern: string) {
    this.search.set(pattern);
  }

  /**
   * Сброс списка в начальное состояние
   * Вызывается после выбора поставщика сообщений
   */
  private resetList() {
    this.groupDynamicItems = [...GROUP_DYNAMIC_ITEMS];
    this.groupDynamicItemsConfigMap = { ...GROUP_DYNAMIC_ITEMS_STICKY_MAP };
  }

  /** Поток формирования сообщения */
  private write() {
    const msg = generateMessage(this._nextIndex);
    this._nextIndex++;
    return of(msg).pipe(
      tap(() => {
        const writeIndicator = generateWriteIndicator(this._nextIndex);
        this._nextIndex++;
        this.groupDynamicItems = [...this.groupDynamicItems, writeIndicator];
        this.groupDynamicItemsConfigMap[writeIndicator.id] = {
          sticky: 0,
          selectable: false,
        };

        const writeIndicatorShift = generateWriteIndicator(this._nextIndex);
        this._nextIndex++;
        this.groupDynamicItems = [writeIndicatorShift, ...this.groupDynamicItems];
        this.groupDynamicItemsConfigMap[writeIndicatorShift.id] = {
          sticky: 0,
          selectable: false,
        };

        this.increaseVersion();
      }),
      delay(500),
      tap(() => {
        const items = [...this.groupDynamicItems];
        items.pop();
        items.push(msg);
        this.groupDynamicItemsConfigMap[msg.id] = {
          sticky: 0,
          selectable: true,
        };

        items.shift();

        for (let i = 0, l = 1; i < l; i++) {
          const msgStart = generateMessage(this._nextIndex);
          this._nextIndex++;
          this.groupDynamicItemsConfigMap[msgStart.id] = {
            sticky: 0,
            selectable: true,
          };
          items.unshift(msgStart);
        }

        this.groupDynamicItems = items;

        this.increaseVersion();
      }),
    );
  }

  /**
   * Записывает метрики скролла
   */
  onScrollHandler(e: IScrollEvent & { [x: string]: any; }) {
    this._scrollParams.set({
      viewportEndY: e.scrollSize + e.size,
      scrollWeight: e.scrollWeight,
    });
  }

  /**
   * Записывает метрики скролла
   */
  onScrollEndHandler(e: IScrollEvent & { [x: string]: any; }) {
    this._scrollParams.set({
      viewportEndY: e.scrollSize + e.size,
      scrollWeight: e.scrollWeight,
    });
  }

  /**
   * Трэйс клика по сообщению
   */
  onClickHandler(item: IRenderVirtualListItem | undefined) {
    if (item) {
      console.info(`Click: (ID: ${item.id}) Item ${item.data.name}`);
    }
  }

  /**
   * Блокировка распространения события `keydown` при нажатии `Space`
   * Это необходимо, чтобы предотвратить снятие выделения с сообщения.
   * Т.к. за выбор сообщения в списке отвечает именно клавиша `Space`
   */
  onKeyDownHandler(e: KeyboardEvent) {
    if (e.key === ' ') {
      e.stopImmediatePropagation();
    }
  }

  /**
   * Обработчик переключения режима редактирования
   */
  onEditItemHandler(e: Event, item: IRenderVirtualListItem | undefined, selected: boolean) {
    if (selected) {
      e.stopImmediatePropagation();
    }
    const index = this.groupDynamicItems.findIndex(({ id }) => id === item?.id);
    if (index > -1) {
      const items = [...this.groupDynamicItems], item = items[index];
      items[index] = { ...item, edited: selected ? !item.edited : false };
      this.groupDynamicItems = items;
      this.increaseVersion();
    }
  }

  onTAClickHandler(e: Event) {
    e.stopImmediatePropagation();
  }

  /**
   * Завершение редактирования сообщения по срабатыванию `outside click`
   */
  onOutsideClickHandler(e: Event, item: IRenderVirtualListItem<any> | undefined, selected: boolean) {
    const index = this.groupDynamicItems.findIndex(({ id }) => id === item?.id);
    if (index > -1) {
      const items = [...this.groupDynamicItems], item = items[index];
      items[index] = { ...item, edited: false };
      this.groupDynamicItems = items;
      this.increaseVersion();
    }
    this._service.activeTarget = null;
  }

  /**
   * Обработчик завершения редактирования сообщения
   */
  onEditingCloseHandler(data: { target: any; item: IItemData & { id: Id }; }) {
    const index = this.groupDynamicItems.findIndex(({ id }) => id === data.item.id);
    if (index > -1) {
      const items = [...this.groupDynamicItems], _item = items[index];
      items[index] = { ..._item, edited: false, name: data.target.value };
      this.groupDynamicItems = items;
      this.increaseVersion();
    }
  }

  /**
   * Обработчик перехода сообщения из просмотра в режим редактирования и наооборот
   */
  onEditedHandler(e: any, item: IRenderVirtualListItem<any> | undefined) {
    const index = this.groupDynamicItems.findIndex(({ id }) => id === item?.id);
    if (index > -1) {
      const items = [...this.groupDynamicItems], _item = items[index];
      items[index] = { ..._item, edited: !_item.edited, name: e.target.value };
      this.groupDynamicItems = items;
      this.increaseVersion();
    }
  }

  /**
   * Обработчик удаления сообщения
   */
  onDeleteItemHandler(e: Event, item: IRenderVirtualListItem | undefined) {
    e.stopImmediatePropagation();
    const index = this.groupDynamicItems.findIndex(({ id }) => id === item?.id);
    if (index > -1) {
      const items = [...this.groupDynamicItems];
      items.splice(index, 1);
      this.groupDynamicItems = items;
      this.increaseVersion();
    }
  }

  /**
   * Обработчик клика по поставщику сообщений
   */
  onRoomClickHandler(item: IRenderVirtualListItem | undefined) {
    this.menuOpened.set(false);
    if (item) {
      this.title.set(item.data['name']);
      this.resetList();
      this._listContainerRef()?.scrollToEnd('instant');

      setTimeout(() => {
        this._listContainerRef()?.scrollToEnd('instant');
      }, 150);
    }
  }

  /**
   * Открытие/закрытие дока с поставщиками сообщений
   */
  onOpenMenuHandler() {
    this.menuOpened.update(v => !v);
  }

Помимо этого проект включает в себя различные директивы, сервисы и вспомогательные компоненты.

Preview вьюпорта
Preview вьюпорта

Полный код проекта см. по ссылке ng-virtual-list-demo

Live demo проекта

Понравился проект и инструмент? Тогда ставьте ⭐ ng-virtual-list и инструмент будет дальше развиваться и улучшаться!

А также, если интересен данный инструмент виртуализации списков под React, тоже ставьте ⭐ rcx-virtual-list и проект будет портирован с полной функциональностью оригинала!

Комментарии (0)