Seguro que has visto alguna vez esos headers que en un momento dado del scroll se quedan fijados a la vista. Debajo puedes ver un par de ejemplos de lo que digo, implementados en mi nueva web… ¿Nunca te has preguntado como se crea un componente con estas características?
Ejemplo de sticky header bar
Ejemplo de sticky social buttons
Creando un elemento sticky en Angular
Pues si te preguntabas como hacerlo, estas de suerte, voy a mostrarte como crear un elemento sticky, como el del ejemplo sticky social buttons, con Angular.
Además, voy a generalizar la lógica y extraerla a una directiva, para conseguir un comportamiento reutilizable.
Entendiendo el problema
Lo primero es entender el problema:
Imagina que tienes una página bastante larga (el artículo de un blog, por ejemplo) y hacia el final de la misma hay un elemento que es importante. Es tan importante, que quieres que se muestre de forma fija mientras el usuario no llegue a su posición original haciendo scroll. Lo llamaremos «elemento sticky«.
La siguiente imagen muestra las condiciones iniciales en el momento de la carga de la página.
Condiciones iniciales
En el momento que se carga la página, puedes tener (o no) un cierto scroll (S0), que sumado al offset desde el viewport al elemento sticky (VO0), da la distancia original del elemento sticky al inicio de la página.
En este caso todavía no has hecho suficiente scroll y por tanto el elemento sticky no está visible. Eso es justo lo que quieres cambiar.
¿No sería genial añadirle una clase .fixed
a este elemento para añadirle el estilo position:fixed
por CSS? El objetivo es que cuando el elemento sticky tenga esta clase, se muestre en la zona inferior de la vista, siempre con el mismo offset hasta el bottom.
Elemento fijado
Como ves, el offset desde el viewport al elemento sticky siempre será el mismo (VOf) mientras el elemento esté fijado.
Entonces… ¿cual es el umbral para decidir si el elemento debe tener la clase .fixed
o no? Imagina una situación de scroll cualquiera…
Es fácil darse cuenta de que el punto crítico es cuando
Si + VOf = S0 + VO0
Para ser más exactos, la clase .fixed
se debe añadir cuando
Si + VOf < S0 + VO0
¿Y cuando debe eliminarse la clase .fixed
?
En este caso hay que fijarse en la situación contraria, cuando el elemento sticky ya no está fijado sino que se mueve al hacer scroll por que el usuario ha superado su posición. Sería el caso de la imagen siguiente:
Fíjate que ahora he definido VOi, que corresponde al offset actual desde el viewport al elemento, cuyo valor cambia al hacer scroll por que el elemento sticky ya no está fijado.
La condición para eliminar la clase .fixed
del elemento, será por tanto:
Si + VOi >= S0 + VO0
Directiva sticky, la solución
Ahora que está más claro lo que hay que hacer, es el momento de que te pongas manos a la obra. Vamos por partes:
1. Reusabilidad
Es posible que quieras utilizar esta lógica para distintos componentes en distintos momentos. Por eso, en lugar de encapsularla en un componente, es más útil vincularla una directiva atributo. La llamaré sticky-below-view
.
De este modo, podrás añadir esta funcionalidad a cualquier componente o elemento de tu vista con solo añadirle este atributo:
<!-- some content -->
<p> Scroll down to see how the sticky element works </p>
<some-element sticky-below-view>
This is now a sticky element
</some-element>
<!-- some more content -->
2. Crear una directiva
Para crear esta directiva con la CLI de Angular, solo tienes que hacer: ng generate directive sticky-below-view
.
Esto creará un elemento como el siguiente:
sticky-below-view.directive.ts
//sticky-below-view.directive.ts
import { Directive } from '@angular/core';
@Directive({
selector: '[sticky-below-view]'
})
export class HighlightDirective {
constructor() { }
}
Además, declarará la directiva en mi NgModule:
// app.module.ts
//...some imports...
import { StickyBelowViewDirective } from './sticky-below-view.directive';
@NgModule({
imports: [ BrowserModule, FormsModule ],
declarations: [ AppComponent, StickyBelowViewDirective ],
bootstrap: [ AppComponent ],
})
export class AppModule { }
3. Obteniendo el offset inicial
Lo primero que hay que hacer es obtener el offset inicial del elemento, es decir, la suma S0 + VO0.
Para eso creo el siguiente método en la directiva:
//sticky-below-view.directive.ts
//...some stuff...
export class HighlightDirective {
private initialOffsetFromTop = 0;
constructor(private element: ElementRef) { }
private getInitialOffset(){
let initialViewportOffset = this.element.nativeElement.getBoundingClientRect().top;
let currentScroll = window.scrollY;
this.initialOffsetFromTop = initialViewportOffset + currentScroll;
}
}
- Obtengo S0 a partir de
window.scrollY
. - Obtengo VO0 llamando al método
getBoundingClientRect().top
del elemento que contiene la directiva (lo obtengo inyectandoElementRef
en el constructor). - Y finalmente guardo la suma S0 + VO0 en la propiedad
initialOffsetFromTop
.
4. Obteniendo el offset en posición fija
También necesito guardarme otro offset, que es el de cuando el elemento está en posición fija, es decir, cuando se le aplica la clase .fixed
.
En el ejemplo que te pongo, mi estilo .fixed
para este elemento es así de sencillo:
.fixed{
position: fixed;
bottom: 50px;
}
Esta posición no la conozco de antemano (podría tener distintos valores de bottom
para distintos elementos), así que la mejor forma de conocer este valor, es añadiendo momentáneamente la clase al elemento.
Para eso añado un nuevo método getFixedViewportOffset
a mi directiva:
//sticky-below-view.directive.ts
//...some stuff...
export class HighlightDirective {
private initialOffsetFromTop = 0;
private fixedViewportOffset = 0;
constructor(
private element: ElementRef,
private renderer:Renderer2
) { }
private getInitialOffset(){/*...*/}
private getFixedViewportOffset(){
//set the fixed class
this.renderer.addClass(this.element.nativeElement, 'fixed');
//save the view offset in fixed position
this.fixedViewportOffset = this.element.nativeElement.getBoundingClientRect().top;
//remove again the fixed class
this.renderer.removeClass(this.element.nativeElement, 'fixed');
}
}
- He utilizado el servicio
Renderer2
de Angular para añadir la clase.fixed
al elemento. - Esto ha modificado su posición debido al CSS de dicha clase, y he aprovechado para calcular el nuevo offset con respecto al viewport (lo guardo en
fixedViewportOffset
). - Finalmente, quito la clase
.fixed
del elemento.
5. Escuchando al evento de scroll
Si echas un vistazo a mi blog, verás que en artículos anteriores he explicado en detalle como escuchar el evento scroll.
Aquí te presento la forma más simple de hacerlo, pero te recomiendo darle un vistazo a este servicio de scroll que publiqué hace unos días. De hecho, el código de StackBlitz que encontrarás con la demo al final de artículo utiliza una variación de este servicio de scroll para dejar más limpio el código.
Volviendo al ejemplo, lo que necesitas ahora es escuchar el evento de scroll, y añadir la clase .fixed
o eliminarla, en función de la situación. Para eso añado el método handleScroll
decorado con un HostListener
del evento scroll:
//sticky-below-view.directive.ts
//...some stuff...
enum StickyState {
fixed = "fixed",
noFixed = "no-fixed"
}
//...some more stuff...
export class HighlightDirective {
private fixedState = StickyState.noFixed;
private initialOffsetFromTop = 0;
private fixedViewportOffset = 0;
constructor(
private element: ElementRef,
private renderer:Renderer2
) {
this.getInitialOffset();
this.getFixedViewportOffset();
}
private getInitialOffset(){/*...*/}
private getFixedViewportOffset(){/*...*/}
@HostListener("window:scroll", ['$event'])
private handleScroll($event:Event){
let currentScroll = $event.srcElement.children[0].scrollTop;
//if not fixed
//and we have not yet scrolled until the original position of the element
//add the fixed class
if(this.fixedState == StickyState.noFixed &&
currentScroll + this.fixedViewportOffset < this.initialOffsetFromTop){
this.fixedState = StickyState.fixed;
this.renderer.addClass(this.element.nativeElement, 'fixed');
}
//if fixed
else if(this.fixedState == StickyState.fixed){
let currentOffsetFromTop = currentScroll + this.element.nativeElement.getBoundingClientRect().top;
//and the current offset from top is greater or equal than the original
//remove the fixed class
if (currentOffsetFromTop >= this.initialOffsetFromTop){
this.fixedState = StickyState.noFixed;
this.renderer.removeClass(this.element.nativeElement, 'fixed');
}
}
}
}
- He creado la propiedad
fixedStage
a la que le asigno valores del enumStickyState
, para ver de forma clara en qué estado se encuentra el elemento. - He inicializado el offset fijo y el inicial en el constructor, llamando a los métodos que he creado al principio.
- En el método
handleScroll
recojo el scroll actual y aplico las formulas que he definido al principio del artículo, para ver cuando añadir la clase.fixed
y cuando eliminarla del elemento.
Resultado final
El código anterior te permite añadir la directiva atributo sticky-below-view
a cualquier elemento, modificando su comportamiento cuando se hace scroll en función de cómo definas la clase CSS .fixed
.
Dale un vistazo al siguiente StackBlitz para ver el resultado:
Conclusiones
Mi objetivo de hoy era mostrarte un caso práctico de la utilidad de las directivas en Angular, en este caso con una aplicación vistosa y llamativa que (espero) haya captado tu atención.
Si te ha parecido interesante, la barra de social sharing de está web sigue ese mismo principio, así que te animo a que la uses 😉
¿Te ha gustado este artículo? No te cortes, déjame un comentario y ayúdame a compartirlo 😉
Gracias por tu tiempo! te sigo desde udemy y eres un máquina!
Hola Enrique!
He intentado seguir el tutorial con angular universal.
No entiendo por que this.fixedViewportOffset = this.element.nativeElement.getBoundingClientRect().top; siempre da 0; incluso teniendo el elemento en la mitad de la pantalla…
He intentado meter la inicialización de la directiva (this.initStickyElement() ) dentro del ciclo de vida AfterViewInit y lo mismo… con document referenciado una clase en particular pasa lo mismo..
Sabes por que puede pasar? se te ocurre alguna forma de poder remediar esto en angular universal ?
Cómo siempre! enorme el contenido que ofreces! y tus cursos!!
Hola! quedo deprecado el srcElement.
$event.srcElement.children[0].scrollTop;
encontre que ahora se usa target, pero ni idea lo de children y el scrolltop.
Podras actualizar el post?
Gracias!
german Kolberg
Quizá es tarde para responderte, pero dejo una solución por aquí por si a alguien le sirve. La idea es cambiar
$event.srcElement.children[0].scrollTop;
por
($event.target as Element).scrollTop;
Un saludo