Saving scroll position with RouteReuseStrategy
In the previous post we discussed the basics of RouteReuseStrategy. We have created a sample implementation of the strategy and made it to work. One of the main issues here is that RouteReuseStrategy can not manage your scrolls. Let’s try to solve it
An issue
Our main goal was to reuse components that were already initialized. Angular can store the component context, scope and element and use them again when RouteReuseStartegy asks about it.
But Angular stores just the DOM. If we want to store our scrolls position, we should mind that scroll is outside of the DOM.
Concept
So, we have to implement saving the scrolls by ourselves. There may be a lot of ways here:
- custom directive;
- component-wide provider;
- custom behaviour inside our RouteReuseStrategy implementation.
Here are my thoughts about it; may be I’m wrong about something and my solution is not the simplest and the easiest to use but it works good and is extendable and reusable
Custom directive
Why not: How can we decide when should we restore the scroll? Where is NavigationEnd event? Should we subscribe to each NavigationEnd? What about performance issues and memory leaks?
Custom behaviour inside RouteReuseStrategy
Why not: how to store scrolls in global context? What if we have more than one component instance? How to restore scrolls after async calls, not just after restoring the route snapshot?
Component-wide provider
A good way is to create custom provider, that shoud be able to store links to your HTMLElements and scrolls and to handle router events and custom emitters from the component
class ElementScroll {
el: HTMLElement;
scroll: number;
}
class ScrollStoreConfig {
componentContext: any;
router: Router;
route: ActivatedRoute;
storage: {
[key: string]: ElementScroll
};
}
export class ScrollStoreProvider {
public config: ScrollStoreConfig = <ScrollStoreConfig>{};
}
Here we have storage config with external links to the component context, router and current route
constructor(options: {
compContext: any,
router: Router,
route: ActivatedRoute
}) {
this.config.componentContext = options.compContext;
this.config.router = options.router;
this.config.route = options.route;
this.config.storage = {};
// subscribe to NavigationEnd // subscribe to async updates
}
Note that our provider is not @Injectable()
, we need to create the instance manually with custom options in component context:
this.scrollStoreProvider = new ScrollStoreProvider({
compContext: this,
router: this.router,
route: this.route
})
Router will be used to subscribe to NavigationEnd event, ActivatedRoute and component reference — to match current component from the route with our parent component
Handling scroll events
Now we want to have a method that takes a reference to the scrollable element and does all this stuff:
private storeScroll(key: string, el: HTMLElement) {
this.config.storage[key] = <ElementScroll>{
el: el,
scroll: $(el).scrollTop()
};
}public handleScroll(key: string, el: HTMLElement) {
let providerRef = this;
$(el).scroll(()=>{
providerRef.storeScroll(key, el);
})
}
Now we can call it in our component:
this.scrollStoreProvider
.handleScroll("key", this.viewChild.nativeElement);
So, we just say “please take this element and manage it’s scroll” to the provider. Scroll position will be saved there on each call
Restoring scroll position
We need to restore scroll position after going back to this page — another words, on each NavigationEnd event. This events fires each time when you go to another route inside your Angular application. We do not want to restore scroll position on each navigation, so we need to match the component from the current route with our parent component:
this.config.router.events.subscribe((event: NavigationEnd) => {
if (event instanceof NavigationEnd
&& this.config.componentContext
instanceof (this.config.route.component as Function)) {
setTimeout(()=>{
this.restoreAll();
})
}
}, error => console.error(error));
If you want to restore any async data, just declare custom emitter in your parent component and handle it here:
if (this.config.componentContext.resultsReady) {
let subscription = this.config.componentContext.resultsReady.subscribe(()=>{
setTimeout(()=>{
this.restoreAll();
})
subscription.unsubscribe();
})
}
Do not forget to unsubscribe, or your scroll will jump each time you get any data
Result
class ElementScroll {
el: HTMLElement;
scroll: number;
}
class ScrollStoreConfig {
componentContext: any;
router: Router;
route: ActivatedRoute;
storage: {
[key: string]: ElementScroll
};
}
export class ScrollStoreProvider {
public config: ScrollStoreConfig = <ScrollStoreConfig>{};
constructor(options: {
compContext: any,
router: Router,
route: ActivatedRoute
}) {
// init
// subscribe to NavigationEnd
// subscribe to async updates
private restoreAll() {...}
public clearStorage() {...}
public storeScroll(key: string, el: HTMLElement) {...}
public handleScroll(key: string, el: HTMLElement) {...}
}
Finally
I am not sure that this approach is the best, but it works and it is reliable, extendable and reusable. Now we can restore scroll position for our lists and grids
I have not implemented horizontal scroll in this sample but you can implement it the same way. If you have any ideas, please leave your comments, may be we will find a better solution!