Angular 2 Desplazarse hacia abajo (estilo Chat)

111

Tengo un conjunto de componentes de una sola celda dentro de un ng-forbucle.

Tengo todo en su lugar, pero parece que no puedo encontrar el

Actualmente tengo

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

Pero esto no funciona todo el tiempo ya que las imágenes empujan la ventana hacia abajo de forma asincrónica.

¿Cuál es la forma adecuada de desplazarse hasta la parte inferior de una ventana de chat en Angular 2?

Max Alexander
fuente

Respuestas:

203

Tuve el mismo problema, estoy usando una combinación AfterViewCheckedy @ViewChild(Angular2 beta.3).

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

La plantilla:

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

Por supuesto, esto es bastante básico. Los AfterViewCheckeddisparadores cada vez que se verificó la vista:

Implemente esta interfaz para recibir notificaciones después de cada verificación de la vista de su componente.

Si tiene un campo de entrada para enviar mensajes, por ejemplo, este evento se activa después de cada tecla (solo para dar un ejemplo). Pero si guarda si el usuario se desplazó manualmente y luego omite el scrollToBottom(), debería estar bien.

déjame ajustar eso
fuente
Tengo un componente de inicio, en la plantilla de inicio tengo otro componente en el div que muestra datos y sigo agregando datos, pero la barra de desplazamiento css está en la plantilla de inicio div no en la plantilla interior. el problema es que llamo a este scrolltobottom cada vez que los datos entran dentro del componente, pero #scrollMe está en el componente de inicio ya que ese div es desplazable ... y # scrollMe no es accesible desde el componente interno ... no estoy seguro de cómo usar #scrollme desde el componente interno
Anagh Verma
Su solución, @Basit, es maravillosamente simple y no termina disparando pergaminos interminables / necesita una solución para cuando el usuario se desplaza manualmente.
theotherdy
3
Hola, ¿hay alguna forma de evitar el desplazamiento cuando el usuario se desplazó hacia arriba?
John Louie Dela Cruz
2
También estoy tratando de usar esto en un enfoque de estilo de chat, pero no necesito desplazarme si el usuario se desplazó hacia arriba. He estado buscando y no puedo encontrar una manera de detectar si el usuario se desplazó hacia arriba. Una advertencia es que este componente de chat no es de pantalla completa la mayor parte del tiempo y tiene su propia barra de desplazamiento.
HarvP
2
@HarvP & JohnLouieDelaCruz ¿Encontraste una solución para omitir el desplazamiento si el usuario se desplazaba manualmente?
suhailvs
166

La mejor y más simple solución para esto es:

Agregue esta #scrollMe [scrollTop]="scrollMe.scrollHeight"cosa simple en el lado de la Plantilla

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

Aquí está el enlace para la demostración de trabajo (con la aplicación de chat ficticia) Y EL CÓDIGO COMPLETO

Funcionará con Angular2 y también hasta 5, ya que la demostración anterior se realiza en Angular5.


Nota :

Por error: ExpressionChangedAfterItHasBeenCheckedError

Verifique su css, es un problema del lado css, no del lado angular, uno de los usuarios @KHAN lo ha resuelto eliminando overflow:auto; height: 100%;de div. (consulte las conversaciones para obtener más detalles)

Vivek Doshi
fuente
3
¿Y cómo se activa el pergamino?
Burjua
3
se desplazará automáticamente hacia la parte inferior en cualquier elemento agregado al elemento ngfor O cuando aumente la altura del div del lado interno
Vivek Doshi
2
Gracias @VivekDoshi. Aunque después de agregar algo, aparece este error:> ERROR Error: ExpressionChangedAfterItHasBeenCheckedError: La expresión ha cambiado después de que se verificó. Valor anterior: '700'. Valor actual: '517'.
Vassilis Pits
6
@csharpnewbie Tengo el mismo problema cuando la eliminación de cualquier mensaje de la lista: Expression has changed after it was checked. Previous value: 'scrollTop: 1758'. Current value: 'scrollTop: 1734'. ¿Lo resolviste?
Andrey
6
Pude resolver el "La expresión ha cambiado después de que se verificó" (manteniendo la altura: 100%; overflow-y: scroll) agregando una condición a [scrollTop], para evitar que se establezca HASTA que tenga datos para completar con, por ejemplo: <ul class = "chat-history" #chatHistory [scrollTop] = "messages.length === 0? 0: chatHistory.scrollHeight"> <li * ngFor = "dejar mensaje de mensajes"> .. La adición de la verificación "messages.length === 0? 0" pareció solucionar el problema, aunque no puedo explicar por qué, por lo que no puedo decir con certeza que la solución no sea exclusiva de mi implementación.
Ben
20

Agregué un cheque para ver si el usuario intentó desplazarse hacia arriba.

Voy a dejar esto aquí si alguien lo quiere :)

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

y el 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
fuente
Solución realmente viable sin errores en la consola. ¡Gracias!
Dmitry Vasilyuk Just3F
20

La respuesta aceptada se dispara mientras se desplaza por los mensajes, esto evita eso.

Quieres una plantilla como esta.

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

