Angular: How to save scroll position when navigating

johncarroll

John Carroll

Posted on December 20, 2020

Angular: How to save scroll position when navigating

If your app makes use of the Angular Router, you'd probably like for a user's scroll position to be remembered when they use the "back" button to navigate back to a page they were already on. In a standard "static" webpage, the browser does this for the user automatically. This doesn't happen automatically in an angular app however and there are a few reasons why.

  1. Even for static sites, the browser will only refresh the scroll position of the <body> element. In a single page app, it's quite likely that you'd like to refresh the scroll position of other elements. For example, if you're showing an admin dashboard, maybe there are a number of divs on the page that you'd like to independently refresh the scroll position for.

  2. Before you can refresh the scroll position of an element, you need to wait for all the data that element needs to load. For example, if you have a <my-list> component and you try to refresh the scroll position before the <my-list> items have finished loading, nothing will happen because the <my-list> component's height in the DOM will not account for all of the items that haven't been loaded.

So how can we accomplish our goal? Well, you could try the Angular Router's built in scroll position refresh functionality, but you'll probably be disappointed to learn that it only works for the page's <body> element and it also requires you to use Angular Resolvers for all of your data. In practice, I don't find it to be useful. There's also very little documentation for it.

Here's an alternative solution that's very flexible: if you're using the small IsLoadingService to manage your app's loading state (and I highly recommend that you do), then you have a simple, centralized way of checking if something is loading. This sets us up to build a ScrollPositionDirective (@service-work/scroll-position) which automatically saves an elements scroll position on router navigation, and automatically refreshes an element's scroll position after all the pieces of an element have finished loading.

This post builds off of Angular: How to easily display loading indicators. If you haven't read that post, I recommend you start there and then come back here.

Now, let's look at some code! Here are a few examples which build off of each other. You'll see that most of these examples also use the IsLoadingService from the @service-work/is-loading package, which the ScrollPositionDirective depends on.

Refresh the scroll position for a synchronous element

Let's start easy. We want to refresh the scroll position of an element that doesn't depend on any asynchronous data.

@Component({
  selector: 'app-lorum-ipsum',
  template: `
    <div
      swScrollPosition='lorum-ipsum'
      style="height: 35px; overflow: auto;"
    >
      <p>
        Class aptent taciti sociosqu ad litora torquent 
        per conubia nostra, per inceptos himenaeos. In 
        ultricies mollis ante. Phasellus mattis ut turpis 
        in vehicula. Morbi orci libero, porta ac tincidunt a,
        hendrerit sit amet sem.
      </p>
    </div>
  `,
})
export class LorumIpsumComponent {}
Enter fullscreen mode Exit fullscreen mode

Simply by applying the [swScrollPosition] directive to the div element (seen here with the key "lorum-ipsum"), Angular will now automatically remember and refresh this element's scroll position when you navigate away from, and back to, this component (admittedly, this example component doesn't have that much text so I'd imagine most of the time everything can fit in the viewport without needing a scrollbar).

Refresh the scroll position for an asynchronous element

Let's look at a more realistic example, say we have a contact list component and we want to automatically refresh the scroll position when a user navigates back to this component.

@Component({
  selector: 'app-user-list',
  template: `
    <ul
      id="user-list"
      swScrollPosition='users-loading'
    >
      <li
        *ngFor='let user of users | async'
        [routerLink]="['/user', user.id, 'profile']"
      >
        {{ user.name }}
      </li>
    </ul>
  `,
})
export class UserListComponent {
  users: Observable<IUser[]>;

  constructor(
    private isLoadingService: IsLoadingService,
    // assume UserService is a service you've created to fetch
    // user data
    private userService: UserService,
  ) {}

  ngOnInit() {
    this.users = this.isLoadingService.add(
      // assume `UserService#getUsers()` returns `Observable<IUser[]>`
      this.userService.getUsers(),
      { key: 'users-loading' }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Similar to the previous example, the UserListComponent will automatically refresh the #user-list element's scroll position when a user navigates back to this component. Unlike in the previous example however, here the ScrollPositionDirective will wait until the "users-loading" key has finished loading (i.e. swScrollPosition='users-loading') before attempting to refresh the #user-list element's scroll position.

Refresh the scroll position for an asynchronous element and show a loading indicator while data is loading

Let's expand on the previous example. Say you want to show a loading indicator while the #user-list element is loading. Here, we'll use the Angular Material MatProgressSpinner component as our loading spinner, along with the IsLoadingPipe (i.e. swIsLoading pipe) from @service-work/is-loading.

@Component({
  selector: 'app-user-list',
  template: `
    <mat-spinner
      *ngIf="'users-loading' | swIsLoading | async; else showContent"
    >
    </mat-spinner>

    <ng-template #showContent>
      <ul
        id="user-list"
        swScrollPosition='users-loading'
      >
        <li
          *ngFor='let user of users | async'
          [routerLink]="['/user', user.id, 'profile']"
        >
          {{ user.name }}
        </li>
      </ul>
    </ng-template>
  `,
})
export class UserListComponent {
  users: Observable<IUser[]>;

  constructor(
    private isLoadingService: IsLoadingService,
    private userService: UserService,
  ) {}

  ngOnInit() {
    this.users = this.isLoadingService.add(
      this.userService.getUsers(),
      { key: 'users-loading' }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

What's happening?

Behind the scenes, the ScrollPositionDirective will subscribe to the appropriate IsLoadingService loading state as specified by a string key you pass the directive. For example, if you setup the directive with swScrollPosition='users-loading', then the ScrollPositionDirective will use the IsLoadingService to subscribe to the "users-loading" loading state and wait for loading to emit false. When it does so, it will refresh any saved scroll position it has for that element.

Psyudo-code:

class ScrollPositionDirective {
  ngAfterViewInit() {
    this.isLoadingService
      .isLoading$({ key: this.key })
      .pipe(
        filter((v) => !v),
        take(1),
       )
       .subscribe(() => this.refresh())
  }
}
Enter fullscreen mode Exit fullscreen mode

The ScrollPositionDirective will also subscribe to Angular Router navigation events. When the router emits a ResolveEnd event, then the directive will grab the host element's current scroll position and save it with a key derived from the provided loading key and the current URL. For advanced usage, if there are parts of your application's URL that you want ignored by the ScrollPositionDirective (e.g. specific query params), then you can provide a custom url serialization function to ScrollPositionDirective by re-providing the SW_SCROLL_POSITION_CONFIG.

Psyudo-code:

private getPositionKey(userProvidedKey: string) {
  return `${userProvidedKey}::` + this.config.urlSerializer(this.router.url);
}
Enter fullscreen mode Exit fullscreen mode

The loading key (e.g. "users-loading") is also how the ScrollPositionDirective differentiates between different loading elements on the same page. And the URL is how the ScrollPositionDirective differentiates between the same element on different pages.

Conclusion

And that's pretty much it. There's some additional customization you can do for the ScrollPositionDirective. For example, you can set an optional swScrollPositionDelay which adds a delay (in milliseconds) before the scroll position is refreshed. You can also set swScrollPositionSaveMode='OnDestroy' to have the ScrollPositionDirective save its host's scroll position OnDestroy rather than OnNavigation. This is useful (and necessary) if the host component is inside of an ngIf structural directive and is being shown / hidden by logic other than page navigation.

You can check it out at: https://gitlab.com/service-work/is-loading

💖 💪 🙅 🚩
johncarroll
John Carroll

Posted on December 20, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related