Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(template): introduce experimental virtual-scrolling sub-package #1539

Merged
merged 28 commits into from
Apr 21, 2023

Conversation

hoebbelsB
Copy link
Member

@hoebbelsB hoebbelsB commented Apr 13, 2023

Introducing @rx-angular/template/experimental/virtual-scrolling submodule 🥳 🔥 🔥 🔥

rxa vs cdk perf comparison

Usage

import {
  FixedSizeVirtualScrollStrategy, // choose any strategy you like
  RxVirtualScrollViewportComponent,
  RxVirtualFor,
} from '@rx-angular/template/experimental/virtual-scrolling';

@Component({
  standalone: true,
  imports: [
    RxVirtualFor,
    RxVirtualScrollViewportComponent,
    FixedSizeVirtualScrollStrategy,
  ],
})
export class MyComponent {}
<rx-virtual-scroll-viewport>
  <div [itemSize]="50" *rxVirtualFor="let hero of heroes$;">
    <div>
      <div><strong>{{ hero.name }}</strong></div>
      <div>{{ hero.id }}</div>
      <div>{{ hero.description }}</div>
    </div>
  </div>
</rx-virtual-scroll-viewport>

Demo

Check out the Demo Application. You can play around with
all pre-packaged ScrollStrategies as well as control the majority of inputs.

layout-techniques.mp4

Performance Benchmark and CDK comparison

https://github.com/hoebbelsB/rxa-virtual-scroll#performance-benchmarks

Components & Directives

RxVirtualFor

The *rxVirtualFor structural directive implements the RxVirtualViewRepeater and is responsible to create, update, move and remove views
from the bound data.
As RxFor, RxVirtualFor treats each child template as single renderable unit.
By default the change detection of the child templates get prioritized, scheduled and executed by leveraging RenderStrategies under the hood.
This technique enables non-blocking rendering of lists and can be referred to as concurrent mode.

Read more about the concurrent mode in the concurrent strategies section in the RxAngular docs.

Inputs

Input Type description
trackBy keyof T or (index: number, item: T) => any Identifier function for items. rxVirtualFor provides a shorthand where you can name the property directly.
patchZone boolean default: true if set to false, the RxVirtualForDirective will operate out of NgZone. See NgZone optimizations
parent boolean default: false if set to false, the RxVirtualForDirective won't inform its host component about changes being made to the template. More performant, @ViewChild and @ContentChild queries won't work. Handling view and content queries
strategy Observable<RxStrategyNames \ string> \ RxStrategyNames \ string> default: normal configure the RxStrategyRenderStrategy used to detect changes. Render Strategies
renderCallback Subject<U> giving the developer the exact timing when the RxVirtualForDirective created, updated, removed its template. Useful for situations where you need to know when rendering is done.
viewCacheSize number default: 20 Controls the amount if views held in cache for later re-use when a user is scrolling the list If this is set to 0, rxVirtualFor won't cache any view, thus destroying & re-creating very often on scroll events.

RxVirtualScrollViewportComponent

Container component comparable to CdkVirtualScrollViewport acting as viewport for *rxVirtualFor to operate on.
Its main purpose is to implement the RxVirtualScrollViewport interface as well as maintaining the scroll runways'
height in order to give the provided RxVirtualScrollStrategy room to position items. Furthermore, it will gather and forward
all events to the consumer of rxVirtualFor.

Outputs

Output Type description
viewRange ListRange: { start: number; end: number; } The range to be rendered by *rxVirtualFor. This value is determined by the provided RxVirtualScrollStrategy. It gives the user information about the range of items being actually rendered to the DOM. Note this value updates before the renderCallback kicks in, thus it is only in sync with the DOM when the next renderCallback emitted an event.
scrolledIndexChange number The index of the currently scrolled item. The scrolled item is the topmost item actually being visible to the user.

RxVirtualScrollStrategy

The RxVirtualScrollStrategy is responsible for positioning the created views on the viewport.
The three pre-packaged scroll strategies share similar concepts for layouting views.
All of them provide a twitter-like virtual-scrolling implementation, where views are positioned absolutely and transitioned by
using css transforms.
They also share two inputs to define the amount of views to actually render on the screen.

Input Type description
runwayItems number default: 10 The amount of items to render upfront in scroll direction
runwayItemsOpposite number default: 2 The amount of items to render upfront in reverse scroll direction

See the layouting technique in action in the following video. It compares @rx-angular/template vs. @angular/cdk/scrolling

