Home Articles

Combine the power of Angular services and directives to detect screen size changes

May 8, 2020 ∘ Yoko Ishioka
Twins riding uphill on a bike

I've read a lot about how awesome Angular services can be (and they're right) and have seen a few on directives (there should be way more), but what has really been exciting me lately is how to combine the two!

Problem to solve

Detect when the screen size changes from mobile, tablet or desktop to automatically toggle between a navigation menu and hamburger menu.

Solution requirements

  1. Request and receive the values from anywhere in my Angular workspace without duplicating code or worrying about dependencies.
  2. Request the values from only the components who care about them.
  3. Receive the values as either numbers or device sizes, such as small, medium and large.
  4. Receive the values only if they change.

Why not just use a service?

To separate concerns and to be reusable, I created a toggle menu component that doesn't care what content is inserted and is used by a menu component that only cares about creating the content. If I were to just use a service, both components would have to listen to the service even when the service wasn't being used and the service would have to care about which state the component is in rather than just focusing on passing values.

Why use a directive?

Think of directives as being able to supplement your components without having to create another component to do it. It's like having a phantom component that can do everything a component can do but without requiring an actual instance of itself. By not including the HostListener in the service, only the element that has the directive will be listening for screen size changes.

Role breakdown

Directive: Listens for when the screen width changes and sends those values to the service.

Service: Receives values from anyone who uses it, categorizes the number by screen size (if requested) and sends values to whoever subscribes to them.

Component: Tells the directive when to start listening for the changes. Subscribes to the service to get back the values, specifying it only cares when the values change. Determines when to stop sending values.

The Directive

Attach the selector to the element you want to listen for window events. You'll want to include a HostListener for when the window first gets loaded and then also one if the screen gets resized. Then, send the values to the service.

device-screen-size.directive.ts

import { DevicesService } from './devices.service';

@Directive({
selector: '[cesDeviceScreenSize]'
})
export class DeviceScreenSizeDirective {

constructor(
  private devices: DevicesService
) { }

@HostListener('window:load', ['$event']) onLoad(event) {
  this.sendSize(event.currentTarget.innerWidth);
}

@HostListener('window:resize', ['$event']) onResize(event) {
  this.sendSize(event.currentTarget.innerWidth);
}

sendSize(width: number) {
  this.devices.setSize(width);
}
}

The Service

Inside of your service, you'll want to use an input that will take in the values and pass those values to a subject so that anyone can subscribe to them, even if they aren't using the directive.

devices.service.ts

import { Injectable, Input } from '@angular/core';
import { Subject, Observable } from 'rxjs';
import { ScreenSize, DetectType } from './devices';

@Injectable({
  providedIn: 'root'
})

export class DevicesService {
  screenSize: Subject<any> = new Subject();
  @Input() detectSize: DetectType;

  constructor() { }

  setSize(width: number): Observable<any> {
    if (this.detectSize === 'number') {
      this.screenSize.next(width);
    }

    else {
      if (width < ScreenSize.medium) {
        this.screenSize.next('small');
      }

      else if (width > ScreenSize.medium) {
        this.screenSize.next('medium');
      }

      if (width > ScreenSize.large) {
        this.screenSize.next('large');
        }
      }

  return this.screenSize;
  }
}

The Component

For my use case, I created a menu toggle component that needs to detect when the screen size changes so that it knows whether or not to display the hamburger icon instead of the full navigation menu. I specified to the service that I want to listen for device sizes rather than the screen size width because I only care when the device changes.

I recently discovered the distinctUntilChanged RxJS operator that will only pass the value if it's changed! How cool is that?? No more useless values clogging up my stream.

I don't have to listen for when the device is large because nothing changes between medium and large. And medium always comes in between small and large.

Don't forget to unsubscribe from the subject to avoid memory leaks.

toggle-menu.component.ts

import { Component, OnInit, Input, Output, EventEmitter, OnDestroy } from '@angular/core';
import { DevicesService } from 'projects/devices/src/public-api';
import { distinctUntilChanged } from 'rxjs/operators';
import { Subscription } from 'rxjs';

@Component({
  selector: 'ces-menu-toggle',
  templateUrl: './menu-toggle.component.html',
  styleUrls: ['./menu-toggle.component.scss']
})

export class MenuToggleComponent implements OnInit, OnDestroy {
  @Input() showMenuButton: boolean = false;
  @Input() showMenuContent: boolean = false;
  @Input() menuTitle: string = "menu";
  allowToggle: boolean;
  sub: Subscription;

  constructor(
    private devices: DevicesService
  ) { }

  ngOnInit() {
    this.devices.detectSize = "type";
    this.sub =      this.devices.screenSize.pipe(distinctUntilChanged()).subscribe(size => {
    this.checkSize(size);
  })
 }

  ngOnDestroy() {
    this.sub.unsubscribe();
  }

  closeMenu() {
    this.showMenuButton = true;
    this.showMenuContent = false;
  }
  
  openMenu() {
    this.showMenuButton = false;
    this.showMenuContent = true;
  }

  checkSize(size: string) {
    if (size === 'medium') {
      this.allowToggle = false;
      this.openMenu();
    }

    else if (size === 'small') {
      this.allowToggle = true;
      this.closeMenu();
    }
  }

  toggleNav() {
    if (this.allowToggle) {
      this.showMenuButton = !this.showMenuButton;
      this.showMenuContent = !this.showMenuContent;
     }  
  }
}

I also posted the code to GitHub Gist for better readability. You can view the menu in action on my blog's website. (Expand and shrink the window after you click the link.) Thanks for reading!