How to create an Angular NavBar with a dynamic slider (and keep your sanity)

One must imagine Angular devs happy

A little over three months ago, I moved on to a new project within my company and started working with Angular. Before that, I worked exclusively with React for about two years.

I immediately noticed that the learning curve for Angular is a lot steeper than for React. And this is probably more true if you are an absolute beginner with zero to little FE experience.

- The React way of doing things

For example, to start building with React, you can use the CRA (create-react-app) npm package to bootstrap the application. Then you can open the App.js file and start writing your HTML-like (JSX), Javascript, and even CSS code - by using any of the CSS-in-JS tools like StyledComponents. So all of the concerns go into a single file!

You also need to understand some basic concepts like components, state, and props. Plus some extremely basic FP stuff. And that's it, more or less.

Of course, things tend to get more complicated as the complexity of the app grows. And there are more concepts, design patterns, libraries, and tools you need to learn about and eventually master (like React Router, global state management, Redux, R-Thunk, R-Saga, rendering optimisation techniques etc.).

But all of this is optional (not part of the React core library). Most of the extra stuff comes in the form of 3rd party libraries.

- The Angular way of doing things

Angular takes things to a whole new level. If you want to build the famous TO-DO list, the "Hello World" equivalent in the single-page application world, you can't just bootstrap an Angular app and start writing Javascript code in a single file.

First, you have to learn Angular-specific abstractions and some new design patterns, like components, directives, templates, the basics of OOP, dependency injection, and more.

You can argue that I said the same thing for React. In both cases, you need to learn the library-specific, basic stuff before building anything. And this is true. But in my opinion, Angular has much more of that "basic stuff" compared to React.

You also need to know Typescript to write Angular apps. It is not a must, but it is an accepted industry standard.

Also, the HTML, CSS, and TS code is isolated in separate files. It is similar to the classical way of building web apps, with a clear separation of concerns. This has its benefits - but I think I prefer how React handles this.

Once you master the basics and start to think you're finally getting the hang of things, you pass the first hill on the Dunning-Kruger curve, and fall from the peak of "Mount Stupid" to the Valley of Despair.

- Things can quickly become complicated

You eventually realize that Angular has much more stuff baked into its core than React (Router, Animations, RxJS) and that it is a complete SPA development toolbox. This is why people call it a framework. Unlike React, which is "just" a library.

...

The current point in my Angular learning journey is probably somewhere near the bottom of the D-K curve. And I feel like I just started to roll a massive boulder up the hill of enlightenment. Thee bright side is that I'm slowly getting closer and closer to the summit.

The good stuff - How to build a NavBar with a Slider...

...and to keep your sanity during that process.

Last week I implemented the "NavBar with a dynamic slider underneath" component/feature on the project I'm currently working on (for a company client).

So, for the purpose of this blog post I've re-created this component in isolation. I ran into an interesting problem along the way. Solving that problem required some creative thinking.

Here's how the completed component looks like.

The NavBar component has 4 navigation items. By clicking on any of the items, the user is redirected to a predefined route ('/home', '/posts', '/random', '/speed')

The main goal was to indicate the currently active route, and consequently the currently active NavBar item to the user (hence the slider).

Another requirement was that the slider needed to transition smoothly from one item to the other.

The slider is implemented as an additional list element, with some basic styling:

<!-- navbar.component.html -->

  <ul class="header-menu">
    <li #navElements *ngFor="let item of navItemsList">
      <a 
        routerLink="/{{item.route}}" 
        (click)="calcNewIndicatorDOMStyles()"
      >
        {{ item.name }}
      </a>
    </li>
    <li 
      class="slider" 
      [style.width.px]="activeItemWidth" 
      [style.left.px]="activeItemLeftMargin">
    </li>
  </ul>
// navbar.component.css

  .slider {
    position: absolute;
    bottom: -5px;
    margin-left: 2.2em;
    border-bottom: 2px solid white;
    transition: 0.3s;
    width: 50px;
  }

You can find the running app here

An additional requirement is that the slider width needed to change dynamically and match the width of the nav item above it.

Nav item width change can happen in two scenarios:

  • Screen resize. The user can pivot his device.
  • Text translation change. Simulated with the DE/EN button underneath the component.

If you look at the template file code below, you'll see that I used inline styles to dynamically set the slider's left margin and width:

<!-- navbar.component.html -->

  <li 
    class="slider" 
    [style.width.px]="activeItemWidth"    <======
    [style.left.px]="activeItemLeftMargin">    <======
  </li>

activeItemWidth and activeItemLeftMargin are calculated in this method:

// navbar.component.ts

    calcNewIndicatorDOMStyles() {
      this.activeItemWidth = this.router.isActive(routes.name, 
      false)
        ? this.navItemDOMProps?.[0].width
        : this.router.isActive(routes.posts, false)
        ? this.navItemDOMProps?.[1].width
        : this.router.isActive(routes.random, false)
        ? this.navItemDOMProps?.[2].width
        : this.router.isActive(routes.speed, false)
        ? this.navItemDOMProps?.[3].width
        : 0;

      this.activeItemLeftMargin = 
      this.router.isActive(routes.name, false)
        ? 0
        : this.router.isActive(routes.posts, false)
        ? this.navItemDOMProps?.[0].width + 30
        : this.router.isActive(routes.random, false)
        ? this.navItemDOMProps?.[0].width + 
          this.navItemDOMProps?.[1].width + 60
        : this.router.isActive(routes.speed, false)
        ? this.navItemDOMProps?.[0].width + 
          this.navItemDOMProps?.[1].width + 
          this.navItemDOMProps?.[2].width + 90
        : 0;
    }

This method is triggered by the user when a nav item is clicked. Then the new slider position (margin-left) and width need to be re-calculated, so that the slider can transition under the new active item.

So, the tricky part was figuring out how to get the "freshest" DOM styles (after the template re-renders and new properties are computed). To be more specific, I needed the newest nav element offsetWidth value (last render), so that it can be used in the calcNewIndicatorDOMStyles() method to calculate the slider width and left-margin.

The first step was getting the target list elements from the view DOM. I used the ViewChildren decorator for that:

// navbar.component.ts

    @ViewChildren('navElements') navElements: 
     QueryList<ElementRef>;

and this method to extract the new offsetWidth's:

// navbar.component.ts

  private getNewNavItemDOMWidths(navElementsList: any) {
    this.navItemDOMProps = navElementsList.map(item => ({
      width: item.nativeElement.offsetWidth
    }));
  }

Finally, I arrived to the reason why I used the word "sanity" in the headline.

This was the hardest part to figure out.

I asked myself which lifecycle method can I use to get the newest, freshly computed DOM style properties?

The most likely candidates were ngAfterViewInit() and ngAfterViewChecked(). All of the other methods fired way too early in the comp lifecycle.

But, to my surprise, calling the getNewNavItemDOMWidths() method from either of those two methods didn't work. I was still getting the old values (from the previous render).

So this:

ngAfterViewInit() { 
    this.getNewNavItemDOMWidths(this.navElements.toArray());
    this.calcNewIndicatorDOMStyles();
  }

or this:

ngAfterViewChecked() { 
    this.getNewNavItemDOMWidths(this.navElements.toArray());
    this.calcNewIndicatorDOMStyles();
  }

by itself didn't work.

Example.

Let's say that the current selected language was EN, and that the 4 nav items had widths 10, 20, 30, 40 (i am using random numbers here for the purpose of illustration).

Then if I change the language to DE, this will cause the actual DOM widths to change to 50, 60, 70, 80 - because the text length is different.

If I tried to console log this in the ngAfterViewInit() and ngAfterViewChecked() lifecyle methods, I would get 10, 20, 30, 40 (the values from the previous render)

How I managed to solve this problem.

I formulated the following questions:

Is the ngAfterViewChecked lifecycle method called again, after the template view re-renders and new DOM style properties are computed?

If not, why? How can I force it to run?

My investigation led me to the conclusion that Angular doesn't run this method by default when new DOM style properties are computed and available. It somehow needs to become aware, or forced, to re-run this method when the new styles become available.

So, I solved it like this:

ngAfterViewChecked() { 
    this.getNewNavItemDOMWidths(this.navElements.toArray());
    this.calcNewIndicatorDOMStyles();

    setTimeout(() => {}, 0);
  }

The call to the setTimeout browser API inside this method forces Angular to re-run it every time, just in case. Because the callback fn inside setTimeout can contain code which can potentially affect the View - after it has already been checked!

And as you probably already noticed the second place in which the this.calcNewIndicatorDOMStyles() is called, is inside the already mentioned lifecycle method.

What's interesting about this solution, is that it also covers the case when the "window" gets resized. Resizing the viewport will trigger this lifecycle method and the new DOM styles will be fetched and used to updated the slider.

And that's it, more or less.

You can find the entire source code here

- The end of this journey

Thanks for reading until the end.

I hope you learned something new about Angular. Or that the code I provided will help you on future projects.

Speaking of which, I have a question for the Angular experts who read through this entire post.

What do you think about my implementation? Is it fine, or is it an obvious antipattern? Is there something I could have done better? Thx

35