One of the hallmarks of a site that is a web app is nice, fluid animations. Angular 4+ has a solid animation system you can use, but sometimes animating between router views can be tricky.
When I was developing Artisan I ran into issues animating router transitions that used the same component via URL parameters. When a route changes URL, but keeps using the same component, Angular will only update what changes and not reload the entire view. That caused the page animations to not fire and it looked bad. The way to get past that is to use a custom route reuse strategy so that the component is instantiated with each route parameter change.
To add in a custom route reuse is not hard to do. I created a file called “route.reuse.ts” and placed it in my shared folder like this:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { ActivatedRouteSnapshot, RouteReuseStrategy, DetachedRouteHandle } from '@angular/router'; | |
export class CustomReuseStrategy implements RouteReuseStrategy { | |
shouldDetach(route: ActivatedRouteSnapshot): boolean { | |
return false; | |
} | |
store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle): boolean { | |
return false; | |
} | |
shouldAttach(route: ActivatedRouteSnapshot): boolean { | |
return false; | |
} | |
retrieve(route: ActivatedRouteSnapshot): DetachedRouteHandle { | |
return false; | |
} | |
shouldReuseRoute(future: ActivatedRouteSnapshot, curr: ActivatedRouteSnapshot): boolean { | |
return false; | |
} | |
} |
Then that file is imported into the module and listed as a provider like so:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
providers: [ | |
{provide: RouteReuseStrategy, useClass: CustomReuseStrategy} | |
], |
Now when your router changes URL it will always reload the component regardless, which is what we need.
While that allowed me to finally have animations on my router changes, I ran into trouble with images that were not loaded before the transitions completed. I needed a way to wait for the image at the top of the page to load completely before the loading animation started. That would give me the silky smooth experience that I was expecting.
It turns out doing that is quite simple and will greatly affect the fit and polish of your app. For this we’ll be using a fade type transition between URLs. Here’s the file that contains the animation. I’ve located it also in my shared folder:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { trigger, animate, transition, style, query } from '@angular/animations'; | |
export const fadeAnimation = | |
trigger('fadeAnimation', [ | |
transition( '* => *', [ | |
query(':enter', | |
[ | |
style({ opacity: 0 }) | |
], | |
{ optional: true } | |
), | |
query(':leave', | |
[ | |
style({ opacity: 1 }), | |
animate('0.5s', style({ opacity: 0 })) | |
], | |
{ optional: true } | |
) | |
]) | |
]); |
Next up we import that into our component that has our router outlet in it and add it in like this:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@Component({ | |
selector: 'app-root', | |
templateUrl: './app.component.html', | |
styleUrls: [ './app.component.scss' ], | |
animations: [fadeAnimation] | |
}) |
You’ll notice I also have a scss file here. We’ll need to change the positioning of the components so they stack on top of one another in order to achieve the fade effect. The scss will allow us to apply that at the correct time. Here’s that file:
:host {
overflow: hidden;
.router-wrap {
position: relative;
}
/deep/ router-outlet ~ * {
position: absolute;
width: 100%;
}
}
Next in the app.compoent.html file, we’ll need to change the router outlet to pick up our animation. So here
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<router-outlet></router-outlet> |
becomes
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<div | |
class="router-wrap" | |
[@fadeAnimation]="getRouterOutletState(o)" | |
(@fadeAnimation.done)="pageTransitioned()" | |
> | |
<router-outlet #o="outlet"></router-outlet> | |
</div> |
Back in the app.component.ts file, we need to add this to tie things together:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public getRouterOutletState(outlet) { | |
return outlet.isActivated ? outlet.activatedRoute : ''; | |
} | |
@HostBinding( 'class.page-transitioned' ) pageAnimationFinished: boolean = false; | |
pageTransitioned() { | |
this.pageAnimationFinished = true; | |
} |
And we also employ ngOnInit to make use of router navigation events like so:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
ngOnInit() { | |
this.router.events.subscribe((evt) => { | |
if (evt instanceof NavigationStart) { | |
this.pageAnimationFinished = false; | |
} | |
}); | |
} |
Here’s what we accomplish with this. When the animation starts, the position of the page is changed to fixed so we can stack this and the incoming view on top of one another. But in order to have the page positioned correctly after the animation, I added in a host binding to toggle a class called page-transitioned that I can use to switch the position back to relative.
Bottom line for the app.component is all it is responsible for is fading out the current view and then setting a css class that my other components can use a hook for their animations.
For the various components that will appear via the router outlet I still need to fade them in when they are ready. To do that I created an animation called ready.animation.ts and saved it in my shared folder like so:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { trigger, animate, transition, style, query } from '@angular/animations'; | |
export const readyAnimation = | |
trigger('readyAnimation', [ | |
transition('* => *', [ | |
style({ opacity: 0 }), | |
animate(500, style({ opacity: 1 })) | |
]) | |
]); |
That gets imported into my components. My pages have a large hero image at the top of each one and I don’t want the fade in to trigger until that has finished loading. No one wants a nice fade in and then afterwards see an image trickle down.
So to do that in the page component I create a bit of host binding and also a function that toggles true/false depending on if the image has loaded or not like so:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@HostBinding( 'class.loaded-image' ) loadingImage: boolean = false; | |
displayImage() { | |
setTimeout(() => { | |
this.loadingImage = true; | |
}, 1); | |
} |
You may wonder what the setTimeout is doing there. I found that adding a 1 millisecond delay helped this for some reason.
In the ngOnInit, I added a router event to toggle the loadingImage variable like so:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
this.router.events.subscribe((evt) => { | |
if (evt instanceof NavigationStart) { | |
this.loadingImage = false; | |
} | |
}); |
So when you first visit the page or begin to use the router, the loadingImage variable gets set to false. Nothing is added to the host binding and if loading-image was present, it gets removed.
Now we need some way to detect when the image is loaded so we can flip this variable to true and being the animation. In our HTML, we do it like this:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<img class="background-image" [src]=page.featured_image_url (load)="displayImage()" /> |
We just use javascript (load) event to fire the displayImage() function once the image has finished downloading from our source. That will switch our loadingImage variable to true. Up at the top of our HTML, we have this to bind that variable to the animation like so:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<div class="site-content page" *ngIf="page" [@readyAnimation]="loadingImage"> |
When loadingImage becomes true, then our readyAnimation can fire. That triggers the fade in animation and one final bit of CSS positions everything with this:
.page-transitioned .loaded-image {
position: relative;
}
Our host bindings work together to put everything back to relative positioning now that we’re finished.
That may seem like a lot of code to accomplish a simple fade in and fade out, but I believe it’s worth the extra effort to tie our animations not just to the router changing parameters, but also to elements actually loading so our visitors have the best experience possible.
If you give this a try or have any suggestions, let me know in the comments.
Leave a Reply