Logo

dev-resources.site

for different kinds of informations.

Angular LAB: let's create a Visibility Directive

Published at
10/9/2024
Categories
angular
rxjs
javascript
Author
michelestieven
Categories
3 categories in total
angular
open
rxjs
open
javascript
open
Author
14 person written this
michelestieven
open
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
rxjs Article's
30 articles in total
Favicon
rxjs
Favicon
Angular Signals and Their Benefits
Favicon
Migrando subscribe Callbacks para subscribe arguments no RxJS
Favicon
New in Angular: Bridging RxJS and Signals with toSignal!
Favicon
RxSignals: The Most Powerful Synergy in the History of Angular 🚀
Favicon
Virtually Infinite Scrolling with Angular
Favicon
Building a Collapsible UI Component in Angular: From Concept to Implementation 🚀
Favicon
Disabling button on api calls in Angular
Favicon
Angular Dependency Injection — Inject service inside custom Rxjs operators
Favicon
Unsubscribe Observable! why is it so important?
Favicon
Harnessing the Power of RxJS with React for Asynchronous Operations
Favicon
DOM Observables, Rx7, Rx8 and the observable that doesn't exist yet
Favicon
Advanced RxJs Operators You Know But Not Well Enough pt 2.
Favicon
Observables in Node.js: Transforming Asynchronous Chaos into Elegant Code
Favicon
Reusable, Extensible and Testable State Logic with Reactive Programming.
Favicon
Understanding RxJS and Observables in Angular: A Beginner-Friendly Guide
Favicon
Are Angular Resolvers on Life Support ?
Favicon
Creating Custom rxResource API With Observables
Favicon
Forestry: The Best State System for React and (not React)
Favicon
📢 Announcing the New Reactive State Management Libraries! 🎉
Favicon
Reactables: Reactive State Management for any UI Framework.
Favicon
Hot vs Cold Observables in Angular: Understanding the Difference
Favicon
RxJS Simplified with Reactables
Favicon
How I structure my Angular components with Signals
Favicon
Angular LAB: let's create a Visibility Directive
Favicon
Difference between mergeMap vs switchMap vs concatMap vs exhaustMap
Favicon
How Signals Improve Your App Performance
Favicon
The Article I Took So Long to Write: Concepts about Reactive Programming and RxJS
Favicon
Tame Your Asynchronous World with RxJS
Favicon
Custom RxJS Operators to Improve your Angular Apps

Featured ones: