Angular 2 Scroll to bottom (Chat style)

111

Eu tenho um conjunto de componentes de uma única célula dentro de um ng-forloop.

Eu tenho tudo no lugar, mas não consigo descobrir o adequado

Atualmente eu tenho

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

Mas isso não funciona o tempo todo, pois as imagens empurram a janela de visualização para baixo de maneira assíncrona.

Qual é a maneira apropriada de rolar até a parte inferior de uma janela de bate-papo no Angular 2?

Max Alexander
fonte

Respostas:

203

Tive o mesmo problema, estou usando uma combinação AfterViewCheckede @ViewChild(Angular2 beta.3).

O componente:

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) { }                 
    }
}

O modelo:

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

Claro que isso é muito básico. O AfterViewCheckedacionador sempre que a visualização é verificada:

Implemente esta interface para ser notificado após cada verificação da visualização do seu componente.

Se você tiver um campo de entrada para enviar mensagens, por exemplo, este evento é disparado após cada tecla (apenas para dar um exemplo). Mas se você salvar se o usuário rolou manualmente e, em seguida, pular o, scrollToBottom()você ficará bem.

deixe-me corrigir isso
fonte
Eu tenho um componente home, no modelo home eu tenho outro componente no div que está mostrando dados e continuo acrescentando dados, mas a barra de rolagem css está no div do modelo home, não no modelo interno. o problema é que estou chamando esse scrolltobottom toda vez que os dados entram no componente, mas #scrollMe está no componente inicial, pois div é rolável ... e # scrollMe não é acessível pelo componente interno .. não tenho certeza de como usar #scrollme do componente interno
Anagh Verma
Sua solução, @Basit, é lindamente simples e não acaba disparando pergaminhos sem fim / precisa de uma solução alternativa para quando o usuário rola manualmente.
theotherdy
3
Olá, existe uma maneira de evitar a rolagem quando o usuário rola para cima?
John Louie Dela Cruz
2
Também estou tentando usar isso em uma abordagem de estilo de bate-papo, mas não é necessário rolar se o usuário rolar para cima. Tenho procurado e não consigo encontrar uma maneira de detectar se o usuário rolou para cima. Uma ressalva é que esse componente de bate-papo não fica em tela inteira na maioria das vezes e tem sua própria barra de rolagem.
HarvP
2
@HarvP & JohnLouieDelaCruz você encontrou uma solução para pular a rolagem se o usuário rolou manualmente?
suhailvs
166

A solução mais simples e melhor para isso é:

Adicione esta #scrollMe [scrollTop]="scrollMe.scrollHeight"coisa simples no lado do modelo

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

Aqui está o link para DEMO DE TRABALHO (com aplicativo de bate-papo fictício) E CÓDIGO COMPLETO

Funcionará com Angular2 e também até 5, como a demonstração acima é feita em Angular5.


Nota :

Por erro: ExpressionChangedAfterItHasBeenCheckedError

Verifique seu css, é um problema do lado do css, não do lado angular. Um dos usuários @KHAN resolveu o problema removendo overflow:auto; height: 100%;do div. (verifique as conversas para obter detalhes)

Vivek Doshi
fonte
3
E como você aciona o pergaminho?
Burjua
3
ele rolará automaticamente para baixo em qualquer item adicionado ao item ngfor OU quando a altura do div do lado interno aumentar
Vivek Doshi
2
Thx @VivekDoshi. Embora, depois de adicionar algo, eu receba este erro:> ERROR Error: ExpressionChangedAfterItHasBeenCheckedError: A expressão foi alterada depois de ser verificada. Valor anterior: '700'. Valor atual: '517'.
Vassilis Pits
6
@csharpnewbie Eu tenho o mesmo problema quando remover qualquer mensagem da lista: Expression has changed after it was checked. Previous value: 'scrollTop: 1758'. Current value: 'scrollTop: 1734'. Você resolveu isso?
Andrey
6
Consegui resolver o "Expression mudou depois de ser verificado" (mantendo a altura: 100%; overflow-y: scroll) adicionando uma condição a [scrollTop], para evitar que fosse definido ATÉ que eu tivesse dados para preencher com, ex: <ul class = "chat-history" #chatHistory [scrollTop] = "messages.length === 0? 0: chatHistory.scrollHeight"> <li * ngFor = "let message of messages"> .. . a adição da verificação "messages.length === 0? 0" parece ter corrigido o problema, embora eu não possa explicar o porquê, portanto, não posso dizer com certeza se a correção não é exclusiva da minha implementação.
Ben
20

Eu adicionei uma verificação para ver se o usuário tentou rolar para cima.

Só vou deixar isso aqui se alguém quiser :)

<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>

e o código:

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) { }
    }

}
robert king
fonte
solução realmente viável sem erros no console. Obrigado!
Dmitry Vasilyuk Just3F
20

A resposta aceita dispara durante a rolagem pelas mensagens, o que evita isso.

Você quer um modelo como este.

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

Em seguida, você deseja usar uma anotação ViewChildren para assinar novos elementos de mensagem que estão sendo adicionados à página.

@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) {}
}
denixtry
fonte
Oi - estou tentando essa abordagem, mas sempre vai para o bloco catch. Eu tenho um cartão e dentro dele tenho uma div que exibe várias mensagens (dentro do cartão div, tenho uma estrutura semelhante à sua). Você pode me ajudar a entender se isso precisa de alguma outra alteração nos arquivos css ou ts? Obrigado.
csharpnewbie
2
De longe a melhor resposta. Obrigado !
cagliostro
1
Atualmente, isso não funciona devido a um bug do Angular em que o QueryList altera as assinaturas disparando apenas uma vez, mesmo quando declarado em ngAfterViewInit. A solução de Mert é a única que funciona. A solução de Vivek dispara independentemente do elemento adicionado, enquanto a de Mert pode disparar apenas quando certos elementos são adicionados.
John Hamm
1
Essa é a única resposta que resolve meu problema. funciona com angular 6
suhailvs
2
Impressionante!!! Usei o acima e achei maravilhoso, mas fiquei preso ao rolar para baixo e meus usuários não conseguiram ler os comentários anteriores! Obrigado por esta solução! Fantástico! Eu daria a você mais de 100 pontos se pudesse :-D
cheesydoritosandkale
13

Se quiser ter certeza de que vai rolar até o final depois que * ngFor for concluído, você pode usar isso.

<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;
}

Importante aqui, a variável "última" define se você está no último item, então você pode acionar o método "scrollToBottom"

Mert
fonte
1
Essa resposta merece ser aceita. É a única solução que funciona. A solução de Denixtry não funciona devido a um bug do Angular em que o QueryList altera as assinaturas disparando apenas uma vez, mesmo quando declarado em ngAfterViewInit. A solução de Vivek dispara independentemente do elemento adicionado, enquanto a de Mert pode disparar apenas quando certos elementos são adicionados.
John Hamm
OBRIGADO! Cada um dos outros métodos tem algumas desvantagens. Isso funciona como previsto. Obrigado por compartilhar seu código!
lkaupp
6
this.contentList.nativeElement.scrollTo({left: 0 , top: this.contentList.nativeElement.scrollHeight, behavior: 'smooth'});
Stas Kozlov
fonte
5
Qualquer resposta que leve o autor da pergunta na direção certa é útil, mas tente mencionar quaisquer limitações, suposições ou simplificações em sua resposta. A brevidade é aceitável, mas explicações mais completas são melhores.
baduker
2

Compartilhando minha solução, pois não fiquei completamente satisfeito com o resto. Meu problema AfterViewCheckedé que às vezes estou rolar para cima e, por algum motivo, esse gancho de vida é chamado e rola para baixo, mesmo que não haja novas mensagens. Tentei usar OnChangesmas isso foi um problema que me levou a esta solução. Infelizmente, usando apenas DoCheck, ele estava rolando para baixo antes de as mensagens serem renderizadas, o que também não era útil, então eu os combinei para que DoCheck esteja basicamente indicando AfterViewCheckedse deveria chamar scrollToBottom.

Fico feliz em receber feedback.

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%
RTYX
fonte
Isso pode ser usado para verificar se o chat foi rolado para baixo antes de adicionar uma nova mensagem ao DOM: const isScrolledDown = Math.abs(this.scrollable.nativeElement.scrollHeight - this.scrollable.nativeElement.scrollTop - this.scrollable.nativeElement.clientHeight) <= 3.0;(onde 3.0 é a tolerância em pixels). Ele pode ser usado em ngDoCheckpara condicionalmente não definida shouldScrollDownpara truese o usuário é rolada manualmente.
Ruslan Stelmachenko
Obrigado pela sugestão @RuslanStelmachenko. Eu implementei (decidi fazer no ngAfterViewCheckedcom o outro booleano. shouldScrollDownnumberOfMessagesChanged
Mudei o
Vejo que sim if (this.numberOfMessagesChanged && !isScrolledDown), mas acho que não entendeu a intenção da minha sugestão. Minha real intenção é não rolar para baixo automaticamente, mesmo quando uma nova mensagem é adicionada, se o usuário rolar manualmente para cima. Sem isso, se você rolar para cima para ver o histórico, o chat irá rolar para baixo assim que uma nova mensagem chegar. Isso pode ser um comportamento muito irritante. :) Então, minha sugestão foi verificar se o chat é rolado para cima antes de uma nova mensagem ser adicionada ao DOM e não rolagem automática, se for. ngAfterViewCheckedé tarde demais porque o DOM já mudou.
Ruslan Stelmachenko
Aaaaah, eu não tinha entendido a intenção então. O que você disse faz sentido - acho que, nessa situação, muitos bate-papos apenas adicionam um botão para descer, ou uma marca de que há uma nova mensagem lá, então esta seria definitivamente uma boa maneira de conseguir isso. Vou editar e mudar mais tarde. Obrigado pelo feedback!
RTYX de
Graças a Deus por sua parte, com a solução @Mert, minha visão foi derrubada em casos raros (chamada foi rejeitada do backend), com seus IterableDiffers ela funciona bem. Muito obrigado!
lkaupp
2

A resposta de Vivek funcionou para mim, mas resultou em uma expressão que mudou depois de ser verificado um erro. Nenhum dos comentários funcionou para mim, mas o que fiz foi mudar a estratégia de detecção de alterações.

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

No angular using material design sidenav, tive que usar o seguinte:

let ele = document.getElementsByClassName('md-sidenav-content');
    let eleArray = <Element[]>Array.prototype.slice.call(ele);
    eleArray.map( val => {
        val.scrollTop = val.scrollHeight;
    });
Helzgate
fonte
1
const element = document.getElementById('box');
element.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' });
Kanomdook
fonte
0

Depois de ler outras soluções, a melhor solução que posso pensar, para que você execute apenas o que precisa é o seguinte: Você usa ngOnChanges para detectar a mudança adequada

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;
        }
      }
    }

}

E você usa ngAfterViewChecked para realmente impor a mudança antes de renderizar, mas depois que a altura total for calculada

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

Se você está se perguntando como implementar scrollToBottom

@ViewChild('scrollWrapper') private scrollWrapper: ElementRef;
scrollToBottom(){
    try {
      this.scrollWrapper.nativeElement.scrollTop = this.scrollWrapper.nativeElement.scrollHeight;
    } catch(err) { }
  }
Zardilior
fonte