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!
// 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, butclassList.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 therootMargin
set to something, that usually helps in catching intersections with edge components, like the footer stuck to the bottom of the body. But withthreshold
, we can overcome the issue. Thus, no need to userootMargin
.
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
?