All Blogs
๐ŸŒž

The power of Angular Directives 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

@Component({
  selector: 'ngt-cube',
  template: `
    <ngt-soba-box
      #sobaBox
      [ngtBoxHelper]="['black']"
      (animateReady)="onAnimateReady(sobaBox.object)"
      (click)="active = !active"
      (pointerover)="hover = true"
      (pointerout)="hover = false"
      [isMaterialArray]="true"
      [scale]="active ? [1.5, 1.5, 1.5] : [1, 1, 1]"
    >
      <ngt-cube-materials [hover]="hover"></ngt-cube-materials>
    </ngt-soba-box>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CubeComponent {
  hover = false;
  active = false;

  onAnimateReady(mesh: THREE.Mesh) {
    mesh.rotation.x = -Math.PI / 2;
    mesh.rotation.z += 0.01;
  }
}

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

  • Hovering changes its colors.
  • Clicking changes its scale.

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:

@Component({
  selector: 'ngt-cube',
  template: `
    <ngt-soba-box
      #sobaBox
            ...
      (pointerover)="onPointerOver()"
      (pointerout)="onPointerOut()"
            ...
    >
      <ngt-cube-materials [hover]="hover"></ngt-cube-materials>
    </ngt-soba-box>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CubeComponent {
  hover = false;
  active = false;

    onPointerOver() {
        this.hover = true;
    }

    onPointerOut() {
        this.hover = false;
    }

  onAnimateReady(mesh: THREE.Mesh) {
        /* ... */
  }
}

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

@Component({
  selector: 'ngt-cube',
  template: `
    <ngt-soba-box
      #sobaBox
            ...
      (pointerover)="onPointerOver()"
      (pointerout)="onPointerOut()"
            ...
    >
      <ngt-cube-materials [hover]="hover"></ngt-cube-materials>
    </ngt-soba-box>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CubeComponent {
  hover = false;
  active = false;

                                        // ๐Ÿ‘‡ inject DOCUMENT (aka document)
    constructor(@Inject(DOCUMENT) private document: Document) {}

    onPointerOver() {
        this.hover = true;
                                      //๐Ÿ‘‡ change to pointer on hover
        this.document.body.style.cursor = 'pointer';
    }

    onPointerOut() {
        this.hover = false;
                                      //๐Ÿ‘‡ change to pointer off hover
        this.document.body.style.cursor = 'auto';
    }

  onAnimateReady(mesh: THREE.Mesh) {
        /* ... */
  }
}
Cursor changes to โ€œpointerโ€ on hover

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

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

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.

  • We would need to listen to two events (pointerover) and (pointerout) even if the object might not need to have โ€œhoverโ€ interactions.
  • We would need to inject DOCUMENT to change the body.style.

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

  • We need to listen to (pointerover) and (pointerout) events on 3D Objects
  • We need to change the style of a global object (document.body)
  • We need to do this for any 3D objects
  • We want to be able to declaratively add/remove this functionality (cursor:pointer)

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.

@Directive({
    selector: '[some]'
})
export class SomeDirective {
          // ๐Ÿ‘‡ what is available here? This is important
    constructor() {}
}

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

HTML Elements

<div some></div>
<button some></button>

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

  • ElementRef<TElement>: where TElement is the actual type of the element. Eg: <button> is HTMLButtonElement, <div> is HTMLDivElement. ElementRef is the reference to the DOM element that is rendered, like what you would see in the Element Dev Tool.
    @Directive({/*...*/})
    export class SomeDirective {
      constructor(elRef: ElementRef<HTMLButtonElement>) {}
    }

Component

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

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

  • ElementRef<HTMLElement>: same as above
  • TComponent: where TComponent is the type of the Component. The Directive has access to the instance of the Component that Angular creates.
    @Directive({/*...*/})
    export class SomeDirective {
      constructor(
      // ๐Ÿ‘‡ the <some-component> element
          elRef: ElementRef<HTMLElement>,
      // ๐Ÿ‘‡ the SomeComponent instance
          some: SomeComponent
      ) {}
    }

ng-template

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

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

  • ElementRef<Comment>: When you render ng-template, Angular will put a comment: <!-- container --> in its place. This comment will be available for the Directive via ElementRef<Comment>
  • TemplateRef<any>: The TemplateRef instance.
    @Directive({/*...*/})
    export class SomeDirective {
      constructor(
      // ๐Ÿ‘‡ the <!-- container --> comment
          elRef: ElementRef<Comment>,
      // ๐Ÿ‘‡ the TemplateRef instance
      // ๐Ÿ‘‡.                   ๐Ÿ‘‡ you can use any specific generic here if you know what the Context is
          templateRef: TemplateRef<any>
      ) {
          console.log(elRef.nativeElement.textContent): // logs: 'container'
      }
    }

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:

  • Other directives
    <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.

  • Componentโ€™s Providers
    <some-component some></some-component>

SomeDirective has access to anything that is provided in SomeComponent's providers

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

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

<some-parent>
    <some-component some></some-component>
</some-parent>

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

  • Root/Platform Providers: ChangeDetectorRef, NgZone, ApplicationRef, @Inject(DOCUMENT), etc.

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

@Directive({
    selector: '[some]'
})
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

<some-component some></some-component>
<some-component some></some-component>
<some-component some></some-component>
<some-component some></some-component>
<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

@Directive({
            // ๐Ÿ‘‡ nothing really prevents you from doing this
    selector: 'some-component'
})
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:

  • Relaxing: make the selectors more relaxed. Like what we did with SomeDirective and SomeComponent
  • Constraining: make the selectors more constrained. For example, [some] might be too broad and might be misused; we can constrain it to just some-component by changing the selector to some-component[some]
  • Condition: Directivesโ€™ selectors are like CSS Selectors, you can apply some conditions to them. For example, [some][foo]:not([bar]),[some][bar]:not([foo]),[some]:not([foo]):not(bar) . This says: SomeDirective can be instantiated by itself, with [foo] input, or with [bar] input but never [foo] and [bar] inputs at the same time.

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)

@Directive({
    selector: '[cursorPointer]'
})
export class CursorPointerDirective {
    constructor() {}
}

This is how we would want to use CursorPointerDirective

<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

@Directive({
    selector: '[cursorPointer]'
})
export class CursorPointerDirective implements OnDestroy {
  // ๐Ÿ‘‡ a Subject to use with takeUntil
    destroyed$ = new Subject();

    constructor(
    // ๐Ÿ‘‡ we need the Document so we can change its body's style
        @Inject(DOCUMENT) document: Document,
    // ๐Ÿ‘‡ we use Optional so we can be less disruptive. The consumers might attach [cursorPointer] on elements that are not supposed to be attached to
    // ๐Ÿ‘‡       // ๐Ÿ‘‡ This is arbitrary but in Angular Three, this is where the pointer's events are declared (aka Outputs)
        @Optional() object3dInputsController: NgtObject3dInputsController
    ) {}

  // ๐Ÿ‘‡ a Directive shares the same life-cycle with the Element/Component that it's attached to
  // in this case, when the 3D Object is destroyed, `cursorPointer` is also destroyed
    ngOnDestroy() {
        this.destroyed$.next();
    }
}

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

@Directive({
    selector: '[cursorPointer]'
})
export class CursorPointerDirective implements OnDestroy {
    destroyed$ = new Subject();

    constructor(
        @Inject(DOCUMENT) document: Document,
        @Optional() object3dInputsController: NgtObject3dInputsController
    ) {
    // if object3dInputsController is not available, just fail fast
        if (!object3dInputsController) return;

    // ๐Ÿ‘‡ EventEmitter (Outputs) is just RxJS Subjects so we can subscribe to them
    // ๐Ÿ‘‡ import { merge } from rxjs;
        merge(
                               // ๐Ÿ‘‡ if pointerover, map to true as in hovered: true
            object3dInputsController.pointerover.pipe(mapTo(true)),
                               // ๐Ÿ‘‡ if pointerout, map to true as in hovered: false
            object3dInputsController.pointerout.pipe(mapTo(false))
        ).pipe(
      // ๐Ÿ‘‡ clean up
            takeUntil(this.destroyed$)
        ).subscribe(hovered => {
      // ๐Ÿ‘‡ the main logic
            document.body.style.cursor = hovered ? 'pointer' : 'auto';
        })
    }

    ngOnDestroy() {
        this.destroyed$.next();
    }
}

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

@Component({
  selector: 'ngt-cube',
  template: `
    <ngt-soba-box
      #sobaBox
      <!-- ๐Ÿ‘‡ this is it. ngtCursor is Angular Three equivalent to cursorPointer -->
      ngtCursor
      [ngtBoxHelper]="['black']"
      (animateReady)="onAnimateReady(sobaBox.object)"
      (click)="active = !active"
      (pointerover)="hover = true"
      (pointerout)="hover = false"
      [isMaterialArray]="true"
      [scale]="active ? [1.5, 1.5, 1.5] : [1, 1, 1]"
    >
      <ngt-cube-materials [hover]="hover"></ngt-cube-materials>
    </ngt-soba-box>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CubeComponent {
  hover = false;
  active = false;

  // component code is so clean

  onAnimateReady(mesh: THREE.Mesh) {
    mesh.rotation.x = -Math.PI / 2;
    mesh.rotation.z += 0.01;
  }
}

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 Dec 27, 2021