As I was scrolling through Twitter’s PWA (I assume this would be the same in their native application as well), I noticed that when you scroll down on a screen that has a “double toolbar” in the header:
The top “toolbar” will animate off of the screen, leaving only the tab bar:
This leaves more screen real estate for viewing the content by removing interface elements that are not immediately needed. The top toolbar will then reappear when the user begins to scroll up again. I wanted to implement this same feature in an Ionic/Angular application, and ended up building something that looks like this:
In this tutorial, we are going to walk through building a directive that we can attach to an element on the screen that will cause it to disappear when the content area is scrolled down (and reappear when the content area is scrolled back up). We will be covering a few interesting concepts in this tutorial, including:
- Creating a directive in Angular
- Utilising Ionic 4 web components in a directive
- Using
@Input
to pass references to elements to a directive - Modifying the properties of an element in a performant way
- Simple animation
- Listening for DOM changes inside of a Shadow DOM
- Creating an observable from Custom DOM Events emitted by Ionic Web Components
- Styling with CSS4 Variables
This is a lot for one tutorial, so I will just briefly touch on these concepts and I will link out to separate tutorials to explain the concepts in more depth.
Before We Get Started
Last updated for Ionic 4.3.0
This tutorial assumes you already have a basic level of understanding of Ionic & Angular. If you require more introductory level content on Ionic I would recommend checking out my book or the Ionic tutorials on my website.
1. Set up the Template
When developing a custom component or directive, I like to pretend that it already exists and use it in the way that I want it to work. So, we’re going to start a bit backward and add the non-existent directive to our template immediately.
Modify src/app/home/home.page.html to reflect the following:
<ion-header>
<ion-toolbar color="primary" [myScrollVanish]="scrollArea">
<ion-title> Browse Animals </ion-title>
<ion-buttons slot="start">
<ion-back-button defaultHref="/"></ion-back-button>
</ion-buttons>
</ion-toolbar>
<ion-segment>
<ion-segment-button> Cats </ion-segment-button>
<ion-segment-button> Dogs </ion-segment-button>
<ion-segment-button> Snakes </ion-segment-button>
<ion-segment-button> Hamsters </ion-segment-button>
</ion-segment>
</ion-header>
<ion-content #scrollArea scrollEvents="true">
<ion-list>
<ion-item *ngFor="let test of tests">
<a href="http://thecatapi.com"
><img src="http://thecatapi.com/api/images/get?format=src&type=gif"
/></a>
</ion-item>
</ion-list>
</ion-content>
That is the entire template, but let’s focus on the important parts:
<ion-header>
<ion-toolbar color="primary" [myScrollVanish]="scrollArea"> ... </ion-toolbar>
<ion-segment> ... </ion-segment>
</ion-header>
<ion-content #scrollArea scrollEvents="true"> ... </ion-content>
We have two components inside of our ion-header
and we also have our main ion-content
area. Our goal is to listen to the main content area for scroll events and then hide our ion-toolbar
based on those scroll events. We will achieve this by adding the myScrollVanish
directive to the ion-toolbar
and passing it a reference to the content area using the local variable #scrollArea
. The Content
component will not emit scroll events by default, so we also need to enable scrollEvents
on ion-content
.
This is a nice and clean way to use the directive – it will allow us to just simply drop that directive wherever we want to use it, and it is easy to create the reference to the content area.
Let’s also add some dummy data so that we can loop over the little cat picture we added (just for the sake of having some content to scroll).
Modify src/app/home/home.page.ts to reflect the following:
import { Component } from "@angular/core";
@Component({
selector: "app-home",
templateUrl: "home.page.html",
styleUrls: ["home.page.scss"]
})
export class HomePage {
public tests = new Array(20);
}
2. Create the Directive
Now that we have an idea of how we want the directive to work, let’s build it. If you are unfamiliar with creating custom directives and their general purpose, I would recommend reading Using a Directive to Modify the Behaviour of an Ionic Component.
To create the directive, we can run the following command:
ionic g directive directives/ScrollVanish
With the directive created, we are first going to implement all of the code for the directive and then we will talk through it.
Modify src/app/directives/scroll-vanish.directive.ts to reflect the following:
import { Directive, Input, ElementRef, Renderer2, OnInit } from "@angular/core";
import { DomController } from "@ionic/angular";
@Directive({
selector: "[myScrollVanish]"
})
export class ScrollVanishDirective implements OnInit {
@Input("myScrollVanish") scrollArea;
private hidden: boolean = false;
private triggerDistance: number = 20;
constructor(
private element: ElementRef,
private renderer: Renderer2,
private domCtrl: DomController
) {}
ngOnInit() {
this.initStyles();
this.scrollArea.ionScroll.subscribe(scrollEvent => {
let delta = scrollEvent.detail.deltaY;
if (scrollEvent.detail.currentY === 0 && this.hidden) {
this.show();
} else if (!this.hidden && delta > this.triggerDistance) {
this.hide();
} else if (this.hidden && delta < -this.triggerDistance) {
this.show();
}
});
}
initStyles() {
this.domCtrl.write(() => {
this.renderer.setStyle(
this.element.nativeElement,
"transition",
"0.2s linear"
);
this.renderer.setStyle(this.element.nativeElement, "height", "44px");
});
}
hide() {
this.domCtrl.write(() => {
this.renderer.setStyle(this.element.nativeElement, "min-height", "0px");
this.renderer.setStyle(this.element.nativeElement, "height", "0px");
this.renderer.setStyle(this.element.nativeElement, "opacity", "0");
this.renderer.setStyle(this.element.nativeElement, "padding", "0");
});
this.hidden = true;
}
show() {
this.domCtrl.write(() => {
this.renderer.setStyle(this.element.nativeElement, "height", "44px");
this.renderer.removeStyle(this.element.nativeElement, "opacity");
this.renderer.removeStyle(this.element.nativeElement, "min-height");
this.renderer.removeStyle(this.element.nativeElement, "padding");
});
this.hidden = false;
}
}
There is quite a bit of code here, so we will break it down into chunks as we talk through it. First, let’s take a look at the set up of the directive:
import { Directive, Input, ElementRef, Renderer2, OnInit } from "@angular/core";
import { DomController } from "@ionic/angular";
@Directive({
selector: "[myScrollVanish]"
})
export class ScrollVanishDirective implements OnInit {
@Input("myScrollVanish") scrollArea;
private hidden: boolean = false;
private triggerDistance: number = 20;
constructor(
private element: ElementRef,
private renderer: Renderer2,
private domCtrl: DomController
) {}
...
}
We are importing quite a lot here, and there are a few things that will likely stand out more than the others. We are importing ElementRef
and Renderer2
as we will be making modifications to the DOM in order to hide/animate the element that the directive is attached to. We are also importing DomController
which will allow us to make our modifications to the DOM at the ideal time for better performance.
You can see that we have an input of myScrollVanish
that we are assigning to the scrollArea
class member – this will be the reference to the ion-content
area that we are passing in to listen to scroll events. If you are unfamiliar with the role of @Input
and @Output
I would recommend watching Custom Components in Ionic (in short, we use @Input
to pass data into our directives, and @Output
to pass data back out of our directives).
We’ve set up some additional class members as well. The hidden
variable will allow us to keep track of whether the element is currently hidden or not, and triggerDistance
allows us to specify a tolerance level for when the hiding/showing should trigger.
Now let’s look at the ngOnInit
function that will run immediately upon this directive being initialised:
ngOnInit() {
this.initStyles();
this.scrollArea.ionScroll.subscribe(scrollEvent => {
let delta = scrollEvent.detail.deltaY;
if (scrollEvent.detail.currentY === 0 && this.hidden) {
this.show();
} else if (!this.hidden && delta > this.triggerDistance) {
this.hide();
} else if (this.hidden && delta < -this.triggerDistance) {
this.show();
}
});
}
The goal for our ngOnInit
function is to:
- Set up the required styles on the element
- Start listening for scroll events
- Trigger the hiding/showing of the element when appropriate
To listen for scroll events, we can just subscribe to the ionScroll
observable that is provided by ion-content
, which we do like this:
this.scrollArea.ionScroll.subscribe((scrollEvent) => {});
We use this to set up our logic for reacting to scroll events. Basically, when the user scrolls down we want to trigger hide
and when the user scrolls up we want to trigger show
. However, we add some extra checks in to make sure that we only trigger those methods when necessary (e.g. we don’t want to trigger the hide
method if the element is already hidden).
Finally, let’s talk through those three methods we are calling:
initStyles() {
this.domCtrl.write(() => {
this.renderer.setStyle(
this.element.nativeElement,
"transition",
"0.2s linear"
);
this.renderer.setStyle(this.element.nativeElement, "height", "44px");
});
}
hide() {
this.domCtrl.write(() => {
this.renderer.setStyle(this.element.nativeElement, "min-height", "0px");
this.renderer.setStyle(this.element.nativeElement, "height", "0px");
this.renderer.setStyle(this.element.nativeElement, "opacity", "0");
this.renderer.setStyle(this.element.nativeElement, "padding", "0");
});
this.hidden = true;
}
show() {
this.domCtrl.write(() => {
this.renderer.setStyle(this.element.nativeElement, "height", "44px");
this.renderer.removeStyle(this.element.nativeElement, "opacity");
this.renderer.removeStyle(this.element.nativeElement, "min-height");
this.renderer.removeStyle(this.element.nativeElement, "padding");
});
this.hidden = false;
}
All we are doing to set up our animation is to add the transition
CSS property to the element the directive is attached to. This will cause CSS changes to animate rather than instantly changing to the new style. Notice that we are using renderer
to modify the styles instead of just manipulating the DOM element directly – this is preferred as it allows Angular to handle changing the style in the way it deems best. We also put all of our changes inside of the write
method of DomController
– writing or reading from the DOM at the incorrect time can cause performance issues, using the DomController
avoids this by batching requests to read or write at the most opportune time. For more information on the DomController
you should read Increasing Performance with Efficient DOM Writes in Ionic.
The hide
and show
methods also just modify the styles of the element the directive is attached to, in such a way that the element will simultaneously shrink and disappear.
3. Use the Directive
Now that we have our directive built, we can use it! Make sure that you add the directive to the module that you want to use it inside of, e.g:
src/app/home/home.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { IonicModule } from '@ionic/angular';
import { FormsModule } from '@angular/forms';
import { RouterModule } from '@angular/router';
import { HomePage } from './home.page';
import { ScrollVanishDirective } from '../../scroll-vanish.directive';
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
RouterModule.forChild([
{
path: '',
component: HomePage,
},
]),
],
declarations: [HomePage, ScrollVanishDirective],
})
export class HomePageModule {}
We are also going to add a few styles to the template we created before to make it look a little nicer:
src/app/home/home.page.scss
ion-header {
background-color: var(--ion-color-primary);
}
ion-segment-button {
color: var(--ion-color-contrast);
}
ion-item {
margin: 10px 0;
}
By setting the ion-header
background to the same colour as the ion-toolbar
the animation will look much smoother as the opacity
is animated to 0 – this way just the contents of the toolbar will seem to disappear rather than the toolbar itself.
We are relying on CSS4 variables to control styles here, if you are not familiar with CSS4 variables and how they are used in Ionic I would recommend reading A Primer on CSS 4 Variables for Ionic 4.
If you serve your application now, hopefully, you should see something like this:
Summary
The end result of what we have done is a directive that can easily be added to an element in your template, even though there is quite a bit of complex logic happening behind the scenes. We’ve also managed to make use of a lot of concepts in this tutorial, so it serves as a good learning example.