Sekrab Garage

Intersection observer directive to add and remove classes in Angular

Angular July 19, 23
Subscribe to Sekrab Parts newsletter.
Series:

Intersection observer directives

In this series we want to create a directive, or multiple directives in Angular to handle different features that employ the intersection observer, mainly, adapting classes of DOM elements, lazy loading images, and emitting events.
  1. one year ago
    Intersection observer directive to add and remove classes in Angular
  2. one year ago
    Lazy loading images upon intersection in Angular
  3. one year ago
    Intersection events and loose ends

First, the basics, this directive needs to return if on SSR.

I borrowed the definition of isBrowser from Angular CDK platform file, like 37.

The main layout of our directive looks like this. I named it crUiio because io was wrong, and iobs was even more wrong!

The final code can be found on StackBlitz.
// cruiio directive
@Directive({
    selector: '[crUiio]',
    standalone: true
})
export class UiioDirective implements AfterViewInit {
  @Input() crUiio: string;
  
  // at least ElementRef, platform, and renderer
  constructor(private el: ElementRef, 
    @Inject(PLATFORM_ID) private _platformId: Object, 
    private renderer: Renderer2) {
  }
  // this is a nice property copied from Angular CDK
  isBrowser: boolean = this._platformId 
    ? isPlatformBrowser(this._platformId)
    : typeof document === 'object' && !!document;
	
  ngAfterViewInit() {
      // if on server, do not show image
      if (!this.isBrowser) {
        return;
      }
      
      // if intersection observer is not supported
      if (!IntersectionObserver) {
        // do nothing
        return;
      }

      const io = new IntersectionObserver(
        (entries, observer) => {
          // there is only one entry per directive application
          entries.forEach((entry) => {
            if (entry.isIntersecting) {
               // in view

            } else {
               // out of view
            }
          });
        }
      );
			
      // observe the native element
      io.observe(this.el.nativeElement);
  }
}

According to this excellent blog post on the possible performance drag of multiple observers, versus one per document, it looks to me that having one observer per element, for less than 1000 elements, should be fine. Let us try both ways and choose the more convenient.

anchorOne observer per all window

Where shall we save this global intersection observer? I shall save in the window global scope. To reference the window I’ll create a const with type any so that VSCode would stop nagging me about type.

// keep on observer on window

const _global: any = window;

@Directive({ ... })
export class UiioDirective implements AfterViewInit {
 //...
   private getIo() {
    if (_global['crUiio']) {
      return _global['crUiio'];
    } else {
      _global['crUiio'] = new IntersectionObserver((entries, observer) => {
        // this call back is called once
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            // in view, check which behavior to carry out
          } else {
            // out of view
          }
        });
      });
      return _global['crUiio'];
    }
  }
  ngAfterViewInit() {
	  // ...
    const io = this.getIo();
    io.observe(this.el.nativeElement);
  }
}

Since we want to use this directive to do other things as well, let’s create a property that identifies the behavior. The obvious choice is the directive immediate value. It is a string, but we can change type to specific strings.

@Input() crUtil: 'lazy'|'class'|'load' //... etc;

We’ll also create a private method for each, and let’s adapt the main caller to switch between different behaviors

// adapt getIo
_global['crUiio'] = new IntersectionObserver((entries, observer) => {
 entries.forEach((entry) => {
    switch (this.crUtil) {
      case 'lazy':
        this.lazyLoad(entry, observer);
        break;
      case 'load':
        // do something...
        break;
      case 'class':
      default:
        this.classChange(entry, observer);
        break;
    }
 });
});

Okay, let’s implement the class adapter.

anchorAdapting css classes

The behavior to add a class to the element, or remove a class. We want to have both options:

  • On intersection, add class, on out of view remove class.
  • Or on intersection remove class, on out of view add class.

The most flexible way is to just be explicit with the classes to add, or remove, like this:

// example usage
<div crUiio="class" inview="a b" outofview="c d"></div>

In addition to that, (I speak from experience,) we want to target a higher up container with the class list, our best option here is the body. I choose to be as flexible as I can, not because I want to open up for future requirements that may or may not occur, I have passed that level of arrogance, but because I have been bitten by this too many times.

// example use
<div crUiio="class" inview="a b" inviewbody="x y" ></div>

Now we have all the ingredients we need, let’s cook.

// directive
// that's a whole lot of inputs and statements
private classChange(entry: IntersectionObserverEntry, observer: IntersectionObserver) {
  if (entry.isIntersecting) {
    // in view, separate then add tokens
    document.body.classList.add(this.inviewbody);
    entry.target.classList.add(this.inView);
    document.body.classList.remove(this.outofViewBody);
    entry.target.classList.remove(this.outofView);

  } else {
    // out of view
    document.body.classList.remove(this.inViewBody);
    entry.target.classList.remove(this.inView);
    document.body.classList.add(this.outofViewBody);
    entry.target.classList.add(this.outofView);
  }
}

Let’s test this thus far before we go on. Find an example on StackBlitz, a very simple page with ugly boxes, we want to validate that the body and boxes get the right classes when requested. Here are the issues that need fixing:

  • We need to process the class list before we use it, we need to separate into multiple strings, and remove empty strings, since classList.add(’’) would fail, but classList.add(null) won’t.
  • We need to figure out a way to pass those values through the entry target, since we have one shared observer, that kicks in a callback with the first directive used

Class prepping: We’ll put them all in one object, and prepare them on AfterViewInit. Assuming they will not change after initialization. I have yet to run into a requirement where I don’t know my classes ahead of time, but if you run into it, you probably need to adapt this with a public method that changes the values.

We will also assign the default value to empty strings, no harm in that. One short statement to remove empty strings, and the prep method looks like this

// directive

private _allClasses: any;

private prepClasses(): any {
  // split at spaces and remove empty ones
  const clean = (str: string) => str!.split(' ').filter((n) => n !== '');

  return {
    inView: clean(this.inview),
    outofView: clean(this.outofview),
    inViewBody: clean(this.inviewbody),
    outofViewBody: clean(this.outofviewbody),
  };
}
// ...
ngAfterViewInit() {
  // ...
  // prep classes
  this._allClasses = this.prepClasses();
}

Now the class change function will go through them, expend the classes as required like this

// use by expand
document.body.classList.add(...this._allClasses.inViewBody)

Callback isolation: Since the changeClass function is a callback to the globally setup intersectionObserver, the classes passed are those that belong to the first directive. That’s not cool. Instead, we shall load the nativeElement that comes in Angular ElementRef with its own classes, to be reused with entry.target.

anchorLoading information in HTML data attributes programmatically

I kind of miss good old jQuery. All we had to do back then is $(element).data(json) and magic! We can’t do that straight out of the box in JavaScript. We can use the dataset property, which will need to be JSON stringified, then JSON parsed. There is an easier way however. We can set the property directly to the HTML element, like this

this.el.nativeElement['newprop'] = {...this._allClasses};

And read it like this

entry.target['newprop'];

This feels like a cheat! But I know JavaScript, and DOM, they are really that simple to deal with. I’m going to stick to that until it whines.

So after view initialization we add the classes, and when observation is caught we retrieve them:

private classChange(entry: IntersectionObserverEntry,observer: IntersectionObserver) {
  // load from props, cast to magical "any"
  const c = (<any>entry).target['data-classes'];

  if (entry.isIntersecting) {
    // in view
    document.body.classList.add(...c.inViewBody);
    // ... the rest
  }
}
ngAfterViewInit() {
  //...

  // prep classes
  this._allClasses = this.prepClasses();

  // load classes to element
  this.el.nativeElement['data-classes'] = { ...this._allClasses };

  // observe the native element
  const io = this.getIo();
  io.observe(this.el.nativeElement);
}

anchorThreshold

Of all intersection options available root, rootMargin, and threshold, only threshold is the one that might make a difference in a globally applied directive. From my experience, I only ever needed two values: 0, or 1. But it would be nice to be more specific. It would be an overkill to pass an array, so we shall not.

Side note: having the rootMargin set to something, that usually helps in catching intersections with edge components, like the footer stuck to the bottom of the body. But with threshold, we can overcome the issue. Thus, no need to use rootMargin.

This, however, is where we part ways with a single observer per page. The threshold property is initialized, and is read only.

anchorOne observer per element

In order to pass the threshold, we need to change our code so that the observer is unique to every directive. As we said above, the performance gain of having a single observer is not huge. The simplicity of the code of multiple observers is, however, astounding. But first, since we no longer have one observer, there is no point in making our directive work for all behaviors, we shall have multiple; one for class adaption, one for lazy loading, and one for the house! Etc.

We no longer need to go through the list of intersection elements, the first one is good enough: entries[0].

// invew.directive 
// change intersection observer instance
const io = new IntersectionObserver(
  (entries, observer) => {
    this.classChange(entries[0], observer);
  },
  {
    threshold: _todoprop,
  }
);
io.observe(this.el.nativeElement);

We also no longer have to pass the classes in data-classes attribute, the private member is good enough.

anchorRefactor inputs

It is nice to place every input in its own in property, but in the wild, what are the odds of passing all 4 classes? What are the odds of changing the threshold? Maybe we can live with this:

<div crInview="A B" ></div>

Let’s make our inputs a bit neater, and let's name the directive to be used with the most common incident: in-view.

// inview.directive 
// refactor

@Input() options: {
  outofview?: string,
  inviewbody?: string,
  outofviewbody?: string,
  threshold?: number;
} = {
  // have defaults
  outofview: '',
  inviewbody: '',
  outofviewbody: '',
  threshold: 0
};

private prepClasses(): any {
  
  return {
    inView: clean(this.crInview), // this is the main string
    outofView: clean(this.options?.outofview),
    inViewBody: clean(this.options?.inviewbody),
    outofViewBody: clean(this.options?.outofviewbody),
  };
}

A little bit of extra typing to make sure we don’t flip, and the directive is ready to serve.

anchorStop observing

A last bit that might come in handy is to allow the directive to stop observing when intersection occurs. Here is a new option property: once.

// directive new property
@Input() options: {
  // ...
  once?: boolean
} = {
  //...
  once: false
};

// stop observing after first intersection
if (entry.isIntersecting) {
  // ...
  if(this.options.once) {
    observer.unobserve(entry.target);
  }
} else {
  // out of view
  // ...
}

We might as well disconnect the whole intersection observer since we are watching one single entry, but what if we want to resume observation? This is a nice feature we'll keep for one fine Tuesday.

With this, we have enough to create a lazy loading effect using css background images.

<div class="footer-bg" crInview="footer-bg-inview" 
	[options]="{threshold: 0.5, once: true}">
	here is the footer with initial image that changes to new image when in view
	then stops observing
</div>

The main class would have the small image, and the footer-bg-inview class would have the full image. Background images are usually cosmetic, and SSR does not need to know about them. So this shall work. But, we can be more intentful about images. Let's dig deeper and create a different directive for lazy loading images, next Tuesday inshallah. 😴

Did you catch the hint of iobs?

  1. one year ago
    Intersection observer directive to add and remove classes in Angular
  2. one year ago
    Lazy loading images upon intersection in Angular
  3. one year ago
    Intersection events and loose ends