Angular 2 Прокрутка вниз (стиль чата)


112

У меня есть набор компонентов с одной ячейкой внутри ng-forцикла.

У меня все на месте, но я не могу понять, как правильно

В настоящее время у меня есть

setTimeout(() => {
  scrollToBottom();
});

Но это не всегда работает, поскольку изображения асинхронно опускают область просмотра.

Как лучше всего прокрутить окно чата до конца в Angular 2?

Ответы:


206

У меня была такая же проблема, я использую AfterViewCheckedи @ViewChildкомбинированную (Angular2 beta.3).

Компонент:

import {..., AfterViewChecked, ElementRef, ViewChild, OnInit} from 'angular2/core'
@Component({
    ...
})
export class ChannelComponent implements OnInit, AfterViewChecked {
    @ViewChild('scrollMe') private myScrollContainer: ElementRef;

    ngOnInit() { 
        this.scrollToBottom();
    }

    ngAfterViewChecked() {        
        this.scrollToBottom();        
    } 

    scrollToBottom(): void {
        try {
            this.myScrollContainer.nativeElement.scrollTop = this.myScrollContainer.nativeElement.scrollHeight;
        } catch(err) { }                 
    }
}

Шаблон:

<div #scrollMe style="overflow: scroll; height: xyz;">
    <div class="..." 
        *ngFor="..."
        ...>  
    </div>
</div>

Конечно, это довольно просто. AfterViewCheckedСрабатывает каждый раз , когда проверялся вид:

Реализуйте этот интерфейс, чтобы получать уведомления после каждой проверки представления вашего компонента.

Если у вас есть поле ввода для отправки сообщений, например, это событие запускается после каждого нажатия клавиши (просто для примера). Но если вы сохраните, прокручивает ли пользователь вручную, а затем пропустите, scrollToBottom()все будет в порядке.


У меня есть домашний компонент, в домашнем шаблоне у меня есть еще один компонент в div, который показывает данные и продолжает добавлять данные, но полоса прокрутки css находится в домашнем шаблоне div, а не во внутреннем шаблоне. проблема в том, что я вызываю этот scrolltobottom каждый раз, когда данные поступают внутри компонента, но #scrollMe находится в домашнем компоненте, так как этот div прокручивается ... и # scrollMe недоступен из внутреннего компонента .. не уверен, как использовать #scrollme из внутреннего компонента
Anagh Verma

Ваше решение, @Basit, очень простое и не приводит к бесконечным прокруткам / требуется обходной путь, когда пользователь прокручивает вручную.
theotherdy

3
Привет, есть ли способ предотвратить прокрутку, когда пользователь прокручивает вверх?
Джон Луи Дела Круз

2
Я также пытаюсь использовать это в стиле чата, но мне не нужно прокручивать, если пользователь прокручивает вверх. Я искал и не могу найти способ определить, прокрутил ли пользователь вверх. Одно предостережение: этот компонент чата большую часть времени не работает в полноэкранном режиме и имеет собственную полосу прокрутки.
HarvP

2
@HarvP & JohnLouieDelaCruz Вы нашли решение пропустить прокрутку, если пользователь прокручивает вручную?
suhailvs

167

Самое простое и лучшее решение для этого:

Добавьте эту #scrollMe [scrollTop]="scrollMe.scrollHeight"простую вещь на стороне шаблона

<div style="overflow: scroll; height: xyz;" #scrollMe [scrollTop]="scrollMe.scrollHeight">
    <div class="..." 
        *ngFor="..."
        ...>  
    </div>
</div>

Вот ссылка на РАБОЧУЮ ДЕМО (с фиктивным приложением чата) И ПОЛНЫЙ КОД

Будет работать с Angular2, а также до 5. Как и выше, демонстрация сделана в Angular5.


Примечание :

В случае ошибки: ExpressionChangedAfterItHasBeenCheckedError

Пожалуйста, проверьте свой css, это проблема стороны css, а не стороны Angular. Один из пользователей @KHAN решил это, удалив overflow:auto; height: 100%;из div. (подробности смотрите в разговорах)


3
А как запустить прокрутку?
Бурджуа

3
он будет автоматически прокручиваться вниз на любом элементе, добавленном к элементу ngfor ИЛИ при увеличении высоты внутреннего блока div
Вивек Доши,

2
Спасибо @VivekDoshi. Хотя после того, как что-то добавлено, я получаю эту ошибку:> ERROR Error: ExpressionChangedAfterItHasBeenCheckedError: Выражение изменилось после проверки. Предыдущее значение: «700». Текущее значение: «517».
Василис Питс

6
@csharpnewbie У меня такая же проблема при удалении любого сообщения из списка: Expression has changed after it was checked. Previous value: 'scrollTop: 1758'. Current value: 'scrollTop: 1734'. Вы это решили?
Андрей

6
Мне удалось решить «Выражение изменилось после проверки» (сохраняя высоту: 100%; переполнение-y: прокрутка), добавив условие в [scrollTop], чтобы предотвратить его установку ДО ТОГО, КАК у меня были данные для заполнения это с, например: <ul class = "chat-history" #chatHistory [scrollTop] = "messages.length === 0? 0: chatHistory.scrollHeight"> <li * ngFor = "let message of messages"> .. . Добавление проверки "messages.length === 0? 0", похоже, решило проблему, хотя я не могу объяснить почему, поэтому я не могу с уверенностью сказать, что это исправление не является уникальным для моей реализации.
Бен

20

Я добавил проверку, пытался ли пользователь прокрутить вверх.

Я просто оставлю это здесь, если кому-то это нужно :)

<div class="jumbotron">
    <div class="messages-box" #scrollMe (scroll)="onScroll()">
        <app-message [message]="message" [userId]="profile.userId" *ngFor="let message of messages.slice().reverse()"></app-message>
    </div>

    <textarea [(ngModel)]="newMessage" (keyup.enter)="submitMessage()"></textarea>
</div>

и код:

import { AfterViewChecked, ElementRef, ViewChild, Component, OnInit } from '@angular/core';
import {AuthService} from "../auth.service";
import 'rxjs/add/operator/catch';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/switchMap';
import 'rxjs/add/operator/concatAll';
import {Observable} from 'rxjs/Rx';

import { Router, ActivatedRoute } from '@angular/router';

@Component({
    selector: 'app-messages',
    templateUrl: './messages.component.html',
    styleUrls: ['./messages.component.scss']
})
export class MessagesComponent implements OnInit {
    @ViewChild('scrollMe') private myScrollContainer: ElementRef;
    messages:Array<MessageModel>
    newMessage = ''
    id = ''
    conversations: Array<ConversationModel>
    profile: ViewMyProfileModel
    disableScrollDown = false

    constructor(private authService:AuthService,
                private route:ActivatedRoute,
                private router:Router,
                private conversationsApi:ConversationsApi) {
    }

    ngOnInit() {

    }

    public submitMessage() {

    }

     ngAfterViewChecked() {
        this.scrollToBottom();
    }

    private onScroll() {
        let element = this.myScrollContainer.nativeElement
        let atBottom = element.scrollHeight - element.scrollTop === element.clientHeight
        if (this.disableScrollDown && atBottom) {
            this.disableScrollDown = false
        } else {
            this.disableScrollDown = true
        }
    }


    private scrollToBottom(): void {
        if (this.disableScrollDown) {
            return
        }
        try {
            this.myScrollContainer.nativeElement.scrollTop = this.myScrollContainer.nativeElement.scrollHeight;
        } catch(err) { }
    }

}

действительно работоспособное решение без ошибок в консоли. Спасибо!
Дмитрий Василюк Just3F

20

Принятый ответ срабатывает при прокрутке сообщений, это позволяет избежать этого.

Вам нужен такой шаблон.

<div #content>
  <div #messages *ngFor="let message of messages">
    {{message}}
  </div>
</div>

Затем вы хотите использовать аннотацию ViewChildren для подписки на новые элементы сообщения, добавляемые на страницу.

@ViewChildren('messages') messages: QueryList<any>;
@ViewChild('content') content: ElementRef;

ngAfterViewInit() {
  this.scrollToBottom();
  this.messages.changes.subscribe(this.scrollToBottom);
}

scrollToBottom = () => {
  try {
    this.content.nativeElement.scrollTop = this.content.nativeElement.scrollHeight;
  } catch (err) {}
}

Привет - Я пробую этот подход, но он всегда попадает в ловушку. У меня есть карта, а внутри у меня есть div, который отображает несколько сообщений (внутри div карты я похож на вашу структуру). Не могли бы вы помочь мне понять, нужны ли для этого какие-либо другие изменения в файлах css или ts? Спасибо.
csharpnewbie 06

