Angular 2 Faire défiler vers le bas (style de chat)

111

J'ai un ensemble de composants à cellule unique dans un ng-for boucle.

J'ai tout en place mais je n'arrive pas à trouver le bon

Actuellement j'ai

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

Mais cela ne fonctionne pas tout le temps car les images abaissent la fenêtre de manière asynchrone.

Quelle est la manière appropriée de faire défiler vers le bas d'une fenêtre de discussion dans Angular 2?

Max Alexander
la source

Réponses:

204

J'ai eu le même problème, j'utilise une combinaison AfterViewCheckedet @ViewChild(Angular2 beta.3).

Le composant:

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

Le gabarit:

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

Bien sûr, c'est assez basique. Les AfterViewCheckeddéclencheurs à chaque fois que la vue a été vérifiée:

Implémentez cette interface pour être notifié après chaque vérification de la vue de votre composant.

Si vous avez un champ d'entrée pour l'envoi de messages par exemple, cet événement est déclenché après chaque keyup (juste pour donner un exemple). Mais si vous enregistrez si l'utilisateur a fait défiler manuellement et ensuite ignorer, tout scrollToBottom()devrait être bien.

laissez-moi corriger cela
la source
J'ai un composant d'accueil, dans le modèle d'accueil, j'ai un autre composant dans le div qui affiche des données et continue d'ajouter des données, mais la barre de défilement css est dans le modèle d'accueil div pas dans le modèle intérieur. le problème est que j'appelle ce scrolltobottom chaque fois que les données arrivent à l'intérieur du composant mais #scrollMe est dans le composant home puisque ce div est scrollable ... et # scrollMe n'est pas accessible depuis le composant interne .. je ne sais pas comment utiliser #scrollme depuis le composant interne
Anagh Verma
Votre solution, @Basit, est magnifiquement simple et ne finit pas par déclencher des défilements sans fin / nécessite une solution de contournement lorsque l'utilisateur fait défiler manuellement.
theotherdy
3
Salut, existe-t-il un moyen d'empêcher le défilement lorsque l'utilisateur fait défiler vers le haut?
John Louie Dela Cruz
2
J'essaie également de l'utiliser dans une approche de style chat, mais je ne dois pas faire défiler si l'utilisateur a fait défiler vers le haut. J'ai cherché et je ne trouve pas de moyen de détecter si l'utilisateur a fait défiler vers le haut. Une mise en garde est que ce composant de chat n'est pas en plein écran la plupart du temps et possède sa propre barre de défilement.
HarvP
2
@HarvP & JohnLouieDelaCruz avez-vous trouvé une solution pour sauter le défilement si l'utilisateur faisait défiler manuellement?
suhailvs
167

La solution la plus simple et la meilleure pour cela est:

Ajoutez cette #scrollMe [scrollTop]="scrollMe.scrollHeight"chose simple du côté du modèle

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

Voici le lien pour WORKING DEMO (avec application de chat factice) ET CODE COMPLET

Fonctionnera avec Angular2 et jusqu'à 5, comme ci-dessus, la démo se fait dans Angular5.


Remarque :

Pour erreur: ExpressionChangedAfterItHasBeenCheckedError

Veuillez vérifier votre css, c'est un problème du côté css, pas du côté angulaire, l'un des utilisateurs @KHAN a résolu cela en supprimant overflow:auto; height: 100%;de div. (veuillez vérifier les conversations pour plus de détails)

Vivek Doshi
la source
3
Et comment déclencher le parchemin?
Burjua
3
il fera défiler automatiquement vers le bas sur tout élément ajouté à l'élément ngfor OU lorsque la hauteur du div côté intérieur augmentera
Vivek Doshi
2
Merci @VivekDoshi. Bien qu'après l'ajout de quelque chose, j'obtiens cette erreur:> ERROR Error: ExpressionChangedAfterItHasBeenCheckedError: L'expression a changé après avoir été vérifiée. Valeur précédente: «700». Valeur actuelle: '517'.
Vassilis Pits
6
@csharpnewbie J'ai le même problème lors de la suppression de message de la liste: Expression has changed after it was checked. Previous value: 'scrollTop: 1758'. Current value: 'scrollTop: 1734'. L'avez-vous résolu?
Andrey
6
J'ai pu résoudre le "Expression a changé après avoir été vérifié" (tout en gardant la hauteur: 100%; overflow-y: scroll) en ajoutant une condition à [scrollTop], pour l'empêcher d'être définie JUSQU'À ce que j'aie des données à remplir avec, ex: <ul class = "chat-history" #chatHistory [scrollTop] = "messages.length === 0? 0: chatHistory.scrollHeight"> <li * ngFor = "let message of messages"> .. L'ajout de la vérification "messages.length === 0? 0" a semblé résoudre le problème, même si je ne peux pas expliquer pourquoi, donc je ne peux pas dire avec certitude que le correctif n'est pas unique à mon implémentation.
Ben
20

J'ai ajouté une vérification pour voir si l'utilisateur a essayé de faire défiler vers le haut.

Je vais juste laisser ça ici si quelqu'un le veut :)

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

et le code:

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
la source
solution vraiment réalisable sans erreurs dans la console. Merci!
Dmitry Vasilyuk Just3F
20

La réponse acceptée se déclenche lors du défilement des messages, cela évite cela.

Vous voulez un modèle comme celui-ci.

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

Ensuite, vous souhaitez utiliser une annotation ViewChildren pour vous abonner aux nouveaux éléments de message ajoutés à la page.

@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
la source
Salut - J'essaie cette approche, mais elle entre toujours dans le bloc catch. J'ai une carte et à l'intérieur j'ai un div qui affiche plusieurs messages (à l'intérieur de la carte div, j'ai similaire à votre structure). Pouvez-vous m'aider à comprendre si cela nécessite un autre changement dans les fichiers css ou ts? Merci.
csharpnewbie
2
De loin la meilleure réponse. Merci !
cagliostro
1
Cela ne fonctionne actuellement pas en raison d'un bogue angulaire où QueryList modifie les abonnements en ne se déclenchant qu'une seule fois, même lorsqu'il est déclaré dans ngAfterViewInit. La solution de Mert est la seule qui fonctionne. La solution de Vivek se déclenche quel que soit l'élément ajouté, tandis que celle de Mert ne peut se déclencher que lorsque certains éléments sont ajoutés.
John Hamm du
1
C'est la seule réponse qui résout mon problème. fonctionne avec angulaire 6
suhailvs
2
Impressionnant!!! J'ai utilisé celui ci-dessus et j'ai pensé que c'était merveilleux, mais je me suis retrouvé coincé à faire défiler vers le bas et mes utilisateurs ne pouvaient pas lire les commentaires précédents! Merci pour cette solution! Fantastique! Je vous donnerais plus de 100 points si je pouvais :-D
cheesydoritosandkale
13

Si vous voulez être sûr que vous faites défiler jusqu'à la fin une fois que * ngFor est terminé, vous pouvez utiliser ceci.

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

Important ici, la variable "last" définit si vous êtes actuellement au dernier élément, vous pouvez donc déclencher la méthode "scrollToBottom"

Mert
la source
1
Cette réponse mérite d'être acceptée. C'est la seule solution qui fonctionne. La solution de Denixtry ne fonctionne pas en raison d'un bogue angulaire où QueryList modifie les abonnements en ne se déclenchant qu'une seule fois, même lorsqu'il est déclaré dans ngAfterViewInit. La solution de Vivek se déclenche quel que soit l'élément ajouté, tandis que celle de Mert ne peut se déclencher que lorsque certains éléments sont ajoutés.
John Hamm
MERCI! Chacune des autres méthodes présente des inconvénients. Cela fonctionne comme prévu. Merci d'avoir partagé votre code!
lkaupp
6
this.contentList.nativeElement.scrollTo({left: 0 , top: this.contentList.nativeElement.scrollHeight, behavior: 'smooth'});
Stas Kozlov
la source
5
Toute réponse qui amène le demandeur dans la bonne direction est utile, mais essayez de mentionner les limites, les hypothèses ou les simplifications dans votre réponse. La brièveté est acceptable, mais des explications plus complètes sont meilleures.
baduker
2

