Как разработчики, мы знаем, что некоторые вещи, которые могут показаться тривиальными, в конечном итоге занимают много времени. Я делал приложение для чата в Angular, и это всплыло. Приложение требует, чтобы положение прокрутки сохранялось, когда сообщения загружаются динамически без какой-либо заметной для пользователя разницы. И эта простая мелочь заняла большую часть недели!

Проблема

Приложение чата сначала загружает самые последние сообщения, а когда загружаются более старые сообщения, представление кажется прыгающим. Это плохой пользовательский опыт. Хорошим решением было бы загружать старые сообщения в фоновом режиме, чтобы пользователь мог прокручивать вверх, чтобы увидеть загруженные сообщения без заметной задержки.

Исходный код

<div class="messages" infiniteScroll [infiniteScrollUpDistance]="9.9" [scrollWindow]="false" [infiniteScrollThrottle]="1500" (scrolledUp)="loadMore()"> <mat-spinner *ngIf="loading" [diameter]="50"></mat-spinner> <ul> <ng-container *ngFor="let list of messageList; trackBy:key"> <li *ngIf="list?.user_type === 'User'" class="sent"> <span> <p>{{list?.message}}</p> <small>{{list?.message_timestamp}}</small> </span> </li> <li *ngIf="list?.user_type === 'Agent'" class="replies"> <span> <p>{{list?.message}}</p> <small>{{list?.message_timestamp}}</small> </span> </li> </ng-container> </ul> </div>

Я использую замечательный ngx-infinite-scroll для управления событиями прокрутки.

Вещи, которые я пробовал

Я обдумал множество подходов и опробовал несколько.

Попытка 1: Element.scrollIntoView()

Каждому сообщению назначается атрибут id, который можно использовать для запроса элемента с помощью getElementById(). Затем я использовал собственный метод элемента scrollIntoView() для прокрутки соответствующего элемента в поле зрения.

Поскольку длина сообщения и область просмотра различаются для каждого пользователя; этот способ не сработал. Невозможно было предсказать, какие все атрибуты id находятся в текущем окне просмотра, поэтому событие не может быть инициировано должным образом.

Попытка 2. Запомните значение scrollHeight; затем прокрутите назад

Идея состоит в том, чтобы запомнить расстояние прокрутки от дна, а затем восстановить то же расстояние после загрузки новых сообщений.

В хорошей статье Andrew Petersen проблема решена с помощью JavaScript. Вы можете найти его реализацию на Angular в Medium post Гаурава Мукерджи. Настоятельно рекомендуется прочитать обе.

Этот метод решил 99% проблемы.

Действительно! 99% проблемы. Мы смогли запомнить позицию прокрутки, в которой загружались сообщения. Действие прокрутки назад привело к резкому эффекту в пользовательском интерфейсе. И прокрутка назад иногда не реагировала. Решение действительно хорошее, но я хотел улучшить его.

Решение

Наше решение представляет собой отлаженную и рефакторинговую версию решения Gaurav. Преобразование службы в директиву помогло нам избавиться от мерцания и дрожания. Мы избегали использования хука жизненного цикла ngAfterViewInit, который, по моему скромному и личному мнению, был излишним.

Обновлен фрагмент HTML:

<div class="messages" appScrollDirective infiniteScroll [infiniteScrollUpDistance]="9.9" [scrollWindow]="false" [infiniteScrollThrottle]="1500" (scrolledUp)="loadMore()"> <mat-spinner *ngIf="loading" [diameter]="50"></mat-spinner> <ul> <ng-container *ngFor="let list of messageList; trackBy:key"> <li *ngIf="list?.user_type === 'User'" class="sent"> <span> <p>{{list?.message}}</p> <small>{{list?.message_timestamp}}</small> </span> </li> <li *ngIf="list?.user_type === 'Agent'" class="replies"> <span> <p>{{list?.message}}</p> <small>{{list?.message_timestamp}}</small> </span> </li> </ng-container> </ul> </div>

Соответствующий фрагмент TypeScript, управляющий прокруткой:

ngOnInit() { this.store.select(getAllMessages).subscribe(messageList => { this.scrollDirective.prepareFor('up'); // this method stores the current scroll position this.messageList = this.messageVO.getMessageVO(messageList); // the updated message list setTimeout(() => this.scrollDirective.restore()); // method to restore the scroll position }); this.store.select(getSelectChatId).distinctUntilChanged() .subscribe(id => { this.scrollDirective.reset(); // refresh scroll to initial position }); }

Мы следуем архитектуре NgRx, следовательно, используем команды store и select.

Метод prepareFor() вызывается каждый раз непосредственно перед обновлением messageList.

Метод restore() вызывается после обновления messageList.

Используется setTimeout() с задержкой 0 мс, поскольку по определению 0 мс — это просто минимальное время для выполнения метода. Мы попробовалиPromise.resolve(1).then(), но это не сработало, потому чтоsetTimout() — это макрозадача, тогда как Promises считается микрозадачей. Обратитесь к этой статье Джейка Арчибальда за отличным объяснением.

Теперь перейдем к главному событию, scroll.directive.ts:

import { Directive, ElementRef } from '@angular/core'; @Directive({ selector: '[appScrollDirective]' }) export class ScrollDirective { previousScrollHeightMinusTop: number; // the variable which stores the distance readyFor: string; toReset = false; constructor(public elementRef: ElementRef) { this.previousScrollHeightMinusTop = 0; this.readyFor = 'up'; this.restore(); } reset() { this.previousScrollHeightMinusTop = 0; this.readyFor = 'up'; this.elementRef.nativeElement.scrollTop = this.elementRef.nativeElement.scrollHeight; // resetting the scroll position to bottom because that is where chats start. } restore() { if (this.toReset) { if (this.readyFor === 'up') { this.elementRef.nativeElement.scrollTop = this.elementRef.nativeElement.scrollHeight - this.previousScrollHeightMinusTop; // restoring the scroll position to the one stored earlier } this.toReset = false; } } prepareFor(direction) { this.toReset = true; this.readyFor = direction || 'up'; this.elementRef.nativeElement.scrollTop = !this.elementRef.nativeElement.scrollTop // check for scrollTop is zero or not ? this.elementRef.nativeElement.scrollTop + 1 : this.elementRef.nativeElement.scrollTop; this.previousScrollHeightMinusTop = this.elementRef.nativeElement.scrollHeight - this.elementRef.nativeElement.scrollTop; // the current position is stored before new messages are loaded } }

ElementRef возвращает ссылку на <div>, который привязан директивой. Использование свойства scrollTop элемента используется для управления положением элемента.
Три метода reset(), prepareFor() и restore() имеют три цели:

  • reset() — сбросить позицию при загрузке новых чатов. Это вызывается при загрузке чата.
  • prepareFor() — для сохранения позиции перед сохранением старых сообщений. Проверка, равна ли scrollTop нулю, потому что это пограничный случай.
  • restoreFor() — восстановить положение прокрутки.

Попутно мы узнали много нового, например, написание директивы, хуки жизненного цикла Angular, цикл событий JavaScript и другие мелкие вещи.

Первоначально опубликовано на сайте blog.neoito.com 19 февраля 2019 г.