Picture of Brian Love wearing black against a dark wall in Portland, OR.

Brian Love

Angular 2 window scroll event using @HostListener

Angular 2 is awesome, but it also a major departure from Angular 1. One of these changes is the $window service is not in Angular 2. I was playing around with Materialize and wanted to toggle if the navbar was fixed vs. relative based on the offset of the body. My old-school event-driven approach kicked in, and I wanted to inject the window object into my component so I could detect when the window is scrolled.

I was able to create a service that would inject the browser’s native window object, but I also want to keep my code from being browser-dependent so that I can easily run tests against it. So, I ditched the idea of injecting the window object after learning about the @HostListener decorator in Angular.

The @HostListener Decorator

I couldn’t find too much information about the @HostListener decorator in the docs, only the interface specification in the API. But, what I was able to learn via other blogs and questions on stack overflow is that the HostListener enables us to listen for events on the host, and to specify the values that are passed as arguments to the decorated function or class.

In this example I want to listen for the window’s scroll event. Here is the simple markup for this:

import { HostListener} from "@angular/core";

@HostListener("window:scroll", [])
onWindowScroll() {
 //we'll do some stuff here when the window is scrolled
}

My primary concern at this point is, have I created a dependency on the browser’s window object? I don’t know the answer to that. If and when I do find out, I’ll be sure to post an update. But, this was a lot easier than injecting the window from a service.

The onWindowScroll() method in my component’s class will now be triggered when the window is scrolled.

The next thing I wanted to do is to determine the body’s offset. This way I could fade-in a fixed navbar when the user had scrolled into the page, say 100 pixels.

Inject Document object

In order to determine the body’s scrollTop value we need to inject the Document object. To do this, Angular 2 has provided a DOCUMENT dependency injection (DI) token to get the application’s rendering context, and when rendering in the browser, this is the browser’s document object.

import { Inject } from "@angular/core";
import { DOCUMENT } from "@angular/platform-browser";

export class LayoutNavComponent implements OnInit {
  constructor(@Inject(DOCUMENT) private document: Document) { }
}

First, I import the Inject decorator as well as the DOCUMENT DI token. Then, in my component’s constructor function I can inject the Document object. Now that I have the document, I can use this to easily determine the scrollTop value in my onWindowScrolled() method.

Window Service

Just like with the document object, it is best practice in Angular to not reference the DOM directly, including the Window object. Here is a simple service for injecting the Window object into our component:

import { isPlatformBrowser } from "@angular/common";
import { ClassProvider, FactoryProvider, InjectionToken, PLATFORM_ID } from '@angular/core';

/* Create a new injection token for injecting the window into a component. */
export const WINDOW = new InjectionToken('WindowToken');

/* Define abstract class for obtaining reference to the global window object. */
export abstract class WindowRef {

  get nativeWindow(): Window | Object {
    throw new Error('Not implemented.');
  }

}

/* Define class that implements the abstract class and returns the native window object. */
export class BrowserWindowRef extends WindowRef {

  constructor() {
    super();
  }

  get nativeWindow(): Window | Object {
    return window;
  }

}

/* Create an factory function that returns the native window object. */
export function windowFactory(browserWindowRef: BrowserWindowRef, platformId: Object): Window | Object {
  if (isPlatformBrowser(platformId)) {
    return browserWindowRef.nativeWindow;
  }
  return new Object();
}

/* Create a injectable provider for the WindowRef token that uses the BrowserWindowRef class. */
export const browserWindowProvider: ClassProvider = {
  provide: WindowRef,
  useClass: BrowserWindowRef
};

/* Create an injectable provider that uses the windowFactory function for returning the native window object. */
export const windowProvider: FactoryProvider = {
  provide: WINDOW,
  useFactory: windowFactory,
  deps: [ WindowRef, PLATFORM_ID ]
};

/* Create an array of providers. */
export const WINDOW_PROVIDERS = [
  browserWindowProvider,
  windowProvider
];

Note, you must provider the WINDOW_PROVIDERS in the app.module.ts decorator’s providers array:

import { WINDOW_PROVIDERS } from './window.service';

@NgModule({
  imports: [BrowserModule],
  declarations: [App],
  bootstrap: [App],
  providers: [WINDOW_PROVIDERS],
})
export class AppModule {}

Then, using this service, we can inject the browser’s native Window object into our component, for example:

import { Inject } from "@angular/core";
import { DOCUMENT } from "@angular/platform-browser";
import { WINDOW } from "./window.service";

export class LayoutNavComponent implements OnInit {
  constructor(
    @Inject(DOCUMENT) private document: Document,
    @Inject(WINDOW) private window: Window
  ) { }
}

Component

Here is what my component looks like:

import { Component, HostListener, Inject, OnInit } from "@angular/core";
import { DOCUMENT } from '@angular/platform-browser';
import { WINDOW } from "./window.service";

@Component({
  selector: "app-layout-nav",
  templateUrl: "./layout-nav.component.html",
  styleUrls: ["./layout-nav.component.scss"]
})
export class LayoutNavComponent implements OnInit {

  public navIsFixed: boolean = false;

  constructor(
    @Inject(DOCUMENT) private document: Document
    @Inject(WINDOW) private window
  ) { }

  ngOnInit() { }

  @HostListener("window:scroll", [])
  onWindowScroll() {
    let number = this.window.pageYOffset || this.document.documentElement.scrollTop || this.document.body.scrollTop || 0;
    if (number > 100) {
      this.navIsFixed = true;
    } else if (this.navIsFixed && number < 10) {
      this.navIsFixed = false;
    }
  }
}

Using the navIsFixed boolean value I can easily update a class named fixed that I am applying to an element that wraps my <nav> element:

<div class="nav-container" [class.fixed]="navIsFixed">
  <nav role="navigation">...</nav>
</div>

Stackblitz

Here is an example of using the @HostListener decorator using Angular 6: