Software Tech

Intersection events and loose ends

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

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 EventEmittervoid>();
@Output() onOutofview = new EventEmittervoid>();

// 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();

}
}
)

It can be used as following

crInview onIniew=“callMeInView()” onOutofview=“callMeOutofView()”>something

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();
}
}

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,
})

To use, in our :



crInview
#bluebox=”crInview
>
…content

Stop observing the box below, then start again />
(click)=“bluebox.unobserve()”>Stop observing
(click)=“bluebox.observe()”>Start observing

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);
}
}
}

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

In StackBlitz lazy component, test that by clicking 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  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;
}

Then just use the directive with null fall


class=“hero”
crLazy=“largeimage.png”
[options]=“{ nullCss: ‘hero-null' }”
>
Text on image

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 element is destroyed.

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

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 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: , 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;
}
}

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 sure the nativeElement is not used in the callback, thus the image source passed, must be saved as an  of the element, so that we can it safely upon intersection.
We also need to set options as a property of the element (cannot set 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(, () => {
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);
}
}

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'} />

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. for reading this far. Did you confuse the bug for a ?

RESOURCES

StackBlitz project

About Author

Ayyash

Leave a Reply

SOFAIO BLOG We would like to show you notifications for the latest news and updates.
Dismiss
Allow Notifications