Animating Modal Windows While Blocking Intra-Modal Transitions In Angular 5.2.6
The other week, I finally released a demo that I had been working on for several months: recreating the InVision App user interface (UI) using Angular 5. When I embarked on that demo, I had hoped to include animations; but, by the time I was done, it had gone on for so long that I had to defer animations to a later effort. And, well, now is that time - to at least start learning about the revamped browser animations module in Angular 5. The first step for me was looking at how to animate modal windows into and out of existence. And, how to block animations when jumping directly from one modal window to another.
Run this demo in my JavaScript Demos project on GitHub.
First, I want to give a huge shout-out to Matias Niemela, whose post on the revamped animations module in Angular 4.2 was a huge help. Matias' explanations were super helpful; and, had details in them that I couldn't find anywhere in the official Angular documentation. That said, the official Angular documentation is also very good! But, you have to go beyond the "Animations Guide" and actually read the docs for the individual animation module exports, like transition(), query(), animate(), and so on.
Now, when it comes to animations, I tend to have very strong feelings. I like when animations and transitions provide value in the user experience (UX); but, I am frustrated when animations exist simply because designers believe, without question, that animations make an application more enjoyable. Animations should help to create a more organic, more self-explanatory application interface; and, when they don't, they should either be omitted entirely or, performed so fast that the user never becomes cognizant of them.
With modal windows, it's nice to have some sort of transition in to and out of the modal layout such that the user can develop a mental model for the view stacking: the modal window opens "over" the main page; and, when the modal window is closed, the main page, below it, will once again be revealed. Seeing a modal window transition into place grants the digital experience a kind of physical dimension that the user can more easily understand and manipulate.
Once the user is in a modal layout, however, there's little value in transitioning from one modal experience to another. It doesn't help the user gain a better understanding of the application architecture; and, it will likely end-up serving more as a distraction than as an enhancement.
So, what I'd like to do with Angular's Browser Animations module is be able to transition to and from the modal window experience as a whole; but, to block animations if I'm transitioning directly from one modal window to another. In order to accomplish this, I'm going to lean on the fact that nested animations are blocked by default in the animations module revamp (this was not the case in the earlier incarnation of the module).
As I was exploring this animation interplay, one question that I struggled to answer sufficiently was: Who owns a Routable View's ":enter" and ":leave" animations? Does the parent view - the one injecting the nested view? Or, does the nested view own it? Or, do they share the responsibility? In the end, I viewed it as a shared responsibility: the parent view owns the "when" and "why" the nested view should animate; but, the nested view owns the "how" of the animation itself.
To be honest, at this time, I'm not sure if that answer is the right one. Or, if it's even generally applicable (as opposed to being specific to modal windows). But, I do like that the parent view doesn't actually have to know about the nested view's animation implementation details; or, if the nested view even animates at all.
That said, let's look at some code. First, to lay the ground work, I created an app component that renders one of two modal windows inside of a router-outlet:
// Import the core angular services.
import { Component } from "@angular/core";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "my-app",
styleUrls: [ "./app.component.less" ],
template:
`
<p>
<a routerLink="/app/modal/one">Open Modal One</a> —
<a routerLink="/app/modal/two">Open Modal Two</a>
</p>
<p>
This is some sweet content here!
</p>
<router-outlet></router-outlet>
`
})
export class AppComponent {
// ...
}
Notice that the two modal windows open up with the following paths:
- /app/modal/one
- /app/modal/two
It's important to see that each of these paths contains a "modal" segment. This segment is the hook into the "modal layout" of the application. When I build modal windows into an Angular application, they all exist inside a common layout component. This makes managing them much easier: things like z-index and transition rules become clean and consistent. It also helps to enforce the "reusability" of a modal window - something that can become "unclear" if you conflate a modal window with a tangentially-related "feature module."
If we look at the app module, we can see we have have a "ModalViewComponent" that acts as the parent to both of our modal routes:
// Import the core angular services.
import { BrowserModule } from "@angular/platform-browser";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router";
// Import the application components and services.
import { AppComponent } from "./app.component";
import { ModalOneViewComponent } from "./modal-one-view.component";
import { ModalTwoViewComponent } from "./modal-two-view.component";
import { ModalViewComponent } from "./modal-view.component";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@NgModule({
bootstrap: [
AppComponent
],
imports: [
BrowserAnimationsModule,
BrowserModule,
RouterModule.forRoot(
[
{
path: "app",
children: [
{
path: "modal",
component: ModalViewComponent,
children: [
{
path: "one",
component: ModalOneViewComponent
},
{
path: "two",
component: ModalTwoViewComponent
}
]
}
]
}
],
{
// Tell the router to use the HashLocationStrategy.
useHash: true,
enableTracing: false
}
)
],
declarations: [
AppComponent,
ModalOneViewComponent,
ModalTwoViewComponent,
ModalViewComponent
],
providers: [
// CAUTION: We don't need to specify the LocationStrategy because we are setting
// the "useHash" property in the Router module above (which will be setting the
// strategy for us).
// --
// {
// provide: LocationStrategy,
// useClass: HashLocationStrategy
// }
]
})
export class AppModule {
// ...
}
By having this ModalViewComponent wrapper, it gives us a place to exert some control over when and why the individual modals animate. For this demo, I want to block individual modal view animations if:
- It's a page refresh that happens to render a modal window.
- The user is jumping directly from one modal window to anther.
In both of those cases, I feel like the animation doesn't really support the mental model of the application and would only serve to distract the user. But, I do want to allow individual modal view animations if:
- I'm opening the modal layout.
- I'm closing the modal layout.
To accomplish this, I'm going to apply a "transition" to every state-change in the modal layout. And, I'm going to view every change in the nested view rendering (ie which modal window is rendered) as a state-change. This way, I'll have the option to block all nested animations for every modal-route change. To do this, I'm going to listen to the (activate) event on the router-outlet and increment a "navigationCount" value for every new sub-view (modal) that is activated. This way, I can use the "navigationCount" as the animation trigger "state", which will force a transition for every modal navigation.
I'm not so good at making state-transition diagrams; so, let's just look at the code for the modal-view layout (ie, the layout that houses the other modal windows):
// Import the core angular services.
import { animateChild } from "@angular/animations";
import { Component } from "@angular/core";
import { OnDestroy } from "@angular/core";
import { OnInit } from "@angular/core";
import { query } from "@angular/animations";
import { Router } from "@angular/router";
import { transition } from "@angular/animations";
import { trigger } from "@angular/animations";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "modal-view",
// Bind an animation trigger to the router-outlet-injected view itself.
host: {
"[@modalView]": "navigationCount"
},
animations: [
trigger(
"modalView",
[
// If this modal-view is rendered as part of a page refresh, we don't
// want to include any animations - animations are for mental modal; and,
// if this is the initial page load, there can be no meaningful mental
// model portrayed for the user and the modal window. As such, we need to
// denote the modal-view has having a "transition" so that the nested
// view transitions will be inherently blocked.
transition( "void => 0", [] ),
// While we don't want a transition on page-refresh, we certainly do want
// the animations to play when the modal-view is opened or closed during
// the normal control flow of the application. As such, for the :enter
// :leave transitions, we want to query for the router-outlet component
// and ask its animations to run (if it has any).
transition(
":enter, :leave",
[
// As the modal-view enters or leaves, we want to allow any of
// nested view animations to execute.
// --
// CAUTION: This query selector does not get the simulated
// encapsulation attribute selectors. This will go DEEP through
// the descendant DOM tree if you're not careful. As such, we
// MUST USE the "limit" property to prevent deeper matches from
// being exercised.
query(
"@*",
animateChild(),
{
limit: 1,
optional: true
}
)
]
),
// By default, we want to block all nested animations (and then
// selectively re-enable them using the transitions above). As such, we
// have to define a generic no-op transition from every state to every
// other state. This transition will inherently block the transitions
// contained within any nested views.
transition( "* <=> *", [] )
]
)
],
styleUrls: [ "./modal-view.component.less" ],
template:
`
<router-outlet (activate)="handleActivate()"></router-outlet>
`
})
export class ModalViewComponent implements OnInit, OnDestroy {
public navigationCount: number;
// I initialize the modal-view component.
constructor( router: Router ) {
// If the router has NOT YET BEEN NAVIGATED, it means that this rendering is the
// initial loading of the application. As such, we do not want to animate the
// modal window. By changing the value here, we can pin-point the first load in
// the animation trigger state transition.
// --
// NOTE: Ideally, this kind of logic would just be on the APP COMPONENT; but,
// for some reason, I cannot get a transition on the app component to block
// animations that are nested in the view-tree.
this.navigationCount = router.navigated
? 0
: -1 // First navigation will transition ( -1 => 0 )
;
}
// ---
// PUBLIC METHODS.
// ---
// I handle the activation event of every new view that's mounted inside the modal-
// view's router-outlet.
public handleActivate() : void {
// When transitioning from one modal window to another, we need to have the top-
// level modal-view's animation run such that it blocks the views in the nested
// modal instances. In order to do that, we need to have a state-transition. And,
// in order to force a state-transition, we're just going to increment a value
// every time the sub-view changes.
this.navigationCount++;
}
// I get called once when the input is being unmounted.
public ngOnDestroy() : void {
// Allow the normal scrollbars to show on the document viewport.
document.documentElement.style.overflow = "auto";
document.body.style.overflow = "auto";
}
// I get called once after the inputs have been bound for the first time.
public ngOnInit() : void {
// Hide the scrollbars on the document viewport while the modal window is
// open. This will prevent scrolling in the modal layout from causing unwanted
// scrolling in the main document viewport.
document.documentElement.style.overflow = "hidden";
document.body.style.overflow = "hidden";
}
}
As you can see, every time an individual modal window is rendered, it triggers a change in the "navigationCount" property. I am then binding the navigationCount to the animation trigger that I am applying to the view with a Host Binding:
host: { "[@modalView]": "navigationCount" }
This gives me some granular controll over when I allow nested animations to run and when I apply a no-op (No Operation) transition in order to block nested animations.
It's important to understand that Angular will execute the first state-transition that matches the given trigger's current condition. As such, the the state-transitions should be listed in the order of decreasing specificity. In this demo, my order of transitions goes from a specific state-change, to a class of state-changes, to a general concept of state-changery:
- "void => 0"
- ":enter, :leave"
- "* <=> *"
In this case, the most general transition ("* <=> *") allows me to block all nested animations by default; but then, with the more specific transitions, I can block or unblock as I see fit.
Now, that we have a sense of the "when" and "why" the modal layout is allowing transitions, let's take a look at the "how" implemented by the modal window itself. This demo has two modal windows, but they are basically the same. The only difference is that one transitions in from the left (left: -10%) and one transitions in from the right (left: 110%); the rest of the code is the same. As such, I'm only going to show the code for one of them:
// Import the core angular services.
import { animate } from "@angular/animations";
import { Component } from "@angular/core";
import { query } from "@angular/animations";
import { style } from "@angular/animations";
import { transition } from "@angular/animations";
import { trigger } from "@angular/animations";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "modal-one-view",
// Bind an animation trigger to the router-outlet-injected view itself.
// --
// CAUTION: The animation trigger does not seem to get picked-up if it's just an
// attribute. Meaning, it has to be a [property] assignment.
host: {
"[@modalOneView]": "true"
},
animations: [
// Define animations for the ROUTABLE VIEW itself, which has a HOST BINDING for
// this animation trigger.
trigger(
"modalOneView",
[
transition(
":enter",
[
// Since we're going to be animating the modal in from an off-
// screen location, we want to disable any local overflow so that
// we don't see the interim scrollbars.
style({
overflow: "hidden"
}),
// Animate the content container in from the left.
// --
// CAUTION: This query selector does not get the simulated
// encapsulation attribute selectors. This will go DEEP
// through the descendant DOM tree if you're not careful.
query(
".content",
[
style({
left: "-10%"
}),
animate(
"1000ms ease-out",
style({
left: "*"
})
)
]
)
]
),
transition(
":leave",
[
// Since we're going to be animating the modal out to an off-
// screen location, we want to disable any local overflow so that
// we don't see the interim scrollbars.
style({
overflow: "hidden"
}),
// Animate the content container out to the left.
// --
// CAUTION: This query selector does not get the simulated
// encapsulation attribute selectors. This will go DEEP
// through the descendant DOM tree if you're not careful.
query(
".content",
[
style({
left: "*"
}),
animate(
"1000ms ease-in",
style({
left: "-10%"
})
)
]
)
]
)
]
)
],
styleUrls: [ "./modal-one-view.component.less" ],
template:
`
<div class="content">
<h2>
Modal One
</h2>
<p>
Bruv, this modal is player, init?
</p>
<p>
<a routerLink="/app/modal/two">Goto modal Two</a>
</p>
<p>
or, <a routerLink="/">Close</a>
</p>
<p>
<strong>NOTE</strong>: Refresh the page to see that animations
are blocked on the nested view.
</p>
</div>
`
})
export class ModalOneViewComponent {
// ...
}
As with the modal-layout, this modal-view attaches an animation trigger using a host binding. It then uses the query() meta-data to find and animate the content container within the view. But, notice that this component only cares about its own animations - it doesn't know anything about the parent container or the constraints of moving from one modal window to another; that logic is deferred to the parent view. The parent view takes care of the "when" and "why", this view takes care of the "how".
The animations module in Angular seems, at first, both simple and terribly complex. It seems simple when you read about it - it's just state transitions; but, it quickly becomes complicated and frustrating the moment you start to write code. And, as you can see in this demo, sometimes the very notion of a "state transition" is just an artificial construct being used to drive animation-blocking rules. I suspect that animations will be one of the harder things for which we try to create a clear and consistent mental model. Of course, I'm only just starting this journey. Perhaps it will become clearer as I proceed.
Want to use code from this post? Check out the license.
Reader Comments
Great tutorial. I have always wanted to know how to use native modals!
And this bit, blew my mind:
transition( "* <=> *", [] )
Ben. One question:
Where is the value that specifies the modal to stop in the middle of the screen?
Is this:
left: "*"
And how about the value that represents the modal's right hand side close co-ordinates?
OK. Had another look at the demo.
The right hand side one, is modal 2, but I would still like to know what:
left: "*"
Means?
@Charles,
Great question. As far as I understand it, the "*" in the style() meta-data tells the animations API to use whatever the "current" value of the given property is at the time the animation is initiated or finalized (depending on where you use it).
So for example, if the "*" is in the animation-start styling, it means take the currently-computed value of that CSS property. And, if the "*" is in the animation-end styling, it means to take the value of the CSS property that would be computed at the end of the animation based on the core styling.
I think ... it's confusing (and takes a bit of trial an error).
But, in this case, my "left: 50%" is actually in the CSS of the component itself (which is not in the actual demo):
.content {
. . . background-color: #FFFFFF ;
. . . border-radius: 7px 7px 7px 7px ;
. . . box-sizing: border-box ;
. . . min-height: 300px ;
. . . left: 50% ;
. . . margin-left: -250px ;
. . . padding: 27px 27px 27px 27px ;
. . . position: absolute ;
. . . top: 100px ;
. . . width: 500px ;
. . .
. . . & > *:first-child {
. . . . . . margin-top: 0px ;
. . . }
. . .
. . . & > *:last-child {
. . . . . . margin-bottom: 0px ;
. . . }
}
So, when I am ":enter" animating, the "*" in the end-animation style is the "left: 50%" as defined by the CSS. And, when I am ":leave" animating, the "*" in the start-animation style is the "left: 50%" as defined by the CSS ... OR ... the current value as defined by an activate animation. Meaning, if the ":enter" animation is cut short, and the ":leave" animation has to fire (such as when the user hits the back-button while the modal is entering), the animation will reverse from the _current runtime position_; it doesn't jump to "50%" and then start animating in the other direction.
I hope I didn't give too much misinformation here; but, that is how I currently understand it.
Ben. Thanks for the explanation. It all made sense once I saw the CSS!