Saving scroll position with RouteReuseStrategy

Pavel Gerasimov
4 min readSep 19, 2017

--

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!

--

--

Pavel Gerasimov

Senior Engineering Manager at Wrike. Growth Engineering, Org and Leadership Transformation. Former CEO and co-founder of Le Talo Robotics