All Blogs

The power of Angular Directive and Dependency Injection

In this blog post, I want to show a use case that demonstrates the power of Angular Directives coupled with Angularโ€™s Dependency Injection system.

Use-case

I am working on a library called Angular Three, which is a THREE.js integration for Angular. Here is a simple 3D Scene and the code for that scene:

A spinning cube with mouse interactions A spinning cube with mouse interactions

1
@Component({
2
selector: "ngt-cube",
3
template: `
4
<ngt-soba-box
5
#sobaBox
6
[ngtBoxHelper]="['black']"
7
(animateReady)="onAnimateReady(sobaBox.object)"
8
(click)="active = !active"
9
(pointerover)="hover = true"
10
(pointerout)="hover = false"
11
[isMaterialArray]="true"
12
[scale]="active ? [1.5, 1.5, 1.5] : [1, 1, 1]"
13
>
14
<ngt-cube-materials [hover]="hover"></ngt-cube-materials>
15
</ngt-soba-box>
16
`,
17
changeDetection: ChangeDetectionStrategy.OnPush,
18
})
19
export class CubeComponent {
20
hover = false;
21
active = false;
22
23
onAnimateReady(mesh: THREE.Mesh) {
24
mesh.rotation.x = -Math.PI / 2;
25
mesh.rotation.z += 0.01;
26
}
27
}

As seen in the GIF, you can see that I can interact with the cube in a couple of ways:

There is no feedback to tell the users that the cube is actionable (no cursor: pointer). A cube is a 3D object drawn inside an HTMLCanvasElement, so there is no DOM for this cube, so there is no cursor: pointer.

We are going to fix that in this blog post.

Naive fix

The naive approach would be to change the style.cursor on the document.body, and we can do this because we can listen to (pointerover) and (pointerout) events on the cube. Letโ€™s change our code to implement this:

1
@Component({
2
selector: "ngt-cube",
3
template: `
4
<ngt-soba-box
5
#sobaBox
6
...
7
(pointerover)="onPointerOver()"
8
(pointerout)="onPointerOut()"
9
...
10
>
11
<ngt-cube-materials [hover]="hover"></ngt-cube-materials>
12
</ngt-soba-box>
13
`,
14
changeDetection: ChangeDetectionStrategy.OnPush,
15
})
16
export class CubeComponent {
17
hover = false;
18
active = false;
19
20
onPointerOver() {
21
this.hover = true;
22
}
23
24
onPointerOut() {
25
this.hover = false;
26
}
27
28
onAnimateReady(mesh: THREE.Mesh) {
29
/* ... */
30
}
31
}

Everything should be the same as before. Letโ€™s actually start with the fix

1
@Component({
2
selector: "ngt-cube",
3
template: `
4
<ngt-soba-box
5
#sobaBox
6
...
7
(pointerover)="onPointerOver()"
8
(pointerout)="onPointerOut()"
9
...
10
>
11
<ngt-cube-materials [hover]="hover"></ngt-cube-materials>
12
</ngt-soba-box>
13
`,
14
changeDetection: ChangeDetectionStrategy.OnPush,
15
})
16
export class CubeComponent {
17
hover = false;
18
active = false;
19
20
// ๐Ÿ‘‡ inject DOCUMENT (aka document)
21
constructor(@Inject(DOCUMENT) private document: Document) {}
22
23
onPointerOver() {
24
this.hover = true;
25
//๐Ÿ‘‡ change to pointer on hover
26
this.document.body.style.cursor = "pointer";
27
}
28
29
onPointerOut() {
30
this.hover = false;
31
//๐Ÿ‘‡ change to pointer off hover
32
this.document.body.style.cursor = "auto";
33
}
34
35
onAnimateReady(mesh: THREE.Mesh) {
36
/* ... */
37
}
38
}

Cursor changes to โ€œpointerโ€ on hover Cursor changes to โ€œpointerโ€ on hover

Hurray ๐ŸŽ‰! We now have feedback for users that our cube is actionable.

https://media4.giphy.com/media/QYLQRR7IF48njkq5an/giphy.gif?cid=ecf05e47e3w414zm0jv0dqzfv482le8uzduejd3e0idk0syw&rid=giphy.gif&ct=g

What if we add the cursor: pointer fix to different 3D objects that might not be in the same Component? That would be quite a chore to do.

Letโ€™s re-assert what we need and think of a different approach ๐Ÿค”

With all of these points listed out, an Angular Directive, specifically an Attribute Directive, sounds like what we need to create.

Check Attribute Directive to learn more about it.

Into the Directive lane

In Angular, you can attach Attribute Directives on any elements on the DOM, and if the Directiveโ€™s selector matches, Angular will instantiate the Directive with the appropriate context.

What is this โ€œcontextโ€ thing?

A context is an environment that the Directive is created with. In other words, what is available for this Directive to access via Angularโ€™s Dependency Injection based on where it is attached.

1
@Directive({
2
selector: "[some]",
3
})
4
export class SomeDirective {
5
// ๐Ÿ‘‡ what is available here? This is important
6
constructor() {}
7
}

Hereโ€™s how SomeDirective might be used/attached:

HTML Elements

1
<div some></div>
2
<button some></button>

When a Directive is attached on an HTMLElement, the Directive will have access to:

1
@Directive({
2
/*...*/
3
})
4
export class SomeDirective {
5
constructor(elRef: ElementRef<HTMLButtonElement>) {}
6
}

Component

1
<some-component some></some-component>

When a Directive is attached on a Component, the Directive will have access to:

1
@Directive({
2
/*...*/
3
})
4
export class SomeDirective {
5
constructor(
6
// ๐Ÿ‘‡ the <some-component> element
7
elRef: ElementRef<HTMLElement>,
8
// ๐Ÿ‘‡ the SomeComponent instance
9
some: SomeComponent,
10
) {}
11
}

ng-template

1
<ng-template some></ng-template>

When a Directive is attached on ng-template, the Directive will have access to:

1
@Directive({/*...*/})
2
export class SomeDirective {
3
constructor(
4
// ๐Ÿ‘‡ the <!-- container --> comment
5
elRef: ElementRef<Comment>,
6
// ๐Ÿ‘‡ the TemplateRef instance
7
// ๐Ÿ‘‡. ๐Ÿ‘‡ you can use any specific generic here if you know what the Context is
8
templateRef: TemplateRef<any>
9
) {
10
console.log(elRef.nativeElement.textContent): // logs: 'container'
11
}
12
}

ViewContainerRef is always available to Attribute Directive

Inheritance

In addition to the above, the Directive also has access to the Hierarchical Injector Tree, where itโ€™s attached. Letโ€™s look at the following examples:

1
<input [ngModel]="name" some />

SomeDirective has access to the NgModel instance and anything that NgModel might have inherited (the underlying NgControl), so you can have a Directive that might do some additional Form logic.

1
<some-component some></some-component>

SomeDirective has access to anything that is provided in SomeComponentโ€™s providers

1
@Component({
2
/*...*/,
3
// ๐Ÿ‘‡ this will also be made available to SomeDirective
4
providers: [SomeService]
5
})
6
export class SomeComponent {}

SomeDirective also has access to SomeComponentโ€™s ancestorsโ€™ Injector

1
<some-parent>
2
<some-component some></some-component>
3
</some-parent>

In this case, anything that is available in SomeParent will also be made available to SomeDirective via Dependency Injection.

Everything listed here might not be exhaustive, as these are the things that I am aware of. You can quickly check what is available in any specific building block of Angular by injecting the Injector and checking it in the console.

Checking Injector in the console Checking Injector in the console

The overlooked feature: Directiveโ€™s Selector

One powerful feature of Angular Directives is specifying the selector like CSS Selectors. Letโ€™s bring back our SomeDirective

1
@Directive({
2
selector: "[some]",
3
})
4
export class SomeDirective {}

Letโ€™s also assume that SomeDirective will ALWAYS need to be there whenever SomeComponent is used. In other words, SomeDirective handles some certain logic for SomeComponent that is extracted out of the Component to achieve Separation of Concern. Having to do the following is tedious

1
<some-component some></some-component>
2
<some-component some></some-component>
3
<some-component some></some-component>
4
<some-component some></some-component>
5
<some-component some></some-component>

Instead, we can change SomeDirectiveโ€™s selector to some-component so that it will get instantiated in the same manner as SomeComponent

1
@Directive({
2
// ๐Ÿ‘‡ nothing really prevents you from doing this
3
selector: "some-component",
4
})
5
export class SomeDirective {}

This is extremely powerful if you know how to take advantage of it, especially youโ€™re building reusable components and want to get rid of some nuances for your consumers. You can control how your Directives are instantiated:

Now that we have all that information, we can continue on our proper fix to the problem stated at the beginning of this blog post.

Proper fix to the cursor problem

Start by creating a Directive (you can use the Angular CLI if you want to)

1
@Directive({
2
selector: "[cursorPointer]",
3
})
4
export class CursorPointerDirective {
5
constructor() {}
6
}

This is how we would want to use CursorPointerDirective

1
<ngt-mesh cursorPointer></ngt-mesh>

The job of CursorPointerDirective is to listen to the pointersโ€™ events and update document.body style. Letโ€™s fill the directive up

1
@Directive({
2
selector: "[cursorPointer]",
3
})
4
export class CursorPointerDirective implements OnDestroy {
5
// ๐Ÿ‘‡ a Subject to use with takeUntil
6
destroyed$ = new Subject();
7
8
constructor(
9
// ๐Ÿ‘‡ we need the Document so we can change its body's style
10
@Inject(DOCUMENT) document: Document,
11
// ๐Ÿ‘‡ we use Optional so we can be less disruptive. The consumers might attach [cursorPointer] on elements that are not supposed to be attached to
12
// ๐Ÿ‘‡ // ๐Ÿ‘‡ This is arbitrary but in Angular Three, this is where the pointer's events are declared (aka Outputs)
13
@Optional() object3dInputsController: NgtObject3dInputsController,
14
) {}
15
16
// ๐Ÿ‘‡ a Directive shares the same life-cycle with the Element/Component that it's attached to
17
// in this case, when the 3D Object is destroyed, `cursorPointer` is also destroyed
18
ngOnDestroy() {
19
this.destroyed$.next();
20
}
21
}

That is all the prep work we need for CursorPointerDirective. Now, the listening part:

1
@Directive({
2
selector: "[cursorPointer]",
3
})
4
export class CursorPointerDirective implements OnDestroy {
5
destroyed$ = new Subject();
6
7
constructor(
8
@Inject(DOCUMENT) document: Document,
9
@Optional() object3dInputsController: NgtObject3dInputsController,
10
) {
11
// if object3dInputsController is not available, just fail fast
12
if (!object3dInputsController) return;
13
14
// ๐Ÿ‘‡ EventEmitter (Outputs) is just RxJS Subjects so we can subscribe to them
15
// ๐Ÿ‘‡ import { merge } from rxjs;
16
merge(
17
// ๐Ÿ‘‡ if pointerover, map to true as in hovered: true
18
object3dInputsController.pointerover.pipe(mapTo(true)),
19
// ๐Ÿ‘‡ if pointerout, map to false as in hovered: false
20
object3dInputsController.pointerout.pipe(mapTo(false)),
21
)
22
.pipe(
23
// ๐Ÿ‘‡ clean up
24
takeUntil(this.destroyed$),
25
)
26
.subscribe((hovered) => {
27
// ๐Ÿ‘‡ the main logic
28
document.body.style.cursor = hovered ? "pointer" : "auto";
29
});
30
}
31
32
ngOnDestroy() {
33
this.destroyed$.next();
34
}
35
}

Thatโ€™s it! Now we can use cursorPointer on ngt-mesh and see the result:

1
@Component({
2
selector: "ngt-cube",
3
template: `
4
<ngt-soba-box
5
#sobaBox
6
cursorPointer
7
[ngtBoxHelper]="['black']"
8
(animateReady)="onAnimateReady(sobaBox.object)"
9
(click)="active = !active"
10
(pointerover)="hover = true"
11
(pointerout)="hover = false"
12
[isMaterialArray]="true"
13
[scale]="active ? [1.5, 1.5, 1.5] : [1, 1,1]"
14
>
15
<ngt-cube-materials [hover]="hover"></ngt-cube-materials>
16
</ngt-soba-box>
17
`,
18
changeDetection: ChangeDetectionStrategy.OnPush,
19
})
20
export class CubeComponent {
21
hover = false;
22
active = false;
23
24
// component code is clean ๐Ÿ˜Ž
25
26
onAnimateReady(mesh: THREE.Mesh) {
27
mesh.rotation.x = -Math.PI / 2;
28
mesh.rotation.z += 0.01;
29
}
30
}

Conclusion

With some knowledge about Directives, we made a reusable cursorPointer directive that other 3D objects can use on any 3D Objects for showing cursor: pointer. I hope you learn something from this blog post. Have fun and good luck ๐Ÿ‘‹.

Published on Mon Dec 27 2021


Angular