2
Безусловно, лучший ответ. Спасибо !
cagliostro

1
В настоящее время это не работает из-за ошибки Angular, когда QueryList изменяет подписки только один раз, даже если они объявлены в ngAfterViewInit. Решение Мерта - единственное, что работает. Решение Vivek срабатывает независимо от того, какой элемент добавлен, тогда как решение Mert может срабатывать только при добавлении определенных элементов.
Джон Хэмм

1
Это единственный ответ, который решает мою проблему. работает с angular 6
suhailvs

2
Потрясающие!!! Я использовал тот, что был выше, и подумал, что это замечательно, но затем я застрял при прокрутке вниз, и мои пользователи не могли читать предыдущие комментарии! Спасибо за это решение! Фантастика! Я бы дал вам 100+ очков, если бы мог :-D
cheesydoritosandkale

19

Рассмотрите возможность использования

.scrollIntoView()

См. Https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView.


Еще не стандарт, если у вас есть кроссбраузерная совместимость, у вас будут ограничения.
chavocarlos

1
@chavocarlos - вроде поддерживается всем, кроме Opera Mini: caniuse.com/#feat=scrollintoview
Борис Якубчик

13

Если вы хотите быть уверенным, что прокручиваете до конца после завершения * ngFor, вы можете использовать это.

<div #myList>
 <div *ngFor="let item of items; let last = last">
  {{item.title}}
  {{last ? scrollToBottom() : ''}}
 </div>
</div>

scrollToBottom() {
 this.myList.nativeElement.scrollTop = this.myList.nativeElement.scrollHeight;
}

Важно здесь, что «последние» переменная определяет , если вы в настоящее время последний пункт, так что вы можете вызвать «scrollToBottom» метод


1
Этот ответ заслуживает того, чтобы его приняли. Это единственное решение, которое работает. Решение Denixtry не работает из-за ошибки Angular, когда QueryList изменяет подписки только один раз, даже если они объявлены в ngAfterViewInit. Решение Vivek срабатывает независимо от того, какой элемент добавлен, тогда как решение Mert может срабатывать только при добавлении определенных элементов.
Джон Хэмм

СПАСИБО! У каждого из других методов есть свои недостатки. Это работает так, как задумано. Спасибо, что поделились своим кодом!
lkaupp 05

6
this.contentList.nativeElement.scrollTo({left: 0 , top: this.contentList.nativeElement.scrollHeight, behavior: 'smooth'});

5
Любой ответ, который направляет задающего вопрос в правильном направлении, полезен, но постарайтесь указать в своем ответе любые ограничения, предположения или упрощения. Краткость приемлема, но более полные объяснения лучше.
baduker

2

Делюсь своим решением, потому что остальное меня не полностью устраивало. Моя проблема в AfterViewCheckedтом, что иногда я прокручиваю вверх, и по какой-то причине вызывается этот жизненный крючок, и он прокручивает меня вниз, даже если не было новых сообщений. Я пробовал использовать, OnChangesно это была проблема, которая привела меня к этому решению. К сожалению, при использовании only DoCheckон прокручивался вниз до того, как сообщения были отображены, что тоже было бесполезно, поэтому я объединил их, так что DoCheck в основном указывает, AfterViewCheckedдолжен ли он вызывать scrollToBottom.

Рады получать отзывы.

export class ChatComponent implements DoCheck, AfterViewChecked {

    @Input() public messages: Message[] = [];
    @ViewChild('scrollable') private scrollable: ElementRef;

    private shouldScrollDown: boolean;
    private iterableDiffer;

    constructor(private iterableDiffers: IterableDiffers) {
        this.iterableDiffer = this.iterableDiffers.find([]).create(null);
    }

    ngDoCheck(): void {
        if (this.iterableDiffer.diff(this.messages)) {
            this.numberOfMessagesChanged = true;
        }
    }

    ngAfterViewChecked(): void {
        const isScrolledDown = Math.abs(this.scrollable.nativeElement.scrollHeight - this.scrollable.nativeElement.scrollTop - this.scrollable.nativeElement.clientHeight) <= 3.0;

        if (this.numberOfMessagesChanged && !isScrolledDown) {
            this.scrollToBottom();
            this.numberOfMessagesChanged = false;
        }
    }

    scrollToBottom() {
        try {
            this.scrollable.nativeElement.scrollTop = this.scrollable.nativeElement.scrollHeight;
        } catch (e) {
            console.error(e);
        }
    }

}

chat.component.html

<div class="chat-wrapper">

    <div class="chat-messages-holder" #scrollable>

        <app-chat-message *ngFor="let message of messages" [message]="message">
        </app-chat-message>

    </div>

    <div class="chat-input-holder">
        <app-chat-input (send)="onSend($event)"></app-chat-input>
    </div>

</div>

chat.component.sass

.chat-wrapper
  display: flex
  justify-content: center
  align-items: center
  flex-direction: column
  height: 100%

  .chat-messages-holder
    overflow-y: scroll !important
    overflow-x: hidden
    width: 100%
    height: 100%

Это можно использовать, чтобы проверить, прокручивается ли чат вниз перед добавлением нового сообщения в DOM: const isScrolledDown = Math.abs(this.scrollable.nativeElement.scrollHeight - this.scrollable.nativeElement.scrollTop - this.scrollable.nativeElement.clientHeight) <= 3.0;(где 3.0 - допуск в пикселях). Его можно использовать, ngDoCheckчтобы условно не задавать shouldScrollDownзначение, trueесли пользователь прокручивается вручную.
Руслан Стельмаченко

Спасибо за предложение @RuslanStelmachenko. Я реализовал это (я решил сделать это ngAfterViewCheckedс другим логическим значением. Я изменил shouldScrollDownимя на, numberOfMessagesChangedчтобы
прояснить,

Я вижу if (this.numberOfMessagesChanged && !isScrolledDown), что вы понимаете, но я думаю, вы не поняли смысла моего предложения. Мое реальное намерение - не прокручивать автоматически вниз, даже когда добавляется новое сообщение, если пользователь вручную прокручивает вверх. Без этого, если вы прокрутите вверх, чтобы увидеть историю, чат будет прокручиваться вниз, как только появится новое сообщение. Это может быть очень раздражающим поведением. :) Итак, я предложил проверить, прокручивается ли чат до того, как новое сообщение будет добавлено в DOM, и не использовать автопрокрутку, если это так. ngAfterViewCheckedслишком поздно, потому что DOM уже изменился.
Руслан Стельмаченко

Ааааа, я тогда не понял намерения. То, что вы сказали, имеет смысл - я думаю, что в этой ситуации многие чаты просто добавляют кнопку для перехода вниз или отметку о том, что там есть новое сообщение, так что это определенно будет хорошим способом добиться этого. Отредактирую и поменяю позже. Спасибо за ответ!
RTYX

Слава богу за вашу долю, с решением @Mert мое мнение ухудшается в редких случаях (вызов отклоняется из бэкэнда), с вашим IterableDiffers он отлично работает. Спасибо вам большое!
lkaupp 05

2

Ответ Вивека сработал для меня, но в результате выражение изменилось после того, как была проверена ошибка. Ни один из комментариев у меня не сработал, но я изменил стратегию обнаружения изменений.

import {  Component, ChangeDetectionStrategy } from '@angular/core';
@Component({
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: 'page1',
  templateUrl: 'page1.html',
})

1

В angular с использованием сайднава Material Design мне пришлось использовать следующее:

let ele = document.getElementsByClassName('md-sidenav-content');
    let eleArray = <Element[]>Array.prototype.slice.call(ele);
    eleArray.map( val => {
        val.scrollTop = val.scrollHeight;
    });

1
const element = document.getElementById('box');
element.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' });

0

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

ngOnChanges() {
  if (changes.messages) {
      let chng = changes.messages;
      let cur  = chng.currentValue;
      let prev = chng.previousValue;
      if(cur && prev) {
        // lazy load case
        if (cur[0].id != prev[0].id) {
          this.lazyLoadHappened = true;
        }
        // new message
        if (cur[cur.length -1].id != prev[prev.length -1].id) {
          this.newMessageHappened = true;
        }
      }
    }

}

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

ngAfterViewChecked(): void {
    if(this.newMessageHappened) {
      this.scrollToBottom();
      this.newMessageHappened = false;
    }
    else if(this.lazyLoadHappened) {
      // keep the same scroll
      this.lazyLoadHappened = false
    }
  }

Если вам интересно, как реализовать scrollToBottom

@ViewChild('scrollWrapper') private scrollWrapper: ElementRef;
scrollToBottom(){
    try {
      this.scrollWrapper.nativeElement.scrollTop = this.scrollWrapper.nativeElement.scrollHeight;
    } catch(err) { }
  }
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.