Luego, desea utilizar una anotación de ViewChildren para suscribirse a los nuevos elementos de mensaje que se agregan a la 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
fuente
Hola: estoy probando este enfoque, pero siempre entra en el bloque de captura. Tengo una tarjeta y dentro de ella tengo un div que muestra varios mensajes (dentro del div de la tarjeta, tengo una estructura similar a tu). ¿Pueden ayudarme a comprender si esto necesita algún otro cambio en los archivos css o ts? Gracias.
csharpnewbie
2
De lejos, la mejor respuesta. Gracias !
cagliostro
1
Esto actualmente no funciona debido a un error angular en el que QueryList cambia las suscripciones solo se activa una vez, incluso cuando se declara en ngAfterViewInit. La solución de Mert es la única que funciona. La solución de Vivek se dispara sin importar qué elemento se agregue, mientras que la de Mert solo se puede disparar cuando se agregan ciertos elementos.
John Hamm
1
Esta es la única respuesta que resuelve mi problema. funciona con angular 6
suhailvs
2
¡¡¡Increíble!!! Usé el de arriba y pensé que era maravilloso, ¡pero luego me quedé atascado al desplazarme hacia abajo y mis usuarios no pudieron leer los comentarios anteriores! ¡Gracias por esta solución! ¡Fantástico! Te daría más de 100 puntos si pudiera :-D
cheesydoritosandkale
13

Si quiere estar seguro de que se desplaza hasta el final después de que * ngFor haya terminado, puede usar esto.

<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 aquí, la "última" variable define si se encuentra actualmente en el último elemento, por lo que puede activar el método "scrollToBottom"

Mert
fuente
1
Esta respuesta merece ser la aceptada. Es la única solución que funciona. La solución de Denixtry no funciona debido a un error angular en el que QueryList cambia las suscripciones solo una vez, incluso cuando se declara en ngAfterViewInit. La solución de Vivek se dispara sin importar qué elemento se agregue, mientras que la de Mert solo se puede disparar cuando se agregan ciertos elementos.
John Hamm
¡GRACIAS! Cada uno de los otros métodos tiene algunas desventajas. Esto simplemente funciona como está previsto. ¡Gracias por compartir tu código!
lkaupp
6
this.contentList.nativeElement.scrollTo({left: 0 , top: this.contentList.nativeElement.scrollHeight, behavior: 'smooth'});
Stas Kozlov
fuente
5
Cualquier respuesta que lleve al autor de la pregunta en la dirección correcta es útil, pero trate de mencionar las limitaciones, suposiciones o simplificaciones en su respuesta. La brevedad es aceptable, pero las explicaciones más completas son mejores.
baduker
2

Compartiendo mi solución, porque no estaba del todo satisfecho con el resto. Mi problema con AfterViewCheckedes que a veces me desplazo hacia arriba y, por alguna razón, se llama a este gancho de vida y me desplaza hacia abajo incluso si no había mensajes nuevos. Intenté usar, OnChangespero esto fue un problema, lo que me llevó a esta solución. Desafortunadamente, al usar solo DoCheck, se desplazaba hacia abajo antes de que se procesaran los mensajes, lo que tampoco fue útil, así que los combiné para que DoCheck básicamente indique AfterViewCheckedsi debería llamar scrollToBottom.

Feliz de recibir comentarios.

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
fuente
Esto se puede usar para verificar si el chat se desplaza hacia abajo antes de agregar un nuevo mensaje al DOM: const isScrolledDown = Math.abs(this.scrollable.nativeElement.scrollHeight - this.scrollable.nativeElement.scrollTop - this.scrollable.nativeElement.clientHeight) <= 3.0;(donde 3.0 es la tolerancia en píxeles). Puede ser utilizado en ngDoCheckcondicionalmente no se establece shouldScrollDowna truesi el usuario se desplaza manualmente.
Ruslan Stelmachenko
Gracias por la sugerencia @RuslanStelmachenko. Lo implementé (decidí hacerlo ngAfterViewCheckedcon el otro booleano. Cambié el shouldScrollDownnombre numberOfMessagesChangedpara dar algo de claridad sobre a qué se refiere exactamente este booleano.
RTYX
Veo que lo hace if (this.numberOfMessagesChanged && !isScrolledDown), pero creo que no entendió la intención de mi sugerencia. Mi verdadera intención es no desplazarme hacia abajo automáticamente incluso cuando se agrega un mensaje nuevo, si el usuario se desplazó manualmente hacia arriba. Sin esto, si se desplaza hacia arriba para ver el historial, el chat se desplazará hacia abajo tan pronto como llegue un nuevo mensaje. Este puede ser un comportamiento muy molesto. :) Entonces, mi sugerencia fue verificar si el chat se desplaza hacia arriba antes de agregar un nuevo mensaje al DOM y no autodesplazarse, si es así. ngAfterViewCheckedes demasiado tarde porque DOM ya cambió.
Ruslan Stelmachenko
Aaaaah, entonces no había entendido la intención. Lo que dijiste tiene sentido: creo que en esa situación muchos chats solo agregan un botón para bajar, o una marca de que hay un nuevo mensaje ahí abajo, así que esta definitivamente sería una buena manera de lograrlo. Lo editaré y lo cambiaré más tarde. ¡Gracias por la respuesta!
RTYX
Gracias a Dios por tu parte, con la solución @Mert, mi vista se rechaza en casos raros (la llamada se rechaza desde el backend), con tus IterableDiffers funciona muy bien. Muchas gracias!
lkaupp
2

La respuesta de Vivek me funcionó, pero resultó en que una expresión cambió después de que se verificó el error. Ninguno de los comentarios me funcionó, pero lo que hice fue cambiar la estrategia de detección de cambios.

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

En angular usando material design sidenav tuve que usar lo siguiente:

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

Después de leer otras soluciones, la mejor solución en la que puedo pensar, para que ejecute solo lo que necesita es la siguiente: usa ngOnChanges para detectar el cambio adecuado

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

}

Y usa ngAfterViewChecked para hacer cumplir el cambio antes de que se procese, pero después de que se calcula la altura completa

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

Si se pregunta cómo implementar scrollToBottom

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