Tutorial hero
Lesson icon

Creating a Custom Scroll Vanish Directive with Ionic Web Components

Originally published July 11, 2018 Time 12 mins

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:

Screenshot of Twitter Header

The top “toolbar” will animate off of the screen, leaving only the tab bar:

Screenshot of Twitter header with searchbar collapsed

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:

Scroll to vanish directive in Ionic

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:

Scroll to vanish directive in Ionic

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.

Learn to build modern Angular apps with my course