DEV Community

Michele Stieven for This is Angular

Posted on

Angular LAB: let's create a Visibility Directive

In this article I'm going to illustrate how to create a very simple Angular Directive that keeps track of an element's visibility state, or in other words, when it goes in and out of the viewport. I hope this will be a nice and perhaps useful exercise!

In order to do this, we're going to use the IntersectionObserver JavaScript API which is available in modern browsers.

What we want to achieve

We want to use the Directive like this:

<p
  visibility
  [visibilityMonitor]="true"
  (visibilityChange)="onVisibilityChange($event)"
>
  I'm being observed! Can you see me yet?
</p>
Enter fullscreen mode Exit fullscreen mode
  • visibility is the selector of our custom directive
  • visibilityMonitor is an optional input which specifies whether or not to keep observing the element (if false, stop monitoring when it enters the viewport)
  • visibilityChange will notifies us

The output will be of this shape:

type VisibilityChange =
  | {
      isVisible: true;
      target: HTMLElement;
    }
  | {
      isVisible: false;
      target: HTMLElement | undefined;
    };
Enter fullscreen mode Exit fullscreen mode

Having an undefined target will mean that the element has been removed from the DOM (for example, by an @if).

Creation of the Directive

Our directive will simply monitor an element, it will not change the DOM structure: it will be an Attribute Directive.

@Directive({
  selector: "[visibility]",
  standalone: true
})
export class VisibilityDirective implements OnInit, OnChanges, AfterViewInit, OnDestroy {
  private element = inject(ElementRef);

  /**
   * Emits after the view is initialized.
   */
  private afterViewInit$ = new Subject<void>();

  /**
   * The IntersectionObserver for this element.
   */
  private observer: IntersectionObserver | undefined;

  /**
   * Last known visibility for this element.
   * Initially, we don't know.
   */
  private isVisible: boolean = undefined;

  /**
   * If false, once the element becomes visible there will be one emission and then nothing.
   * If true, the directive continuously listens to the element and emits whenever it becomes visible or not visible.
   */
  visibilityMonitor = input(false);

  /**
   * Notifies the listener when the element has become visible.
   * If "visibilityMonitor" is true, it continuously notifies the listener when the element goes in/out of view.
   */
  visibilityChange = output<VisibilityChange>();
}
Enter fullscreen mode Exit fullscreen mode

In the code above you see:

  • the input and output we talked about earlier
  • a property called afterViewInit$ (an Observable) which will act as a reactive counterpart to the ngAfterViewInit lifecycle hook
  • a property called observer which will store the IntersectionObserver in charge of monitoring our element
  • a property called isVisibile which will store the last visibility state, in order to avoid re-emitting the same state twice in a row

And naturally, we inject the ElementRef in order to grab the DOM element on which we apply our directive.

Before writing the main method, let's take care of the lifecycle of the directive.

ngOnInit(): void {
  this.reconnectObserver();
}

ngOnChanges(): void {
  this.reconnectObserver();
}

ngAfterViewInit(): void {
  this.afterViewInit$.next();
}

ngOnDestroy(): void {
  // Disconnect and if visibilityMonitor is true, notify the listener
  this.disconnectObserver();
  if (this.visibilityMonitor) {
    this.visibilityChange.emit({
      isVisible: false,
      target: undefined
    });
  }
}

private reconnectObserver(): void {}
private disconnectObserver(): void {}
Enter fullscreen mode Exit fullscreen mode

Now here's what happens:

  • Inside both ngOnInit and ngOnChanges we restart the observer. This is in order to make the directive reactive: if the input changes, the directive will start behaving differently. Notice that, even if ngOnChanges also runs before ngOnInit, we still need ngOnInit because ngOnChanges doesn't run if there are no inputs in the template!
  • When the view is initialized we trigger the Subject, we'll get to this in a few seconds
  • We disconnect our observer when the directive is destroyed in order to avoid memory leaks. Lastly, if the developer asked for it, we notify that the element has been removed from the DOM by emitting an undefined element.

IntersectionObserver

This is the heart of our directive. Our reconnectObserver method will be the one to start observing! It'll be something like this:

private reconnectObserver(): void {
    // Disconnect an existing observer
    this.disconnectObserver();
    // Sets up a new observer
    this.observer = new IntersectionObserver((entries, observer) => {
      entries.forEach(entry => {
        const { isIntersecting: isVisible, target } = entry;
        const hasChangedVisibility = isVisible !== this.isVisible;
        const shouldEmit = isVisible || (!isVisible && this.visibilityMonitor);
        if (hasChangedVisibility && shouldEmit) {
          this.visibilityChange.emit({
            isVisible,
            target: target as HTMLElement
          });
          this.isVisible = isVisible;
        }
        // If visilibilyMonitor is false, once the element is visible we stop.
        if (isVisible && !this.visibilityMonitor) {
          observer.disconnect();
        }
      });
    });
    // Start observing once the view is initialized
    this.afterViewInit$.subscribe(() => {
        this.observer?.observe(this.element.nativeElement);
    });
  }
Enter fullscreen mode Exit fullscreen mode

Trust me, it's not as complicated as it seems! Here's the mechanism:

  • First we disconnect the observer if it was already running
  • We create an IntersectionObserver and define its behavior. The entries will contain the monitored elements, so it will contain our element. The property isIntersecting will indicate if the element's visibility has changed: we compare it to the previous state (our property) and if it's due, we emit. Then we store the new state in our property for later.
  • If visibilityMonitor is false, as soon as the element becomes visible we disconnect the observer: its job is done!
  • Then we have to start the observer by passing our element, so we wait for our view to be initialized in order to do that.

Lastly, let's implement the method which disconnects the observer, easy peasy:

 private disconnectObserver(): void {
    if (this.observer) {
      this.observer.disconnect();
      this.observer = undefined;
    }
  }
Enter fullscreen mode Exit fullscreen mode

Final code

Here's the full directive. This was just an exercise, so be free to change it to whatever you like!

type VisibilityChange =
  | {
      isVisible: true;
      target: HTMLElement;
    }
  | {
      isVisible: false;
      target: HTMLElement | undefined;
    };

@Directive({
  selector: "[visibility]",
  standalone: true
})
export class VisibilityDirective
  implements OnChanges, OnInit, AfterViewInit, OnDestroy {
  private element = inject(ElementRef);

  /**
   * Emits after the view is initialized.
   */
  private afterViewInit$ = new Subject<void>();

  /**
   * The IntersectionObserver for this element.
   */
  private observer: IntersectionObserver | undefined;

  /**
   * Last known visibility for this element.
   * Initially, we don't know.
   */
  private isVisible: boolean = undefined;

  /**
   * If false, once the element becomes visible there will be one emission and then nothing.
   * If true, the directive continuously listens to the element and emits whenever it becomes visible or not visible.
   */
  visibilityMonitor = input(false);

  /**
   * Notifies the listener when the element has become visible.
   * If "visibilityMonitor" is true, it continuously notifies the listener when the element goes in/out of view.
   */
  visibilityChange = output<VisibilityChange>();

  ngOnInit(): void {
    this.reconnectObserver();
  }

  ngOnChanges(): void {
    this.reconnectObserver();
  }

  ngAfterViewInit(): void {
    this.afterViewInit$.next(true);
  }

  ngOnDestroy(): void {
    // Disconnect and if visibilityMonitor is true, notify the listener
    this.disconnectObserver();
    if (this.visibilityMonitor) {
      this.visibilityChange.emit({
        isVisible: false,
        target: undefined
      });
    }
  }

  private reconnectObserver(): void {
    // Disconnect an existing observer
    this.disconnectObserver();
    // Sets up a new observer
    this.observer = new IntersectionObserver((entries, observer) => {
      entries.forEach(entry => {
        const { isIntersecting: isVisible, target } = entry;
        const hasChangedVisibility = isVisible !== this.isVisible;
        const shouldEmit = isVisible || (!isVisible && this.visibilityMonitor);
        if (hasChangedVisibility && shouldEmit) {
          this.visibilityChange.emit({
            isVisible,
            target: target as HTMLElement
          });
          this.isVisible = isVisible;
        }
        // If visilibilyMonitor is false, once the element is visible we stop.
        if (isVisible && !this.visibilityMonitor) {
          observer.disconnect();
        }
      });
    });
    // Start observing once the view is initialized
    this.afterViewInit$.subscribe(() => {
        this.observer?.observe(this.element.nativeElement);
    });
  }

  private disconnectObserver(): void {
    if (this.observer) {
      this.observer.disconnect();
      this.observer = undefined;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
jangelodev profile image
João Angelo

Hi Michele Stieven ,
Thanks for sharing.