Tl;dr: How do I provide a visible component as a dependency for a directive? Naturally the component has to get initialized before the directive, but it has to be the same instance that gets displayed when the app later runs across the selector
of the component.
Details:
My app.component.html has a structure like so:
app.component.html
<app-navigation></app-navigation>
<router-outlet></router-outlet>
There is a navigation bar at the top which is always visible. The <router-outlet>
always displays the currently active component.
I'd now like to allow the components that are rendered in the <router-outlet>
to modify the contents of the navigation bar, for example to display additional buttons that fit the currently active component. This should work with a directive, like so:
some.component.html
<div *appTopBar>
<button>Additional Button</button>
</div>
The additional button should now appear in the navigation bar at the top.
The appTopBar
directive looks like this:
top-bar.directive.ts
import {AfterViewInit, Directive, OnDestroy, TemplateRef} from '@angular/core';
import {AppComponent} from '../navigation/navigation.component';
@Directive({
selector: '[appTopBar]'
})
export class TopBarDirective implements AfterViewInit, OnDestroy {
constructor(private tmpl: TemplateRef<any>,
private nav: NavigationComponent) {
}
ngAfterViewInit(): void {
this.nav.setTopBarContent(this.tmpl);
}
ngOnDestroy(): void {
this.nav.setTopBarContent(null);
}
}
The directive has a dependency on the NavigationComponent and can pass content to the navigation bar via the publicly provided methods setTopBarContent()
:
navigation.component.ts
import {Component, EmbeddedViewRef, TemplateRef, ViewChild, ViewContainerRef} from '@angular/core';
@Component({
selector: 'app-navigation',
templateUrl: './navigation.component.html',
styleUrls: ['./navigation.component.scss']
})
export class NavigationComponent {
@ViewChild('topBarContainer',{static: false})
topBar: ViewContainerRef;
topBarContent: EmbeddedViewRef<any>;
constructor() {}
/**
* Set the template to be shown in the top bar
* @param tmpl template, null clears the content
*/
public setTopBarContent(tmpl: TemplateRef<any>) {
if (this.topBarContent) {
this.topBarContent.destroy();
}
if (tmpl) {
this.topBarContent = this.topBar.createEmbeddedView(tmpl);
}
}
}
The first issue I ran into was that the NavigationComponent
dependency was not available yet, when the TopBarDirective was initialized. I got the following error:
ERROR Error: Uncaught (in promise): NullInjectorError:
StaticInjectorError(AppModule)[TopBarDirective -> NavigationComponent]: StaticInjectorError(Platform: core)[TopBarDirective -> NavigationComponent]:
NullInjectorError: No provider for NavigationComponent!
So obviously the component got initialized after the directive and wasn't available yet.
I tried adding the NavigationComponent
to the providers
array of the AppComponent
and the dependency injection now worked:
@NgModule({
declarations: [
NavigationComponent,
SomeComponent,
TopBarDirective
],
imports: [
BrowserModule,
CommonModule
],
providers: [NavigationComponent]
})
export class AppModule { }
However, it seems there are now two instances of the NavigationComponent. I checked this by generating a random number in the constructor
of the NavigationComponent
and logging it. The directive definitely has an instance other from the one displayed at the <app-navigation>
selector.
Now I know this pattern works somehow. I found it some time ago where it was introduced by some Angular developer, but I unfortunately don't have the source anymore. The working version, however, displays the contents in the AppComponent
, so the directive only has a dependency to AppComponent
, which seems to get initialized first. Therefore the whole dependency issue does not occur.
How can I make sure the instance of NavigationComponent
provided to the TopBarDirective
is the same instance that is displayed at the <app-navigation>
selector?
I propose you to create a service say TopbarService
for this which would be like this.There we will use a BehaviorSubject
to set the template and emit it's latest value.
@Injectable()
export class TopbarService {
private currentState = new BehaviorSubject<TemplateRef<any> | null>(null);
readonly contents = this.currentState.asObservable();
setContents(ref: TemplateRef<any>): void {
this.currentState.next(ref);
}
clearContents(): void {
this.currentState.next(null);
}
}
Now in directive inject this service and invoke the service method.
@Directive({
selector: '[appTopbar]',
})
export class TopbarDirective implements OnInit {
constructor(private topbarService: TopbarService,
private templateRef: TemplateRef<any>) {
}
ngOnInit(): void {
this.topbarService.setContents(this.templateRef);
}
}
In the NavigationComponent
component subscribe on the contents behaviorsubject to get the latest value and set the template.
export class NavigationComponent implements OnInit, AfterViewInit {
_current: EmbeddedViewRef<any> | null = null;
@ViewChild('vcr', { read: ViewContainerRef })
vcr: ViewContainerRef;
constructor(private topbarService: TopbarService,
private cdRef: ChangeDetectorRef) {
}
ngOnInit() {
}
ngAfterViewInit() {
this.topbarService
.contents
.subscribe(ref => {
if (this._current !== null) {
this._current.destroy();
this._current = null;
}
if (!ref) {
return;
}
this._current = this.vcr.createEmbeddedView(ref);
this.cdRef.detectChanges();
});
}
}
Html of this component would be like this where you place the template.
template: `
<div class="full-container topbar">
<ng-container #vcr></ng-container>
<h1>Navbar</h1>
</div>
`,