DEV Community

Evgenii Grebennikov
Evgenii Grebennikov

Posted on

Writing a high-performance viewport for a messenger

The previous article was dedicated to the Angular tool ng-virtual-list. Since then, the tool has acquired rich functionality, a number of significant improvements have been made to the virtualization and tracking algorithms, stability and performance have been increased. A port to React has also been implemented.

It’s worth noting that ng-virtual-list is not just a virtualized list, it can optionally work as a virtualized select and multi-select; it can work with grouped lists, and in the future, collapsableGroups and multi-threading will be added.

Design

Now it’s time to test the full power of list virtualization in practice.

Let’s define the design conditions of the viewport:

  • Message list. Messages will be automatically created at the beginning and end of the list; implement the ability to edit and delete messages.
  • Search for a message by substring.
  • Side list of message providers, which will be displayed in the left dock. The dock opens by clicking on the corresponding button. When selecting a provider, the generated list of messages is displayed and the dock closes.
  • Viewport header, which will display the name of the message provider and the message search controls and the button to open the provider dock.

Mockup of the future viewport for the messenger

We will use Angular 19.x and ng-virtual-list@19

Implementation

In this article I will not provide the entire project code, because you can find it below at the link. I will concentrate only on the main points.

Detailed description of the template:

<div class="container"> <!-- Toolbar --> <div class="toolbar"> <div> <!-- Button that opens the dock with message providers --> <app-menu-button (click)="onOpenMenuHandler()" [opened]="menuOpened()" /> </div> <!-- Message Provider Header --> <div class="title">{{title()}}</div> <!-- Control for searching messages by substring --> <app-search (search)="onSearchHandler($event)" /> </div> <div class="list-container"> @let dock = dockMode(); <app-drawer [dock]="dock" [dockLeftSize]="240"> <!-- Message Provider Dock --> <dock-left> <div class="list-rooms__container"> <!-- Virtual list of message providers --> <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> <!-- Message viewport --> <div class="list-wrapper"> <!-- Virtual Message List --> <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> <!-- Message template --> <ng-template #groupItemRenderer let-data="data" let-measures="measures" let-config="config"> @if (data) { @switch (data.type) { <!-- Message generation indicator --> @case ("write-indicator") { <div class="list__windicator-container"> <!-- Here is the icon for the message generation indicator --> </div> } <!-- Message group title --> @case ("group-header") { <div class="list__group-container" [ngClass]="{'snapped': config.snapped, 'snapped-out': config.snappedOut}"> <span>{{data.name}}</span> </div> } <!-- Message --> @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) { <!-- Editing a post with an image --> @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 { <!-- Editing a post without an image --> <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 { <!-- Message with image --> @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 { <!-- Message without image --> <span searchHighlight substringClass="search-substring" [text]="data.name" [searchedWords]="searchedWords()" (click)="onEditItemHandler($event, data, config.selected)">{{data.name}}</span> } } </div> <!-- When you select a message, a control for deleting it appears. --> @if (config.selected) { <div class="flex"></div> <div class="message__controls"> <div class="ctrl__button del-icon" (click)="onDeleteItemHandler($event, data)"> <!-- Icon for the delete button --> </div> </div> } </div> </div> } } } </ng-template> <!-- Message Provider List Item 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> <!-- Here is the icon for the supplier --> </span> <span>{{data.name}}</span> </div> </div> } } } </ng-template> </div> 
Enter fullscreen mode Exit fullscreen mode

Detailed description of the App component:

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), ); // At the moment of initialization and updating of the list of messages // checks, if it is necessary to scroll to the end of the list, then it performs scrolling 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(); // Search and scroll to the desired message in the viewport 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(); // During initialization, the first message is generated $virtualList.pipe( delay(100), mergeMap(() => this.write()), ).subscribe(); // Further generation of new messages is performed at intervals of 2 seconds. from(interval(2000)).pipe( mergeMap(() => this.write()), ).subscribe(); // Calculating if a scroll position is final 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(); } /** * Calculates the height for an editable text field. */ getContentHeight(v: number, hasImage: boolean = false) { return Math.ceil(v) - 34 - (hasImage ? 72 : 0); } /** * Substring search handler */ onSearchHandler(pattern: string) { this.search.set(pattern); } /** * Reset list to initial state * Called after selecting a message provider */ private resetList() { this.groupDynamicItems = [...GROUP_DYNAMIC_ITEMS]; this.groupDynamicItemsConfigMap = { ...GROUP_DYNAMIC_ITEMS_STICKY_MAP }; } /** Message generation flow */ 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(); }), ); } /** * Records scroll metrics */ onScrollHandler(e: IScrollEvent & { [x: string]: any; }) { this._scrollParams.set({ viewportEndY: e.scrollSize + e.size, scrollWeight: e.scrollWeight, }); } /** * Records scroll metrics */ onScrollEndHandler(e: IScrollEvent & { [x: string]: any; }) { this._scrollParams.set({ viewportEndY: e.scrollSize + e.size, scrollWeight: e.scrollWeight, }); } /** * Trace a click on a message */ onClickHandler(item: IRenderVirtualListItem | undefined) { if (item) { console.info(`Click: (ID: ${item.id}) Item ${item.data.name}`); } } /** * Block propagation of `keydown` event when pressing `Space` * This is necessary to prevent deselection of a message. * Because the `Space` key is responsible for selecting a message in the list */ onKeyDownHandler(e: KeyboardEvent) { if (e.key === ' ') { e.stopImmediatePropagation(); } } /** * Edit mode switch handler */ 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(); } /** * Finish editing a message when `outside click` is triggered */ 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; } /** * Message editing completion handler */ 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(); } } /** * Handler for transitioning a message from viewing to editing mode and vice versa */ 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(); } } /** * Message deletion handler */ 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(); } } /** * Message Provider Click Handler */ 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); } } /** * Opening/closing the dock with message providers */ onOpenMenuHandler() { this.menuOpened.update(v => !v); } 
Enter fullscreen mode Exit fullscreen mode

Viewport Preview

The full project code can be found at ng-virtual-list-demo.

Live demo of the project.

Liked the project and the tool? Give ng-virtual-list a star ⭐ and the tool will develop and improve!

Also, if you are interested in this list of virtualization tools for React, also star ⭐ rcx-virtual-list and the project will be ported with full functionality of the original!

Top comments (0)