In-depth: How do CDK Portals work?
Learn how to implement your own CDK portal for dynamically placing content in Angular apps
6 min read
6 min read
As shown in the previous article, we need two parts to render a template into a DOM element: the portal host and portal. In this article we’re going to look into what the portal host and portal actually do and how we can emulate their implementation.
// Create a portalHost from a DOM element
this.portalHost = new DomPortalHost(
document.querySelector('#page-actions-container'),
...
);
// create a template portal that takes the reference to
// our ng-template
this.portal = new TemplatePortal(
this.pageActionsTmplRef,
this.viewContainerRef
);
// Attach portal to host
this.portalHost.attach(this.portal);
So the #page-actions-container
that we’re passing to our DomPortalHost
is the DOM element on our page to which we want to render our content. The next step is to grab the actual content, basically an <ng-template #pageActions>
which we can get a reference to using Angular’s @ViewChild('pageActions')
and store it in the pageActionsTmplRef
variable.
Once we have that, we can define our TemplatePortal, passing it our reference to the template as well as a reference to the viewContainer, which we simply inject using Angular’s DI.
Finally, we need to attach our “portal” to our “portal host” to render the content on the page.
Let’s start with the TemplatePortal
. First of all we need to define the view, which in Angular we do in the component’s template:
@Component({
...
template: `
<ng-template #pageActions>
<button type="button" ...>
<mat-icon>add</mat-icon>
</button>
</ng-template>
`
})
export class PageActionsComponent {...}
<ng-template>
won’t be rendered by Angular but serves rather as a means to define a view that we can reference. As you can see I applied a so-called “page variable” which can be accessed from within our PageActionsComponent
using the ViewChild
:
@Component({...})
export class PageActionsComponent implements AfterViewInit {
@ViewChild('pageActions') portalActionsTmplRef;
ngAfterViewInit() {
// access the portalActionsTmplRef here
}
}
The ViewChild
will be accessible in the ngAfterViewInit lifecycle hook. Having a reference to our ng-template, we now need to create a so-called EmbeddedViewRef. An EmbeddedViewRef
is a grouping of DOM elements which we can then be passed along to the container (our portal host) where they should ultimately be rendered. To create it, we need to use the createEmbeddedView(..)
function of the ViewContainerRef. The latter can just be injected using Angular’s DI:
@Component({...})
export class PageActionsComponent implements AfterViewInit {
@ViewChild('pageActions') portalActionsTmplRef;
constructor(private viewContainerRef: ViewContainerRef) {}
ngAfterViewInit() {
const viewRef = viewContainer.createEmbeddedView(this.portalActionsTmplRef);
viewRef.detectChanges();
}
}
We directly pass it our reference to the ng-template
into the createEmbeddedView(..)
function. Moreover we execute change detection on our in-memory view.
Note that the
createEmbeddedView(..)
takes an optional 2nd parameter, a context object which can be used to pass data to our view being rendered.
The viewRef
variable now contains an in-memory rendered view, allowing us to pass it along to a “Portal host” which is responsible for placing it onto the DOM.
The portal host is nothing too complicated either, but rather just a wrapper around a DOM element serving us as the placeholder where to render our content. As such, it needs to manage
Element
.TemplateRef
Implementing our own PortalHost
is relatively easy. To grab an instance of a DOM element, we can use the native DOM API:
const outletElement = document.querySelector('#page-actions-container');
This gives us the DOM element which we can then use to append our view.
We will get the view to be rendered from our implementation of the TemplatePortal
we’ve seen before, in the form of an EmbeddedViewRef
. The EmbeddedViewRef
consists of one or more “nodes”. Take for instance the following view:
<ng-template>
<span>Hi</span>
<button>Click me</button>
</ng-template>
In this case our view consists of a <span>
element as well as a <button>
, all of which have to be attached to the target DOM element (in our case outletElement
).
Luckily the EmbeddedViewRef
, has a property rootNodes
which is an array of all the DOM elements it embraces. Thus, we can iterate over that array, and leverage the appendChild(…)
function on our outletElement
to insert the various nodes into the DOM:
// viewRef is the variable containing our instance of
// the EmbeddedViewRef
viewRef.rootNodes.forEach(rootNode => outletElement.appendChild(rootNode));
If you paid attention, then you’ve seen that we’re creating the EmbeddedViewRef
using an instance of the ViewContainerRef
that we get via Angular’s dependency injection from the PageActionsComponent
. That view container is actually part of the PageActionsComponent
and as a result, also our embedded view.
It just happens, that in our case the nodes of the EmbeddedViewRef
are rendered somewhere else on the page, namely inside our outletElement
. Still, whenever Angular runs CD on our PageActionsComponent
, it will execute it as well on our embedded view ref.
We have now all the pieces together, the functionality of the TemplatePortal
as well as the PortalHost
. If we put all of them together in our ngAfterViewInit(..)
it could look as simple as this:
ngAfterViewInit(): void {
// render the View
const viewRef = this.viewContainerRef.createEmbeddedView(this.portalActionsTmplRef);
viewRef.detectChanges();
// grab the DOM element
const outletElement = document.querySelector('#page-actions-container');
// attach the view to the DOM element that matches our selector
viewRef.rootNodes.forEach(rootNode => outletElement.appendChild(rootNode));
...
}
Clearly, we should also take care of removing again the elements from the DOM, whenever the parent component gets destroyed. To remove an attached view, the remove(index)
function of the ViewContainer
an be used.
ngOnDestroy() {
// get the index of where our view resides inside the
// ViewContainer
const index = this.viewContainer.indexOf(this.viewRef);
if (index !== -1) {
this.viewContainer.remove(index);
}
}
Wrapping up, I changed the adapted the implementation from the previous article using CDKPortals to rather use our own implementation of portals. You can find the entire source code on Stackblitz.
Hint: open the src/app/shared/page-actions/page-actions.component.ts
file.
{% assign uid = “github/juristr/demo-cdk-portal-mobile-pageactions/tree/self-made-portals” %}