Scroll Vanish Directive Built in Ionic/Angular

Creating a Custom Scroll Vanish Directive with Ionic Web Components



·

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.0.0-alpha.9

Before you go through this tutorial, you should have a basic understanding of Ionic concepts and be comfortable working with an Ionic/Angular project.

If you’re not familiar with Ionic already, I’d recommend reading my Ionic Beginners Guide first to get up and running and understand the basic concepts. If you want a much more detailed guide for learning Ionic, take a look at my book Building Mobile Apps with Ionic & Angular.

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/pages/home/home.page.ts 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.

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 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 scroll-vanish.directive.ts to reflect the following:

import { Directive, Input, ElementRef, Renderer2, OnInit } from '@angular/core';
import { Observable, fromEvent } from 'rxjs';
import { DomController } from '@ionic/angular';

@Directive({
  selector: '[myScrollVanish]'
})
export class ScrollVanishDirective implements OnInit {

  @Input('myScrollVanish') scrollArea;

  private scrollElement;
  private scrollObservable: Observable<CustomEvent>;
  private hidden: boolean = false;
  private triggerDistance: number = 20;

  constructor(private element: ElementRef, private renderer: Renderer2, private domCtrl: DomController) { 

  }

  ngOnInit(){

    // Wait until 'ion-scroll' element is added to 'ion-content'
    let mutationObserver = new MutationObserver(() => {

      this.scrollElement = this.scrollArea.getScrollElement();

      if(this.scrollElement !== null){

        this.initStyles();

        this.scrollObservable = fromEvent(this.scrollElement, 'ionScroll');

        this.scrollObservable.subscribe((scrollEvent: CustomEvent) => {

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

        });

      }

    });

    mutationObserver.observe(this.scrollArea.shadowRoot, {
      childList: true
    });

  }

  initStyles(){

    this.domCtrl.write(() => {
      this.renderer.setStyle(this.element.nativeElement, 'transition', '0.3s linear');
    });

  }

  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 { Observable, fromEvent } from 'rxjs';
import { DomController } from '@ionic/angular';

@Directive({
  selector: '[myScrollVanish]'
})
export class ScrollVanishDirective implements OnInit {

  @Input('myScrollVanish') scrollArea;

  private scrollElement;
  private scrollObservable: Observable<CustomEvent>;
  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 importing Observable and fromEvent because Ionic web components emit custom DOM events, but since we are working in an Angular environment it will be nicer to work with if we convert that DOM event into an observable. 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. We have scrollElement that will hold a reference to the ion-scroll element that is inside of ion-content (it is ion-scroll that actually emits the scroll events). The scrollObservable will hold the observable we set up for the event, hidden 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(){

    // Wait until 'ion-scroll' element is added to 'ion-content'
    let mutationObserver = new MutationObserver(() => {

      this.scrollElement = this.scrollArea.getScrollElement();

      if(this.scrollElement !== null){

        this.initStyles();

        this.scrollObservable = fromEvent(this.scrollElement, 'ionScroll');

        this.scrollObservable.subscribe((scrollEvent: CustomEvent) => {

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

        });

      }

    });

    mutationObserver.observe(this.scrollArea.shadowRoot, {
      childList: true
    });

  }

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 need to get the “scroll element” (i.e. ion-scroll) from the ion-content area that we passed in. We do that like this:

this.scrollElement = this.scrollArea.getScrollElement();

However, the scroll element is not immediately available and so it will initially be undefined. To make sure we only call getScrollElement once the scroll element is available, we use a mutation observer. If you would like to learn more about mutation observers I would recommend reading Automatic Scroll to Bottom Chat Interface with Mutation Observers in Ionic. In short, a mutation observer will trigger when it detects a change in the DOM structure (which will occur when the ion-scroll component is added).

To set up the mutation observer, we supply it with the element that we want to watch for changes:

    mutationObserver.observe(this.scrollArea.shadowRoot, {
      childList: true
    });

Notice that we don’t just supply this.scrollArea, we supply this.scrollArea.shadowRoot. Usually, you would not do this, but since Ionic uses Shadow DOM for its components (which is basically its own little DOM structure isolated from the rest of the DOM) we need to make sure we observe the shadowRoot for changes. We also set childList to true because we want to react to changes to the components children.

Once we have our reference to the scrollElement we call the initStyles method to set up the styles, we create an observable of the ionScroll DOM event that the ion-scroll component emits, and then we subscribe to that observable 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.3s linear');
    });

  }

  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:

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:

home.page.css

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.

What to watch next...