layout-techniques.mp4

FixedSizeVirtualScrollStrategy

The FixedSizeVirtualScrollStrategy positions views based on a fixed size per item. It is comparable to @angular/cdk/scrolling FixedSizeVirtualScrollStrategy,
but with a high performant layouting technique.

Demo

The default size can be configured directly as @Input('itemSize').

Example

// my.component.ts
import {
  FixedSizeVirtualScrollStrategyModule,
  RxVirtualScrollViewportComponent,
} from '@rx-angular/template/experimental/virtual-scrolling';

@Component({
  /**/,
  standalone: true,
  imports: [FixedSizeVirtualScrollStrategyModule, RxVirtualScrollViewportComponent]
})
export class MyComponent {
  // all items have the height of 50px
  itemSize = 50;

  items$ = inject(DataService).getItems();
}
<rx-virtual-scroll-viewport [itemSize]="itemSize">
  <div class="item" *rxVirtualFor="let item of items$;">
    <div>{{ item.id }}</div>
    <div>{{ item.content }}</div>
    <div>{{ item.status }}</div>
    <div>{{ item.date | date }}</div>
  </div>
</rx-virtual-scroll-viewport>

DynamicSizeVirtualScrollStrategy

The DynamicSizeVirtualScrollStrategy is very similar to the AutosizeVirtualScrollStrategy. Instead of hitting the DOM, it calculates the size
based on a user provided function of type (item: T) => number. Because it doesn't have to interact with the DOM in order to position views,
the DynamicSizeVirtualScrollStrategy has a better runtime performance compared to the AutosizeVirtualScrollStrategy.

This strategy is very useful for scenarios where you display different kind of templates, but already know the dimensions of
them.

Demo

Example

// my.component.ts
import {
  DynamicSizeVirtualScrollStrategy,
  RxVirtualScrollViewportComponent,
} from '@rx-angular/template/experimental/virtual-scrolling';

@Component({
  /**/,
  standalone: true,
  imports: [DynamicSizeVirtualScrollStrategy, RxVirtualScrollViewportComponent]
})
export class MyComponent {
  // items with a description have 120px height, others only 50px
  dynamicSize = (item: Item) => (item.description ? 120 : 50);

  items$ = inject(DataService).getItems();
}
<!--my.component.html-->
<rx-virtual-scroll-viewport [dynamic]="dynamicSize">
  <div class="item" *rxVirtualFor="let item of items$;">
    <div>{{ item.id }}</div>
    <div>{{ item.content }}</div>
    <div>{{ item.status }}</div>
    <div>{{ item.date | date }}</div>
    <div *ngIf="item.description">{{ item.description }}</div>
  </div>
</rx-virtual-scroll-viewport>

AutosizeVirtualScrollStrategy

The AutosizeVirtualScrollStrategy is able to render and position
items based on their individual size. It is comparable to @angular/cdk/experimental AutosizeVirtualScrollStrategy, but with
a high performant layout technique, better visual stability and added features.
Furthermore, the AutosizeVirtualScrollStrategy is leveraging the ResizeObserver in order to detect size changes for each individual
view rendered to the DOM and properly re-position accordingly.

For views it doesn't know yet, the AutosizeVirtualScrollStrategy anticipates a certain size in order to properly size the runway.
The size is determined by the @Input('tombstoneSize') and defaults to 50.

In order to provide top runtime performance the AutosizeVirtualScrollStrategy builds up caches that
prevent DOM interactions whenever possible. Once a view was visited, its properties will be stored instead of re-read from the DOM
again as this can potentially lead to unwanted forced reflows.

Demo

Example

// my.component.ts
import {
  AutosizeVirtualScrollStrategy,
  RxVirtualScrollViewportComponent,
} from '@rx-angular/template/experimental/virtual-scrolling';

@Component({
  /**/,
  standalone: true,
  imports: [AutosizeVirtualScrollStrategy, RxVirtualScrollViewportComponent]
})
export class MyComponent {
  items$ = inject(DataService).getItems();
}
<rx-virtual-scroll-viewport autosize>
  <div class="item" *rxVirtualFor="let item of items$;">
    <div>{{ item.id }}</div>
    <div>{{ item.content }}</div>
    <div>{{ item.status }}</div>
    <div>{{ item.date | date }}</div>
  </div>
</rx-virtual-scroll-viewport>

Configuration

RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS

By providing a RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS token, you can pre-configure default settings for
the directives of the @rx-angular/template/experimental/virtual-scrolling package.

import { RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS } from '@rx-angular/template/experimental/virtual-scrolling';

@NgModule({
  providers: [{
      provide: RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS,
      useValue: { // should be of type `RxVirtualScrollDefaultOptions`
        runwayItems: 50,
        // turn off cache by default
        viewCacheSize: 0
      }
  }]
})

Default Values

/* determines how many templates can be cached and re-used on rendering */
const DEFAULT_VIEW_CACHE_SIZE = 20;
/* determines how many views will be rendered in scroll direction */
const DEFAULT_ITEM_SIZE = 50;
/* determines how many views will be rendered in the opposite scroll direction */
const DEFAULT_RUNWAY_ITEMS = 10;
/* default item size to be used for scroll strategies. Used as tombstone size for the autosized strategy */
const DEFAULT_RUNWAY_ITEMS_OPPOSITE = 2;

RxVirtualScrollDefaultOptions

export interface RxVirtualScrollDefaultOptions {
  /* determines how many templates can be cached and re-used on rendering, defaults to 20 */
  viewCacheSize?: number;
  /* determines how many views will be rendered in scroll direction, defaults to 15 */
  runwayItems?: number;
  /* determines how many views will be rendered in the opposite scroll direction, defaults to 5 */
  runwayItemsOpposite?: number;
  /* default item size to be used for scroll strategies. Used as tombstone size for the autosized strategy */
  itemSize?: number;
}

Missing Features (Roadmap)

The following section describes features that are currently not implemented, but planned.

Support other orientations

Right now, the @rx-angular/template/experimental/virtual-scrolling package only supports vertical scrolling. In the future, it should also
be able to support horizontal scrolling.

Tombstones

Tombstones, skeletons or placeholder templates are a nice way to improve the scrolling performance, especially when the actual views being rendered
are heavy and take a long time to create. Especially for the autosized strategy this can increase the visual stability and runtime performance a lot.

The concept is described in the article Complexities of an infinite scroller
and visible in the corresponding demo.

@nx-cloud
Copy link

nx-cloud bot commented Apr 13, 2023

☁️ Nx Cloud Report

CI is running/has finished running commands for commit fb5a511. As they complete they will appear below. Click to see the status, the terminal output, and the build insights.

📂 See all runs for this branch


✅ Successfully ran 6 targets

Sent with 💌 from NxCloud.

@github-actions github-actions bot added </> Template @rx-angular/template related 📚 Docs Web Documentation hosted on github pages 🔬 Experimental Experimental: Feature, docs, demos labels Apr 13, 2023
Copy link
Member

@edbzn edbzn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work! I left some initial thoughts.

standalone: true,
})
// eslint-disable-next-line @angular-eslint/directive-class-suffix
export class FixedSizeVirtualScrollStrategy<
Copy link
Member

@edbzn edbzn Apr 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like a lot of code is duplicated between strategies, would it make sense to factorize?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

autosize & dynamic size share a lot of code. would be nice to factorize, yes

this.viewport!.containerRect$.pipe(
map(({ height }) => height),
distinctUntilChanged()
),
onScroll$,
this.runwayStateChanged$.pipe(startWith(void 0)),
this.recalculateRange$.pipe(startWith(void 0)),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason to use void 0 instead of undefined?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, i'm just used to it

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, a subject that is typed as void should emit void. I guess emitting undefined instead would result in an error when strict mode is turned on

…rolling (#1542)

* test(template): setup cypress component tests

* test(template): implement first test for virtual-scrolling

* test(template): implement tests for DynamicSizeVirtualScrollStrategy

* test(template): implement tests for AutosizeVirtualScrollStrategy

* test(template): stabilize virtual scrolling component tests for CI

* chore: integrate component-test into ci
.github/workflows/build-and-test.yml Show resolved Hide resolved
@edbzn edbzn enabled auto-merge (squash) April 21, 2023 17:08
@edbzn edbzn disabled auto-merge April 21, 2023 17:08
@edbzn edbzn merged commit 786f87c into main Apr 21, 2023
13 of 15 checks passed
@edbzn edbzn deleted the feat/virtual-scrolling branch April 21, 2023 17:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
📚 Docs Web Documentation hosted on github pages 🔬 Experimental Experimental: Feature, docs, demos </> Template @rx-angular/template related
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants