DEV Community

Cover image for Intersection events and loose ends
Ayyash
Ayyash

Posted on • Originally published at garage.sekrab.com

Intersection events and loose ends

We used intersection observer to add classes and lazy load images. Here are some final touches that may enhance those features.

Add output events

We already have a good directive to add classes, and if no classes are passed it still acts well. Let's add a couple of events to further its use.

// inview.directive
// Enhance with output events

@Output() onInview = new EventEmitter<void>();
@Output() onOutofview = new EventEmitter<void>();

// then emit in the proper places
private classChange(
   //...
  ) {
    const c = this.viewClasses;

    if (entry.isIntersecting) {
        //...
      this.onInview.emit();

      if (this.options.once) {
        observer.disconnect();
      }

    } else {
      //...
      this.onOutofview.emit();

    }
  }
)
Enter fullscreen mode Exit fullscreen mode

It can be used as following

<span crInview onIniew="callMeInView()" onOutofview="callMeOutofView()">something</span>
Enter fullscreen mode Exit fullscreen mode

Enhancement 2: Exposure

Another enhancement we can afford is to expose the observe and unobserve functions. For this to work we need to promote a local member for the intersection observer (io).

// add local member
private io: IntersectionObserver;

// set and use this.io instead of observer

// add a couple of functions

observe() {
  this.io.observe(this.el.nativeElement);
}
unobserve() {
  this.io.unobserve(this.el.nativeElement);
}

// inside class change, we gotta swap disconnect with unobserve
private classChange(...){
  // ...
  if (this.options.once) {
    this.unobserve();
  }
}
Enter fullscreen mode Exit fullscreen mode

Note: I have used entry.target interchangeably with this.el.nativeElement, truth is, since we have a single observer per directive, they are the same, we only need entry for the extra properties provided like isIntersecting.

We need to export the directive to be able to use those methods.

// inview.directive

@Directive({
  selector: '[crLazy]',
  standalone: true,
  // export to use
  exportAs: 'crLazy',
})
Enter fullscreen mode Exit fullscreen mode

To use, in our template:

<!--a box that is being observed-->
 <div
  crInview
  #bluebox="crInview"
>
  ...content
</div>

 Stop observing the box below, then start again <br />
<button (click)="bluebox.unobserve()">Stop observing</button>
<button (click)="bluebox.observe()">Start observing</button>
Enter fullscreen mode Exit fullscreen mode

This looks thorough. Too thorough. I might never use this feature in my whole life. We'll stop here before it gets too slimy.

Bug: dynamic images change undetected

When an image's source changes dynamically, we need a way to restart observation. Bummer. Let's fix that. There are two ways to do that, one is the setter of the main string (crLazy). This involves another private variable to keep track of, but I am not going into that direction when I have OnChanges lifecycle hook.

// lazy.directive
export class LazyDirective implements AfterViewInit, OnChanges {

  // add OnChanges event handler
  ngOnChanges(c: SimpleChanges) {

    if (c.crLazy.firstChange) {
      // act normally
      return;
    }

    if (c.crLazy.currentValue !== c.crLazy.previousValue) {
      // start observing again
      this.io.observe(this.el.nativeElement);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

For this to work, we need to promote the intersection observer to be a private member (io), and we can only use unobserve, and never disconnect.

// lazy.directive

// promote the io
private io: IntersectionObserver;

// then use this.io intead
ngAfterViewInit() {

 this.io = new IntersectionObserver(
   // ...
 );
 this.io.observe(this.el.nativeElement);
}

// then ngOnchanges
Enter fullscreen mode Exit fullscreen mode

In StackBlitz lazy component, test that by clicking on the "change image" button. The image should change.

We could have also exposed the observe and unobserve methods, and let the consuming component handle it, or added a live option for some images. But that is not always practical. The image could be sitting in a highly reusable component like a product card.

Bonus: fade in effect

Here is a nice effect to add to let the background image fade in when ready. This effect is 100% external to the directive. This is how we know we built a flexible directive.

/*add style to fade in an image when ready*/
.hero {
  background: no-repeat center center;
  background-size: cover;
  background-color: rgba(0, 0, 0, 0.35);
  background-blend-mode: overlay;
  transition: background-color 0.5s ease-in-out;

  /*unessential*/
  color: #fff;
  min-height: 40dvh;
  display: flex;
  align-items: center;
  justify-content: center;
  /* image set by code */
}
.hero-null {
  background-color: black;
}
Enter fullscreen mode Exit fullscreen mode

Then just use the directive with null fall back

<div
  class="hero"
  crLazy="largeimage.png"
  [options]="{ nullCss: 'hero-null' }"
>
Text on image
</div>
Enter fullscreen mode Exit fullscreen mode

Have a look in StackBlitz lazy component.

Enhancement #3: Destroy

There is one cheap enhancement we should have thought about earlier, and that is to dispose the observer when the host element is destroyed.

// add onDestroy event handler for both directives
export class LazyDirective implements AfterViewInit, OnChanges, OnDestroy {
  // ...
   ngOnDestroy() {
    this.io?.disconnect();
    this.io = null;
  }
}
Enter fullscreen mode Exit fullscreen mode

Revisit a single observer

I'm not quite keen on creating solutions for probable problems, but having a single intersection observer per page is still eating at me. Placing the observer on the window level and passing the arguments in the target was okay. But I wanted to investigate a different approach, where we are aware of the observers. Where better to do that than a root service?

// experimental lazy/service

@Injectable({ providedIn: 'root' })
export class LazyService {
  obs: { [key: string]: IntersectionObserver } = {};

  newOb(cb: Function, id: string, threshold: number = 0) {
    // create a new observer, if no id is passed, consider as unique

    if (id && this.obs[id]) {
      return this.obs[id];
    }
    const io = new IntersectionObserver(
      (entries, observer) => {
        entries.forEach((entry) => cb(entry));
      },
      {
        threshold: threshold,
      }
    );
    // save observer to reuse
    if (id) {
      this.obs[id] = io;
    }
    return io;
  }
}
Enter fullscreen mode Exit fullscreen mode

To use that, we only need to do the following

  • Add id to options
  • If id was passed, we return an existing observer previously created, or create new one and save it. Else we return the new observer without saving it.
  • We need to make sure the nativeElement is not used in the callback, thus the image source passed, must be saved as an attribute of the element, so that we can recall it safely upon intersection.
  • We also need to set options as a property of the element (cannot set attribute to a json object).
  • With this approach, we cannot disconnect. But since the observer is saved on root, it is reused across multiple routes
// expiremental lazy/directive

// change this to have the target explicitly passed
private setImage(src: string, target: HTMLElement) {
  if (target.tagName === 'IMG') {
    this.renderer.setAttribute(target, 'src', src);
  } else {
    this.renderer.setAttribute(target, 'style', `background-image: url(${src})`);
  }
}

// change this to pass the target
private lazyLoad(entry: IntersectionObserverEntry) {
  // ...
   if (entry.isIntersecting) {
    // if IMG, change src
    const img = new Image();
    // get options saved
    const options = entry.target['options'];

    img.addEventListener('load', () => {
      // pass target explicitly
      this.setImage(img.src, <HTMLElement>entry.target);
      // use options instead of this.options
      this.renderer.removeClass(entry.target, options.nullCss);
      // disconnect
      this.io.unobserve(entry.target);
    });
    if (options.fallBack) {
      img.addEventListener('error', () => {
        this.setImage(options.fallBack, <HTMLElement>entry.target);
        // unobserve
        this.io.unobserve(entry.target);
      });
    }
    // get the source from attribute
    img.src = entry.target.getAttribute('shLazy');
  }
}

// then change this to call the service
ngAfterViewInit() {
    // ...
    // this can have the nativeElement
    this.setImage(this.options.initial, this.el.nativeElement);

    // ...
    // save the content in an attribute to retrieve when wanted
  this.el.nativeElement.setAttribute('shLazy', this.shLazy);
  // also save options
  this.renderer.setProperty(this.el.nativeElement, 'options', this.options);

    this.io = this.lazyService.newOb((entry) => {
      this.lazyLoad(entry);
    }, this.options.id, this.options.threshold);

    this.io.observe(this.el.nativeElement);
}
ngOnChanges(c: SimpleChanges) {
  if (c.shLazy.firstChange) {
    return;
  }
  if (c.shLazy.currentValue !== c.shLazy.previousValue) {
    // set attribute again
    this.el.nativeElement.setAttribute('shLazy', c.shLazy.currentValue);
    // observe element
    this.io.observe(this.el.nativeElement);
  }
}
Enter fullscreen mode Exit fullscreen mode

To use, we only need to pass a unique id for all elements we need to observe together. The downside to this, is that the threshold is no longer unique to every element, but rather to every group of elements. The first host element to initialize the observer, is the deciding element.

<img [src]="defaultImage" [shLazy]="image" [options]="{fallBack: defaultImage, id: 'productcard'}"  />
Enter fullscreen mode Exit fullscreen mode

This solution does not look neat, nor does it have a big effect on performance. But if your page has like a 1000 images above the fold, and more 1000s down the fold, you might want to consider a single observer.

Find this code in StackBlitz lazy folder.

That's it for our intersection observation. Thank you for reading this far. Did you confuse the bug for a cockroach?

RESOURCES

Top comments (0)