The short and sweet answer is "don't iterate over the entire array" but that wasn't good enough for me. I wanted it to look like the entire column of entries was present. So I put a spacer above, the ngFor iterates over a subsection of the array, and a spacer below and together this makes the list look like all the elements are there all the time.
Here's a simplified version of my html with only the relevant parts to this problem (full example on bitbucket):
<div (scroll)="ColScroll($event)">
<div [style.height]="Math.max(0, Math.max(0, scrollPos - 10) * 132)"></div>
<entry *ngFor="let p of (base.pokemon | filter:search:SelectedVer:SelectedLang) | justafew:scrollPos" [pokemon]="p"></entry>
<div [style.height]="Math.max(0,((base.pokemon | filter:search:SelectedVer:SelectedLang).length - scrollPos - 40)) * 132"></div>
</div>
Ultra-minimal structure for absolute clarity:
<div> <!-- column -->
<div></div> <!-- spacer -->
<entry *ngFor='...'></entry>
<div></div> <!-- spacer -->
</div>
First, a very key point: <entry>
is always exactly 120 pixels tall with a 12 pixel bottom margin totaling 132 pixels of total space. The CSS makes this absolute. This works for whatever constant size I wanted to pick, but I make special assumptions that the size is exactly 132 pixels.
The short version is that as you scroll the column's scrollHeight determines which entries should actually be on screen. If the first 10 elements that the ngFor actually builds are off screen then the first visible element begins at number 11. I account for a 4k screen and show 40 entries (taking up 5280 pixels) to be sure that the entire column looks full. Then, so the scrollbar looks correct, I have a spacer below the 40 entries to force the div to have the proper scrollable height. Here's an image of what's visually going on:
Here's the relevant variables and functions in the controller (bitbucket):
scrollPos = 0;
...
ColScroll(event: Event) {
let pos = $(event.target).scrollTop();
this.scrollPos = Math.floor(pos / 132);
}
It kills me to use jQuery here but I was already using it for something else and I needed something cross-browser. scrollPos
holds the first index of the first item that I should be showing on screen.
The ngFor that actually builds all the <entry>
elements looks like this:
*ngFor="let p of (base.pokemon | filter:search:SelectedVer:SelectedLang) | justafew:scrollPos"
Breaking that down:
base.pokemon
is an array of the pokemon data necessary to create each entry element.
... | filter:search:SelectedVer:SelectedLang)
is used for searching through the list. I leave it in my sample here to show that you can still play with the list before my hack comes into play.
... | justafew:scrollPos
is where the magic happens. Here's that filter in it's entirety (bitbucket):
import { Pipe, PipeTransform } from '@angular/core';
import { MinPokemon } from '../models/base';
@Pipe({
name: 'justafew',
pure: false
})
export class JustAFewPipe implements PipeTransform {
public transform(value: MinPokemon[], start: number): MinPokemon[] {
return value.slice(Math.max(0, start - 10), start + 30);
}
}
scrollPos
was passed in as the start
parameter. For example, if I've scrolled 13200 pixels down my column then scrollPos
would be set to 100 (see the scrolling event in the controller above). This will slice the array so that it returns elements 90 through 130. I want to overflow the screen a little to ensure that fast scrolling won't result in visible white space (insanely fast scrolling might still show it but you're moving so fast it's easy to think that the browser simply hasn't rendered that fast so I let it slide). I use Math.max
so I don't slice using negative numbers such as when I'm at the very top of the list and scrollPos
is 0.
Now the spacers. They keep the scrollbar honest. I bind their [style.height]
and use a little math to make these spacers take up the required space. As I scroll down, the top spacer grows taller and the bottom spacer shrinks by the exact same amount so the column is always the same height. When I scroll back up the math works out just the opposite: the top shrinks and the bottom grows. The bottom spacer uses the exact same filter logic as the ngFor so that if I run a search that returns 100 instead of 721 pokemon it adjusts to the height of 100 entries. The first spacer using scrollPos - 10
because the justafew
filter goes back 10. For the same reason, the bottom spacer uses scrollPos - 30
because that's how many justafew
returns.
I know it looks like a lot of moving parts but they're all simple and quick. Unfortunately there are a lot of "magic numbers" all over the place that rely on each other but considering the performance improvements and reliability this gave me over showing the entire list I let it slide. Maybe someday I'll make a component or directive to put it all in one configurable place.
UPDATE: 2 1/2 years or so later and with Angular 7's release there's now a Angular Material package for virtual scrolling. I made a few changes to my site and got virtual scrolling working in about an hour. Even with component recycling. I thoroughly recommend using Angular Material for virtual scrolling.