Partage de ma solution, car je n'étais pas complètement satisfait du reste. Mon problème avec AfterViewCheckedc'est que parfois je défile vers le haut, et pour une raison quelconque, ce hook de vie est appelé et il me fait défiler même s'il n'y avait pas de nouveaux messages. J'ai essayé d' utiliser , OnChangesmais c'était un problème, ce qui me conduit à cette solution. Malheureusement, en utilisant uniquement , il faisait défiler vers le bas avant que les messages ne soient rendus, ce qui n'était pas utile non plus, alors je les ai combinés pour que DoCheck indique essentiellement s'il doit appeler .DoCheckAfterViewCheckedscrollToBottom

Heureux de recevoir des commentaires.

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
la source
Cela peut être utilisé pour vérifier si le chat fait défiler vers le bas avant d'ajouter un nouveau message au DOM: const isScrolledDown = Math.abs(this.scrollable.nativeElement.scrollHeight - this.scrollable.nativeElement.scrollTop - this.scrollable.nativeElement.clientHeight) <= 3.0;(où 3.0 est la tolérance en pixels). Il peut être utilisé ngDoCheckpour ne pas être défini shouldScrollDownde manière trueconditionnelle si l'utilisateur fait défiler manuellement vers le haut.
Ruslan Stelmachenko
Merci pour la suggestion @RuslanStelmachenko. Je l'ai implémenté (j'ai décidé de le faire ngAfterViewCheckedavec l'autre booléen. J'ai changé de shouldScrollDownnom numberOfMessagesChangedpour donner un peu de clarté sur ce à quoi exactement ce booléen se réfère.
RTYX
Je vois que vous le faites if (this.numberOfMessagesChanged && !isScrolledDown), mais je pense que vous n'avez pas compris l'intention de ma suggestion. Mon intention réelle est de ne pas faire défiler automatiquement vers le bas même lorsqu'un nouveau message est ajouté, si l'utilisateur a fait défiler manuellement vers le haut. Sans cela, si vous faites défiler vers le haut pour voir l'historique, le chat défilera vers le bas dès l'arrivée d'un nouveau message. Cela peut être un comportement très ennuyeux. :) Donc, ma suggestion était de vérifier si le chat défile vers le haut avant qu'un nouveau message ne soit ajouté au DOM et de ne pas faire défiler automatiquement, si c'est le cas. ngAfterViewCheckedest trop tard car DOM a déjà changé.
Ruslan Stelmachenko
Aaaaah, je n'avais pas compris l'intention alors. Ce que vous avez dit a du sens - je pense que dans cette situation, de nombreux chats ajoutent simplement un bouton pour descendre, ou une marque indiquant qu'il y a un nouveau message là-bas, donc ce serait certainement un bon moyen d'y parvenir. Je vais le modifier et le changer plus tard. Merci pour les commentaires!
RTYX
Merci mon Dieu pour votre part, avec la solution @Mert, mon point de vue est rejeté sur de rares cas (les appels sont rejetés du backend), avec votre IterableDiffers cela fonctionne bien. Merci beaucoup!
lkaupp
2

La réponse de Vivek a fonctionné pour moi, mais a abouti à une expression a changé après avoir vérifié l'erreur. Aucun des commentaires n'a fonctionné pour moi, mais j'ai changé la stratégie de détection des changements.

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

Dans angular using material design sidenav, j'ai dû utiliser ce qui suit:

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

Après avoir lu d'autres solutions, la meilleure solution à laquelle je puisse penser, donc vous n'exécutez que ce dont vous avez besoin est la suivante: Vous utilisez ngOnChanges pour détecter le changement approprié

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

}

Et vous utilisez ngAfterViewChecked pour appliquer réellement la modification avant son rendu, mais après le calcul de la hauteur totale

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

Si vous vous demandez comment implémenter scrollToBottom

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