John Carroll
Posted on December 20, 2020
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.
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.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'sheight
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.
- 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.
@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.
@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 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' }
);
}
}
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())
}
}
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);
}
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
Posted on December 20, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.