Angular: How to save scroll position when navigating
Tags: angular, ux, githunt
Published: 2020-12-20 11:40:43

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](https://angular.io/api/common/ViewportScroller), 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`](https://gitlab.com/service-work/is-loading#angular-isloading-scrollposition) 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`](https://gitlab.com/service-work/is-loading#angular-isloading-scrollposition) (`@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](https://dev.to/johncarroll/angular-how-to-easily-display-loading-indicators-4359). 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.

 - Alternatively, here's a codesandbox demo: https://codesandbox.io/s/isloadingservice-example-ujlgm?file=/src/app/app.component.ts

## 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.

```ts
@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 {}
```

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.

```ts
@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' }
    );
  }
}
```

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](https://material.angular.io/components/progress-spinner/overview) as our loading spinner, along with the `IsLoadingPipe` (i.e. `swIsLoading` pipe) from `@service-work/is-loading`.

```ts
@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' }
    );
  }
}
```

## 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:_

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

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:_

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

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