diff --git a/libs/template/CHANGELOG.md b/libs/template/CHANGELOG.md index 40822390b2..a7275f9f2d 100644 --- a/libs/template/CHANGELOG.md +++ b/libs/template/CHANGELOG.md @@ -2,6 +2,15 @@ This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver). +# [1.1.0](https://github.com/rx-angular/rx-angular/compare/template@1.0.0...template@1.1.0) (2023-04-13) + + +### Features + +* **template:** introduce experimental virtual-scrolling package ([dcd98b0](https://github.com/rx-angular/rx-angular/commit/dcd98b0b9fca831e03507af2b5e71985191b4ebf)) + + + # [1.0.0](https://github.com/rx-angular/rx-angular/compare/template@1.0.0-rc.5...template@1.0.0) (2023-01-29) diff --git a/libs/template/README.md b/libs/template/README.md index 11080a87c9..9d6ea7af9e 100644 --- a/libs/template/README.md +++ b/libs/template/README.md @@ -21,6 +21,7 @@ **Experimental features** - [🧪 Viewport Priority (viewport-prio)](https://rx-angular.io/docs/template/api/experimental/viewport-prio-directive) +- [🧪 Virtual Scrolling (`*rxVirtualFor`)](https://github.com/rx-angular/rx-angular/tree/feat-virtual-scroll/libs/template/experimental/virtual-scrolling#readme) All experimental features are very stable and already tested in production apps for multiple months. The reason to have them in experimental is so we can make small typing changes without breaking changes. diff --git a/libs/template/experimental/virtual-scrolling/README.md b/libs/template/experimental/virtual-scrolling/README.md new file mode 100644 index 0000000000..3a4e5ee9f7 --- /dev/null +++ b/libs/template/experimental/virtual-scrolling/README.md @@ -0,0 +1,564 @@ +# RxVirtualScroll + +A **high performance** alternative to [`@angular/cdk/scrolling`](https://material.angular.io/cdk/scrolling/) virtual scrolling implementation. + +![rxa-vs-cdk](docs/images/rxa-vs-cdk.png) + +Instead of rendering every item provided, `rxVirtualFor` only renders what is currently visible to the user, thus providing +excellent runtime performance for huge sets of data. + +The technique to render items is comparable to the one used by twitter and +explained in great detail by @DasSurma in his blog post about the +[complexities of infinite scrollers](https://developer.chrome.com/blog/infinite-scroller/). + +"Each recycling of a DOM element would normally relayout the entire runway which would bring us well below our target +of 60 frames per second. To avoid this, we are taking the burden of layout onto ourselves and use absolutely positioned +elements with transforms." (@DasSurma) + +# TOC + +- [Usage](#usage) +- [Demo](#demo) +- [Features](#features) +- [Missing Features (Roadmap)](#missing-features-roadmap) +- [Docs](#docs) + - [RxVirtualFor](#rxvirtualfor) + - [RxVirtualScrollViewportComponent](#rxvirtualfor) + - [RxVirtualScrollStrategy](#rxvirtualscrollstrategy) + - [Configuration](#configuration) +- [Performance Benchmarks](#performance-benchmarks) +- [Version Compatibility](#version-compatibility) + +## Usage + +```ts +import { + FixedSizeVirtualScrollStrategy, + RxVirtualScrollingModule, +} from '@rx-angular/template/experimental/virtual-scrolling'; + +@NgModule({ + imports: [RxVirtualScrollingModule, FixedSizeVirtualScrollStrategy], +}) +export class MyModule {} +``` + +```html + +
+
+
{{ hero.name }}
+
{{ hero.id }}
+
{{ hero.description }}
+
+
+
+``` + +## Demo + +Check out the [Demo Application](https://hoebbelsb.github.io/rxa-virtual-scroll/). You can play around with +all pre-packaged ScrollStrategies as well as control the majority of inputs. + +## Features + +**DX Features** + +- reduces boilerplate (multiple `async` pipe's) +- works also with static variables `*rxVirtualFor="let i of myData"` +- Immutable as well as mutable data structures (`trackBy`) +- Notify when rendering of templates is finished (`renderCallback`) + +**Performance Features** + +- lazy template creation (done by [Render Strategies](https://www.rx-angular.io/docs/cdk/render-strategies)) +- non-blocking rendering of lists [Concurrent Strategies](https://www.rx-angular.io/docs/cdk/render-strategies/strategies/concurrent-strategies) +- configurable frame budget (defaults to 60 FPS) +- Super efficient layouting with css transformations +- Scoped layouting with css containment +- Define a viewCache in order to re-use views instead of re-creating them +- triggers change-detection on `EmbeddedView` level +- Zone-agnostic, opt-out of `NgZone` with `patchZone` +- 3 Configurable `RxVirtualScrollStrategy` providing the core logic to calculate the viewRange and position DOM + Nodes + - `FixedSizeVirtualScrollStrategy` + - `AutosizeVirtualScrollStrategy` + - `DynamicSizeVirtualScrollStrategy` + +## Missing Features (Roadmap) + +The following section describes features that are currently not implemented, but planned. + +### Migrate to latest angular + +Right now, this library is built with angular version 12 in order to have the first version being compatible with `@angular/core: >= 12`. +It is planned to migrate to the latest angular version and adapt to new features like standalone components, provider functions and standalone components. + +### 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](https://developer.chrome.com/blog/infinite-scroller/#tombstones) +and visible in the corresponding [demo](http://googlechromelabs.github.io/ui-element-samples/infinite-scroller/). + +# Docs + +The `@rx-angular/template/experimental/virtual-scrolling` package can be seen as a high performant competitor of the +official `@angular/cdk/scrolling`. +The API is heavily inspired by `@angular/cdk/scrolling` and is divided into multiple +core components which have to be glued together: + +- `RxVirtualViewRepeater`, implemented by `RxVirtualFor` +- `RxVirtualScrollViewport`, implemented by `RxVirtualScrollViewportComponent` +- `RxVirtualScrollStrategy`, implemented by `AutosizeVirtualScrollStrategy`, `FixedSizeVirtualScrollStrategy` & `DynamicSizeVirtualScrollStrategy` + +## API + +### RxVirtualFor + +The `*rxVirtualFor` structural directive implements the `RxVirtualViewRepeater` and is responsible to create, update, move and remove views +from the bound data. +As [`RxFor`](https://www.rx-angular.io/docs/template/api/rx-for-directive), `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`](https://www.rx-angular.io/docs/cdk/render-strategies) 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](https://www.rx-angular.io/docs/cdk/render-strategies/strategies/concurrent-strategies) 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](https://www.rx-angular.io/docs/template/performance-issues/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](https://www.rx-angular.io/docs/template/performance-issues/handling-view-and-content-queries) | +| `strategy` | `Observable \ RxStrategyNames \ string>` | _default: `normal`_ configure the `RxStrategyRenderStrategy` used to detect changes. [Render Strategies](https://www.rx-angular.io/docs/cdk/render-strategies) | +| `renderCallback` | `Subject` | 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. | + +#### Context Variables + +The following context variables are available for each template: + +**Static Context Variables (mirrored from `ngFor`)** + +| Variable Name | Type | description | +| ------------- | --------- | ---------------------------------------------------- | +| `$implicit` | `T` | the default variable accessed by `let val` | +| `index` | `number` | current index of the item | +| `count` | `number` | count of all items in the list | +| `first` | `boolean` | true if the item is the first in the list | +| `last` | `boolean` | true if the item is the last in the list | +| `even` | `boolean` | true if the item has on even index (index % 2 === 0) | +| `odd` | `boolean` | the opposite of even | + +**Reactive Context Variables** + +| Variable Name | Type | description | +| ------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `item$` | `Observable` | the same value as $implicit, but as `Observable` | +| `index$` | `Observable` | index as `Observable` | +| `count$` | `Observable` | count as `Observable` | +| `first$` | `Observable` | first as `Observable` | +| `last$` | `Observable` | last as `Observable` | +| `even$` | `Observable` | even as `Observable` | +| `odd$` | `Observable` | odd as `Observable` | +| `select` | `(keys: (keyof T)[], distinctByMap) => Observable>` | returns a selection function which accepts an array of properties to pluck out of every list item. The function returns the selected properties of the current list item as distinct `Observable` key-value-pair. | + +**Use the context variables** + +```html + +
+
{{ count }}
+
{{ index }}
+
{{ item }}
+
{{ first }}
+
{{ last }}
+
{{ even }}
+
{{ odd }}
+
+
+``` + +### 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 | + +#### 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](https://hoebbelsb.github.io/rxa-virtual-scroll/#/demos/fixed-size) + +The default size can be configured directly as `@Input('itemSize')`. + +**Example** + +```ts +// my.component.ts +import { + FixedSizeVirtualScrollStrategyModule, + RxVirtualScrollingModule, +} from '@rx-angular/template/experimental/virtual-scrolling'; + +@Component({ + /**/, + standalone: true, + imports: [FixedSizeVirtualScrollStrategyModule, RxVirtualScrollingModule] +}) +export class MyComponent { + // all items have the height of 50px + itemSize = 50; + + items$ = inject(DataService).getItems(); +} +``` + +```html + +
+
{{ item.id }}
+
{{ item.content }}
+
{{ item.status }}
+
{{ item.date | date }}
+
+
+``` + +#### 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](https://hoebbelsb.github.io/rxa-virtual-scroll/#/demos/dynamic-size) + +**Example** + +```ts +// my.component.ts +import { + DynamicSizeVirtualScrollStrategyModule, + RxVirtualScrollingModule, +} from '@rx-angular/template/experimental/virtual-scrolling'; + +@Component({ + /**/, + standalone: true, + imports: [DynamicSizeVirtualScrollStrategyModule, RxVirtualScrollingModule] +}) +export class MyComponent { + // items with a description have 120px height, others only 50px + dynamicSize = (item: Item) => (item.description ? 120 : 50); + + items$ = inject(DataService).getItems(); +} +``` + +```html + + +
+
{{ item.id }}
+
{{ item.content }}
+
{{ item.status }}
+
{{ item.date | date }}
+
{{ item.description }}
+
+
+``` + +#### 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](https://hoebbelsb.github.io/rxa-virtual-scroll/#/demos/autosize) + +**Example** + +```ts +// my.component.ts +import { + AutosizeVirtualScrollStrategyModule, + RxVirtualScrollingModule, +} from '@rx-angular/template/experimental/virtual-scrolling'; + +@Component({ + /**/, + standalone: true, + imports: [AutosizeVirtualScrollStrategyModule, RxVirtualScrollingModule] +}) +export class MyComponent { + items$ = inject(DataService).getItems(); +} +``` + +```html + +
+
{{ item.id }}
+
{{ item.content }}
+
{{ item.status }}
+
{{ item.date | date }}
+
+
+``` + +## 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. + +```ts +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 + +```ts +/* 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 + +```ts +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; +} +``` + +# Performance Benchmarks + +As this library is a direct competitor of [`@angular/cdk/scrolling`](https://material.angular.io/cdk/scrolling/overview), +this section discusses not only performance differences, but also feature availability. + +Performance recordings are taken from the [Demo Application](https://hoebbelsb.github.io/rxa-virtual-scroll/). +The demo application by default displays lists of `30 000` items. + +The scenario that was benchmarked here is scrolling over long distances by using the scroll bar. This scenario +puts the most pressure on the virtual scrollers. + +## System Setup + +| | | +| --------- | ---------------------------------------------------------- | +| OS | `Pop!_OS 22.04 LTS` | +| Browser | `Chromium Version 112.0.5615.49 (Official Build) (64-bit)` | +| Processor | `Intel® Core™ i7-9750H CPU @ 2.60GHz × 12` | + +## Different Layout techniques + +The RxVirtualScrolling approach to layout items is to absolutely position every view inside the viewport. Therefore, it sets the `transform` property for each managed item. +The CDK approach instead transforms the viewport. The following video showcases the difference. + +https://user-images.githubusercontent.com/4904455/231340169-f65efe6c-863d-49e8-9f4f-183bb38e1b2a.mp4 + +## Fixed Size Strategy + +Comparison between [RxAngular FixedSizeVirtualScrollStrategy](#fixedsizevirtualscrollstrategy) and [CdkFixedSizeVirtualScroll](https://material.angular.io/cdk/scrolling/api#CdkFixedSizeVirtualScroll). + +[FixedSizeVirtualScrollStrategy comparison Demo](https://hoebbelsb.github.io/rxa-virtual-scroll/#/demos/fixed-size-cdk-compare) + +### Features + +| Feature | `@rx-angular/template/experimental/virtual-scrolling` | `@angular/cdk/scrolling` | +| ----------------- | ----------------------------------------------------- | ------------------------ | +| `scrolledIndex$` | ✅ | ✅ | +| `scrollToIndex()` | ✅ | ✅ | + +### Performance + +**No throttling** + +Both solutions do fine without throttling. But, the `CdkFixedSizeVirtualScroll` already struggles with the frame rate. We can already spot `partially presented frames`. +Also, the javascript tasks are taking longer compared to the `RxAngular FixedSizeVirtualScrollStrategy`. + +| `@rx-angular/template/experimental/virtual-scrolling` | `@angular/cdk/scrolling` | +| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| ![rxa-fixed-size--unthrottled](docs%2Fimages%2Frxa-fixed-size--unthrottled.png) | ![cdk-fixed-size--unthrottled](docs%2Fimages%2Fcdk-fixed-size--unthrottled.png) | + +**4x CPU throttling** + +With throttling enabled, the `CdkFixedSizeVirtualScroll` already struggles a lot with keeping the frame rate above anything reasonable. Javascript tasks take up to ~160ms (long-tasks) and +the amount of `partially presented frames` increases. +The `RxAngular FixedSizeVirtualScrollStrategy` has no issues whatsoever keeping the frame rate above 30fps on 4x times throttling. + +| `@rx-angular/template/experimental/virtual-scrolling` | `@angular/cdk/scrolling` | +| --------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| ![rxa-fixed-size--throttled](docs%2Fimages%2Frxa-fixed-size--throttled.png) | ![cdk-fixed-size--throttled](docs%2Fimages%2Fcdk-fixed-size--throttled.png) | + +## Dynamic Size Strategy + +Comparison between [RxAngular DynamicSizeVirtualScrollStrategy](#dynamicsizevirtualscrollstrategy) and [CDK AutoSizeVirtualScrollStrategy](https://github.com/angular/components/blob/main/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts). +As there is no real counterpart to the `DynamicSizeVirtualScrollStrategy`, the comparison was made against the `CDK AutoSizeVirtualScrollStrategy`. This is scroll behavior wise the most comparable +implementation from the cdk package. + +[DynamicSizeVirtualScrollStrategy comparison Demo](https://hoebbelsb.github.io/rxa-virtual-scroll/#/demos/dynamic-size-cdk-compare) + +### Features + +As an experimental package, the `CDK AutoSizeVirtualScrollStrategy` does not emit the current scrollIndex, nor has it a working `scrollToIndex` method implemented. +The `RxAngular DynamicSizeVirtualScrollStrategy` is able to do both! It emits the current valid scrolledIndex and is able to properly scroll to the +correct position based on an index. + +| Feature | `@rx-angular/template/experimental/virtual-scrolling` | `@angular/cdk/scrolling` | +| ----------------- | ----------------------------------------------------- | ------------------------ | +| `scrolledIndex$` | ✅ | ❌ | +| `scrollToIndex()` | ✅ | ❌ | + +### Performance + +**No throttling** + +Both solutions do fine without throttling. But, the `CDK AutoSizeVirtualScrollStrategy` struggles with the frame rate. We can already spot lots of `partially presented frames`. +The `RxAngular DynamicSizeVirtualScrollStrategy` implementation easily maintains a stable framerate around 45fps. + +| `@rx-angular/template/experimental/virtual-scrolling` | `@angular/cdk/scrolling` | +| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| ![rxa-dynamic-size--unthrottled.png](docs%2Fimages%2Frxa-dynamic-size--unthrottled.png) | ![cdk-autosize--unthrottled.png](docs%2Fimages%2Fcdk-autosize--unthrottled.png) | + +**4x CPU throttling** + +With throttling enabled, the `CDK AutoSizeVirtualScrollStrategy` struggles a lot with keeping the frame rate above anything reasonable. Javascript tasks take up more than ~160ms (long-tasks) and +the amount of `partially presented frames` increases. +The `RxAngular DynamicSizeVirtualScrollStrategy` has no issues whatsoever keeping the frame rate above 30fps on 4x times throttling. The javascript execution time is still very low, the style +recalculations and layouting phases are increasing, though. This will also depend very much on the actual use case. + +| `@rx-angular/template/experimental/virtual-scrolling` | `@angular/cdk/scrolling` | +| ----------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| ![rxa-dynamic-size--throttled.png](docs%2Fimages%2Frxa-dynamic-size--throttled.png) | ![cdk-autosize--throttled.png](docs%2Fimages%2Fcdk-autosize--throttled.png) | + +## Autosize Strategy + +Comparison between [RxAngular AutosizeVirtualScrollStrategy](#autosizevirtualscrollstrategy) and [CDK AutoSizeVirtualScrollStrategy](https://github.com/angular/components/blob/main/src/cdk-experimental/scrolling/auto-size-virtual-scroll.ts). + +[AutosizeVirtualScrollStrategy comparison Demo](https://hoebbelsb.github.io/rxa-virtual-scroll/#/demos/autosize-cdk-compare) + +### Features + +As an experimental package, the `CDK AutoSizeVirtualScrollStrategy` does not emit the current scrollIndex, nor has it a working `scrollToIndex` method implemented. +The `RxAngular AutosizeVirtualScrollStrategy` is able to do both! It emits the current valid scrolledIndex and is able to properly scroll to the +correct position based on an index. + +| Feature | `@rx-angular/template/experimental/virtual-scrolling` | `@angular/cdk/scrolling` | +| ----------------- | ----------------------------------------------------- | ------------------------ | +| `scrolledIndex$` | ✅ | ❌ | +| `scrollToIndex()` | ✅ | ❌ | + +### Performance + +**No throttling** + +For the `CDK AutoSizeVirtualScrollStrategy`, the same is true as for the comparison vs. the `DynamicSizeVirtualScrollStrategy`. +The `RxAngular AutoSizeVirtualScrollStrategy` implementation easily maintains a stable framerate of 60fps. You see the reason why it can maintain this framerate in the +comparison flameshots. The AutoSizeVirtualScrollStrategy puts all the layouting work into the RxAngular scheduler queue which will keep the framebudget for us. +For each inserted view, the `AutoSizeVirtualScrollStrategy` will cause a forced reflow as it immediately reads its dimensions. It sounds like a disadvantage, but in reality +the scrolling performance benefits from this approach. Anyway, that's why we such heavy `rendering` peaks (purple color). +Nodes that were visited once are not queried again, scrolling the same path twice will differ in runtime performance. All consequent attempts should be as fast as the fixed or dynamic +size implementations. + +| `@rx-angular/template/experimental/virtual-scrolling` | `@angular/cdk/scrolling` | +| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| ![rxa-autosize--unthrottled.png](docs%2Fimages%2Frxa-autosize--unthrottled.png) | ![cdk-autosize--unthrottled.png](docs%2Fimages%2Fcdk-autosize--unthrottled.png) | + +**4x CPU throttling** + +For the `CDK AutoSizeVirtualScrollStrategy`, the same is true as for the comparison vs. the `DynamicSizeVirtualScrollStrategy`. + +Even with 4x CPU throttling enabled, the `RxAngular AutoSizeVirtualScrollStrategy` keeps a reasonable frame rate and only sometimes produces partially presented frames. +Thanks to the concurrent strategies, users will never encounter long tasks while scrolling. + +| `@rx-angular/template/experimental/virtual-scrolling` | `@angular/cdk/scrolling` | +| --------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| ![rxa-autosize--throttled.png](docs%2Fimages%2Frxa-autosize--throttled.png) | ![cdk-autosize--throttled.png](docs%2Fimages%2Fcdk-autosize--throttled.png) | + +# Version Compatibility + +| Angular | RxJS | @rx-angular/template/experimental/virtual-scrolling | +| ----------- | -------------------- | --------------------------------------------------- | +| `>= 12.0.0` | `^6.5.5` or `^7.4.0` | `^0.1.0` | + +Regarding the compatibility with RxJS, we generally stick to the compatibilities of the Angular framework itself. +For more information about the compatibilities of Angular itself see this [gist](https://gist.github.com/LayZeeDK/c822cc812f75bb07b7c55d07ba2719b3). diff --git a/libs/template/experimental/virtual-scrolling/docs/images/cdk-autosize--throttled.png b/libs/template/experimental/virtual-scrolling/docs/images/cdk-autosize--throttled.png new file mode 100644 index 0000000000..b6fb550a50 Binary files /dev/null and b/libs/template/experimental/virtual-scrolling/docs/images/cdk-autosize--throttled.png differ diff --git a/libs/template/experimental/virtual-scrolling/docs/images/cdk-autosize--unthrottled.png b/libs/template/experimental/virtual-scrolling/docs/images/cdk-autosize--unthrottled.png new file mode 100644 index 0000000000..06b6bb1e52 Binary files /dev/null and b/libs/template/experimental/virtual-scrolling/docs/images/cdk-autosize--unthrottled.png differ diff --git a/libs/template/experimental/virtual-scrolling/docs/images/cdk-fixed-size--throttled.png b/libs/template/experimental/virtual-scrolling/docs/images/cdk-fixed-size--throttled.png new file mode 100644 index 0000000000..a3a6b1f0bf Binary files /dev/null and b/libs/template/experimental/virtual-scrolling/docs/images/cdk-fixed-size--throttled.png differ diff --git a/libs/template/experimental/virtual-scrolling/docs/images/cdk-fixed-size--unthrottled.png b/libs/template/experimental/virtual-scrolling/docs/images/cdk-fixed-size--unthrottled.png new file mode 100644 index 0000000000..d8a2a9bfd6 Binary files /dev/null and b/libs/template/experimental/virtual-scrolling/docs/images/cdk-fixed-size--unthrottled.png differ diff --git a/libs/template/experimental/virtual-scrolling/docs/images/rxa-autosize--throttled.png b/libs/template/experimental/virtual-scrolling/docs/images/rxa-autosize--throttled.png new file mode 100644 index 0000000000..1c1b296ffb Binary files /dev/null and b/libs/template/experimental/virtual-scrolling/docs/images/rxa-autosize--throttled.png differ diff --git a/libs/template/experimental/virtual-scrolling/docs/images/rxa-autosize--unthrottled.png b/libs/template/experimental/virtual-scrolling/docs/images/rxa-autosize--unthrottled.png new file mode 100644 index 0000000000..19399bd89d Binary files /dev/null and b/libs/template/experimental/virtual-scrolling/docs/images/rxa-autosize--unthrottled.png differ diff --git a/libs/template/experimental/virtual-scrolling/docs/images/rxa-dynamic-size--throttled.png b/libs/template/experimental/virtual-scrolling/docs/images/rxa-dynamic-size--throttled.png new file mode 100644 index 0000000000..4f1567a6d2 Binary files /dev/null and b/libs/template/experimental/virtual-scrolling/docs/images/rxa-dynamic-size--throttled.png differ diff --git a/libs/template/experimental/virtual-scrolling/docs/images/rxa-dynamic-size--unthrottled.png b/libs/template/experimental/virtual-scrolling/docs/images/rxa-dynamic-size--unthrottled.png new file mode 100644 index 0000000000..8fe6a6d1f3 Binary files /dev/null and b/libs/template/experimental/virtual-scrolling/docs/images/rxa-dynamic-size--unthrottled.png differ diff --git a/libs/template/experimental/virtual-scrolling/docs/images/rxa-fixed-size--throttled.png b/libs/template/experimental/virtual-scrolling/docs/images/rxa-fixed-size--throttled.png new file mode 100644 index 0000000000..1b7697e236 Binary files /dev/null and b/libs/template/experimental/virtual-scrolling/docs/images/rxa-fixed-size--throttled.png differ diff --git a/libs/template/experimental/virtual-scrolling/docs/images/rxa-fixed-size--unthrottled.png b/libs/template/experimental/virtual-scrolling/docs/images/rxa-fixed-size--unthrottled.png new file mode 100644 index 0000000000..46a4cd6e17 Binary files /dev/null and b/libs/template/experimental/virtual-scrolling/docs/images/rxa-fixed-size--unthrottled.png differ diff --git a/libs/template/experimental/virtual-scrolling/docs/images/rxa-vs-cdk.png b/libs/template/experimental/virtual-scrolling/docs/images/rxa-vs-cdk.png new file mode 100644 index 0000000000..39f3ce18a7 Binary files /dev/null and b/libs/template/experimental/virtual-scrolling/docs/images/rxa-vs-cdk.png differ diff --git a/libs/template/experimental/virtual-scrolling/ng-package.json b/libs/template/experimental/virtual-scrolling/ng-package.json new file mode 100644 index 0000000000..f77ba479ad --- /dev/null +++ b/libs/template/experimental/virtual-scrolling/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../../../node_modules/ng-packagr/package.schema.json", + "lib": { + "entryFile": "src/index.ts", + "flatModuleFile": "template-experimental-virtual-scrolling" + } +} diff --git a/libs/template/experimental/virtual-scrolling/src/index.ts b/libs/template/experimental/virtual-scrolling/src/index.ts new file mode 100644 index 0000000000..f41a696fd2 --- /dev/null +++ b/libs/template/experimental/virtual-scrolling/src/index.ts @@ -0,0 +1 @@ +export * from './lib'; diff --git a/libs/template/experimental/virtual-scrolling/src/lib/index.ts b/libs/template/experimental/virtual-scrolling/src/lib/index.ts new file mode 100644 index 0000000000..91107c0e8c --- /dev/null +++ b/libs/template/experimental/virtual-scrolling/src/lib/index.ts @@ -0,0 +1,17 @@ +export { + ListRange, + RxVirtualForViewContext, + RxVirtualScrollStrategy, + RxVirtualViewRepeater, +} from './model'; +export * from './scroll-strategies/autosized-virtual-scroll-strategy'; +export * from './scroll-strategies/dynamic-size-virtual-scroll-strategy'; +export * from './scroll-strategies/fixed-size-virtual-scroll-strategy'; +export * from './virtual-for.directive'; +export { + RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS, + RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS_FACTORY, + RxVirtualScrollDefaultOptions, +} from './virtual-scroll.config'; +export * from './virtual-scroll-viewport.component'; +export * from './virtual-scrolling.module'; diff --git a/libs/template/experimental/virtual-scrolling/src/lib/model.ts b/libs/template/experimental/virtual-scrolling/src/lib/model.ts new file mode 100644 index 0000000000..cd74c96eb8 --- /dev/null +++ b/libs/template/experimental/virtual-scrolling/src/lib/model.ts @@ -0,0 +1,179 @@ +import { + ChangeDetectorRef, + Directive, + EmbeddedViewRef, + ErrorHandler, + NgIterable, + NgZone, + Output, + TemplateRef, + TrackByFunction, + ViewContainerRef, +} from '@angular/core'; +import { RxStrategies } from '@rx-angular/cdk/render-strategies'; +import { RxDefaultListViewContext } from '@rx-angular/cdk/template'; +import { Observable, Subject } from 'rxjs'; + +type CreateViewContext = (value: T, computedContext: U) => C; + +type UpdateViewContext = ( + value: T, + view: EmbeddedViewRef, + computedContext?: U +) => void; + +export interface TemplateSettings { + viewContainerRef: ViewContainerRef; + templateRef: TemplateRef; + createViewContext: CreateViewContext; + updateViewContext: UpdateViewContext; + viewCacheSize: number; +} + +export interface RenderSettings { + cdRef: ChangeDetectorRef; + parent: boolean; + patchZone?: NgZone; + strategies: RxStrategies; + defaultStrategyName: string; + errorHandler?: ErrorHandler; +} + +export const enum ListTemplateChangeType { + insert, + remove, + move, + update, + context, +} +// [value, index, oldIndex?] +export type ListTemplateChangePayload = [T, number?, number?]; +export type ListTemplateChange = [ + ListTemplateChangeType, + ListTemplateChangePayload +]; +export type ListTemplateChanges = [ + ListTemplateChange[], // changes to apply + boolean // notify parent +]; + +export interface ListRange { + start: number; + end: number; +} + +/** + * @Directive RxVirtualScrollStrategy + * + * @description + * Abstract implementation for the actual implementations of the ScrollStrategies + * being consumed by `*rxVirtualFor` and `rx-virtual-scroll-viewport`. + * + * This is one of the core parts for the virtual scrolling implementation. It has + * to determine the `ListRange` being rendered to the DOM as well as managing + * the layouting task for the `*rxVirtualFor` directive. + * + * @docsCategory RxVirtualFor + * @docsPage RxVirtualFor + * @publicApi + */ +@Directive() +export abstract class RxVirtualScrollStrategy< + T, + U extends NgIterable = NgIterable +> { + /** Emits when the index of the first element visible in the viewport changes. */ + /** @internal */ + abstract scrolledIndex$: Observable; + /** @internal */ + abstract renderedRange$: Observable; + /** @internal */ + abstract contentSize$: Observable; + + /** + * @description + * + * Emits whenever an update to a single view was rendered + */ + @Output() readonly viewRenderCallback = new Subject<{ + view: EmbeddedViewRef>; + item: T; + index: number; + }>(); + + /** @internal */ + private nodeIndex?: number; + + /** @internal */ + protected getElement( + view: EmbeddedViewRef> + ): HTMLElement { + if (this.nodeIndex !== undefined) { + return view.rootNodes[this.nodeIndex]; + } + const rootNode = view.rootNodes[0]; + this.nodeIndex = rootNode instanceof HTMLElement ? 0 : 1; + return view.rootNodes[this.nodeIndex] as HTMLElement; + } + + /** + * Attaches this scroll strategy to a viewport. + * @param viewport The viewport to attach this strategy to. + * @param viewRepeater The viewRepeater attached to the viewport. + */ + abstract attach( + viewport: RxVirtualScrollViewport, + viewRepeater: RxVirtualViewRepeater + ): void; + + /** Detaches this scroll strategy from the currently attached viewport. */ + abstract detach(): void; + + /** + * Scroll to the offset for the given index. + * @param index The index of the element to scroll to. + * @param behavior The ScrollBehavior to use when scrolling. + */ + abstract scrollToIndex(index: number, behavior?: ScrollBehavior): void; +} + +/** @internal */ +@Directive() +export abstract class RxVirtualScrollViewport { + abstract rendered$: Observable; + abstract viewRange: Observable; + abstract elementScrolled$: Observable; + abstract containerRect$: Observable<{ height: number; width: number }>; + abstract getScrollTop(): number; + abstract scrollTo(scrollTo: number, behavior?: ScrollBehavior): void; +} + +/** @internal */ +@Directive() +export abstract class RxVirtualViewRepeater< + T, + U extends NgIterable = NgIterable +> { + abstract values$: Observable; + abstract rendered$: Observable; + abstract viewsRendered$: Observable[]>; + abstract viewRendered$: Observable<{ + view: EmbeddedViewRef>; + index: number; + item: T; + }>; + abstract renderingStart$: Observable; + _trackBy: TrackByFunction = (i, a) => a; +} + +/** @internal */ +export class RxVirtualForViewContext< + T, + U extends NgIterable = NgIterable, + C extends { count: number; index: number } = { count: number; index: number }, + K = keyof T +> extends RxDefaultListViewContext { + constructor(item: T, public rxVirtualForOf: U, customProps?: C) { + super(item, customProps); + } +} diff --git a/libs/template/experimental/virtual-scrolling/src/lib/observe-element-size.ts b/libs/template/experimental/virtual-scrolling/src/lib/observe-element-size.ts new file mode 100644 index 0000000000..9cfa97a9d5 --- /dev/null +++ b/libs/template/experimental/virtual-scrolling/src/lib/observe-element-size.ts @@ -0,0 +1,32 @@ +import { Observable } from 'rxjs'; + +export function observeElementSize( + element: Element, + config?: { + options?: ResizeObserverOptions; + } +): Observable; +export function observeElementSize( + element: Element, + config?: { + options?: ResizeObserverOptions; + extract: (entries: ResizeObserverEntry[]) => T; + } +): Observable; +export function observeElementSize( + element: Element, + config?: { + options?: ResizeObserverOptions; + extract?: (entries: ResizeObserverEntry[]) => T; + } +): Observable { + const extractProp: (entries: ResizeObserverEntry[]) => T | DOMRectReadOnly = + config?.extract ?? ((entries) => entries[0].contentRect); + return new Observable((subscriber) => { + const observer = new ResizeObserver((entries) => { + subscriber.next(extractProp(entries)); + }); + observer.observe(element, config?.options); + return () => observer.disconnect(); + }); +} diff --git a/libs/template/experimental/virtual-scrolling/src/lib/scroll-strategies/autosized-virtual-scroll-strategy.ts b/libs/template/experimental/virtual-scrolling/src/lib/scroll-strategies/autosized-virtual-scroll-strategy.ts new file mode 100644 index 0000000000..c17c89974d --- /dev/null +++ b/libs/template/experimental/virtual-scrolling/src/lib/scroll-strategies/autosized-virtual-scroll-strategy.ts @@ -0,0 +1,813 @@ +import { + Directive, + EmbeddedViewRef, + Inject, + Input, + IterableDiffer, + IterableDiffers, + NgIterable, + NgModule, + OnChanges, + OnDestroy, + Optional, + SimpleChanges, +} from '@angular/core'; +import { coalesceWith } from '@rx-angular/cdk/coalescing'; +import { + combineLatest, + merge, + MonoTypeOperatorFunction, + Observable, + ReplaySubject, + Subject, +} from 'rxjs'; +import { + distinctUntilChanged, + filter, + map, + startWith, + switchMap, + takeUntil, + tap, +} from 'rxjs/operators'; + +import { + ListRange, + RxVirtualScrollStrategy, + RxVirtualScrollViewport, + RxVirtualViewRepeater, +} from '../model'; +import { unpatchedAnimationFrameTick, unpatchedMicroTask } from '../util'; +import { + DEFAULT_ITEM_SIZE, + DEFAULT_RUNWAY_ITEMS, + DEFAULT_RUNWAY_ITEMS_OPPOSITE, + RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS, + RxVirtualScrollDefaultOptions, +} from '../virtual-scroll.config'; + +/** @internal */ +type VirtualViewItem = { + size: number; +}; + +/** @internal */ +type AnchorItem = { + index: number; + offset: number; +}; + +/** @internal */ +function removeFromArray(arr: any[], index: number): any { + // perf: array.pop is faster than array.splice! + if (index >= arr.length - 1) { + return arr.pop(); + } else { + return arr.splice(index, 1)[0]; + } +} + +const defaultSizeExtract = (entry: ResizeObserverEntry) => + entry.borderBoxSize[0].blockSize; + +/** + * @Directive AutosizeVirtualScrollStrategy + * + * @description + * + * The `AutosizeVirtualScrollStrategy` provides a twitter-like virtual-scrolling + * experience. It 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 layouting technique and more features. + * + * On top of this the `AutosizeVirtualScrollStrategy` is leveraging the native + * `ResizeObserver` in order to detect size changes for each individual view + * rendered to the DOM and properly re-position accordingly. + * + * In order to provide top-notch 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. + * + * @docsCategory RxVirtualFor + * @docsPage RxVirtualFor + * @publicApi + */ +@Directive({ + // eslint-disable-next-line @angular-eslint/directive-selector + selector: 'rx-virtual-scroll-viewport[autosize]', + providers: [ + { + provide: RxVirtualScrollStrategy, + useExisting: AutosizeVirtualScrollStrategy, + }, + ], +}) +// eslint-disable-next-line @angular-eslint/directive-class-suffix +export class AutosizeVirtualScrollStrategy< + T, + U extends NgIterable = NgIterable + > + extends RxVirtualScrollStrategy + implements OnChanges, OnDestroy +{ + /** + * @description + * The amount of items to render upfront in scroll direction + */ + @Input() runwayItems = this.defaults?.runwayItems ?? DEFAULT_RUNWAY_ITEMS; + + /** + * @description + * The amount of items to render upfront in reverse scroll direction + */ + @Input() runwayItemsOpposite = + this.defaults?.runwayItemsOpposite ?? DEFAULT_RUNWAY_ITEMS_OPPOSITE; + + /** + * @description + * The default size of the items being rendered. The autosized strategy will assume + * this size for items it doesn't know yet. For the smoothest experience, + * you provide the mean size of all items being rendered - if possible of course. + * + * As soon as rxVirtualFor is able to also render actual tombstone items, this + * will be the size of a tombstone item being rendered before the actual item + * is inserted into its position. + */ + @Input() tombstoneSize = this.defaults?.itemSize ?? DEFAULT_ITEM_SIZE; + + /** + * @description + * The autosized strategy uses the native ResizeObserver in order to determine + * if an item changed in size to afterwards properly position the views. + * You can customize the config passed to the ResizeObserver as well as determine + * which result property to use when determining the views size. + */ + @Input() resizeObserverConfig?: { + options?: ResizeObserverOptions; + extractSize?: (entry: ResizeObserverEntry) => number; + }; + + /** @internal */ + private viewport: RxVirtualScrollViewport | null = null; + /** @internal */ + private viewRepeater: RxVirtualViewRepeater | null = null; + /** @internal */ + private dataDiffer: IterableDiffer | null = null; + + /** @internal */ + private readonly _contentSize$ = new ReplaySubject(1); + /** @internal */ + override readonly contentSize$ = this._contentSize$.asObservable(); + + /** @internal */ + private _contentSize = 0; + /** @internal */ + private set contentSize(size: number) { + this._contentSize = size; + this._contentSize$.next(size); + } + + /** @internal */ + private readonly _renderedRange$ = new ReplaySubject(1); + /** @internal */ + readonly renderedRange$ = this._renderedRange$.asObservable(); + /** @internal */ + private _renderedRange: ListRange = { start: 0, end: 0 }; + // range of items where size is known and doesn't need to be re-calculated + /** @internal */ + private _cachedSet = new Set(); + + /** @internal */ + private set renderedRange(range: ListRange) { + this._renderedRange = range; + this._renderedRange$.next(range); + } + /** @internal */ + private get renderedRange(): ListRange { + return this._renderedRange; + } + /** @internal */ + private readonly _scrolledIndex$ = new ReplaySubject(1); + /** @internal */ + readonly scrolledIndex$ = this._scrolledIndex$.pipe(distinctUntilChanged()); + /** @internal */ + private _scrolledIndex = 0; + /** @internal */ + private get scrolledIndex(): number { + return this._scrolledIndex; + } + /** @internal */ + private set scrolledIndex(index: number) { + this._scrolledIndex = index; + this._scrolledIndex$.next(index); + } + + /** + * is set, when scrollToIndex is called and the scroll event touches + * the edge of the viewport at the bottom. This is the only event where + * we actually have to `guess` the scroll anchor and might end up having + * to adjust + * @internal + * */ + private _scrollToEdgeIndex: number | null = null; + + /** + * is set, when scrollToIndex is called + * @internal + * */ + private _scrollToAnchor: AnchorItem | null = null; + + /** @internal */ + private containerSize = 0; + /** @internal */ + private contentLength = 0; + /** @internal */ + private _virtualItems: VirtualViewItem[] = []; + /** @internal */ + private scrollTop = 0; + /** @internal */ + private direction: 'up' | 'down' = 'down'; + /** @internal */ + private anchorScrollTop = 0; + /** @internal */ + private anchorItem = { + index: 0, + offset: 0, + }; + /** @internal */ + private lastScreenItem = { + index: 0, + offset: 0, + }; + + /** @internal */ + private readonly detached$ = new Subject(); + + /** @internal */ + private resizeObserver?: ResizeObserver; + /** @internal */ + private readonly viewsResized$ = new Subject(); + /** @internal */ + private readonly afterViewResized$ = new Subject(); + /** @internal */ + private readonly runwayStateChanged$ = new Subject(); + + /** @internal */ + private until$(): MonoTypeOperatorFunction { + return (o$) => o$.pipe(takeUntil(this.detached$)); + } + + private get extractSize() { + return this.resizeObserverConfig?.extractSize ?? defaultSizeExtract; + } + + /** @internal */ + constructor( + private differs: IterableDiffers, + @Optional() + @Inject(RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS) + private defaults?: RxVirtualScrollDefaultOptions + ) { + super(); + } + + /** @internal */ + ngOnChanges(changes: SimpleChanges) { + if ( + (changes['runwayItemsOpposite'] && + !changes['runwayItemsOpposite'].firstChange) || + (changes['runwayItems'] && !changes['runwayItems'].firstChange) + ) { + this.runwayStateChanged$.next(); + } + } + + /** @internal */ + ngOnDestroy() { + this.detach(); + } + + /** @internal */ + attach( + viewport: RxVirtualScrollViewport, + viewRepeater: RxVirtualViewRepeater + ): void { + this.viewport = viewport; + this.viewRepeater = viewRepeater; + this.resizeObserver = new ResizeObserver((events) => { + this.viewsResized$.next(events); + }); + this.maintainVirtualItems(); + this.calcRenderedRange(); + this.positionElements(); + } + + /** @internal */ + detach(): void { + this.viewport = null; + this.viewRepeater = null; + this._virtualItems = []; + this.resizeObserver?.disconnect(); + this.resizeObserver = undefined; + this.detached$.next(); + } + + scrollToIndex(index: number, behavior?: ScrollBehavior): void { + const _index = Math.min(Math.max(index, 0), this.contentLength - 1); + let size = 0; + for (let i = 0; i < _index; i++) { + size += this.getItemSize(i); + } + this._scrollToAnchor = { + index: _index, + offset: 0, + }; + this._scrollToEdgeIndex = null; + if (size + this.containerSize > this._contentSize) { + this._scrollToEdgeIndex = _index; + this._scrollToAnchor = this.calculateAnchoredItem( + this._scrollToAnchor, + -Math.max(size + this.containerSize - this._contentSize) + ); + } + this.viewport!.scrollTo(size, behavior); + } + + /** @internal */ + private maintainVirtualItems(): void { + // reset virtual viewport when opposite orientation to the scroll direction + // changes, as we have to expect dimension changes for all items when this + // happens. This could also be configurable as it maybe costs performance + this.viewport!.containerRect$.pipe( + filter(() => this._virtualItems.length > 0), + map(({ width }) => width), + distinctUntilChanged(), + this.until$() + ).subscribe(() => { + // reset because we have no idea how items will behave + let i = 0; + let size = 0; + this._cachedSet.clear(); + while (i < this.renderedRange.start) { + this._virtualItems[i].size = 0; + i++; + size += this.tombstoneSize; + } + while (i >= this.renderedRange.start && i < this.renderedRange.end) { + size += this._virtualItems[i].size; + this._cachedSet.add(i); + i++; + } + while (i < this.contentLength - 1) { + this._virtualItems[i].size = 0; + i++; + size += this.tombstoneSize; + } + this.contentSize = size; + }); + this.viewRepeater!.values$.pipe(this.until$()).subscribe((items) => { + const changes = this.getDiffer(items)?.diff(items); + if (changes) { + // reset cache on update + changes.forEachOperation( + (item, adjustedPreviousIndex, currentIndex) => { + if (item.previousIndex == null) { + const entry = { + size: 0, + }; + if ( + currentIndex !== null && + currentIndex < this._virtualItems.length + ) { + this._virtualItems.splice(currentIndex, 0, entry); + } else { + this._virtualItems.push(entry); + } + } else if (currentIndex === null) { + const removeIdx = + adjustedPreviousIndex === null + ? this._virtualItems.length - 1 + : adjustedPreviousIndex; + removeFromArray(this._virtualItems, removeIdx); + this._cachedSet.delete(removeIdx); + } else if (adjustedPreviousIndex !== null) { + this._virtualItems[currentIndex] = + this._virtualItems[adjustedPreviousIndex]; + } + } + ); + changes.forEachIdentityChange( + ({ currentIndex }) => + currentIndex && this._cachedSet.delete(currentIndex) + ); + } + if (!this._contentSize && this._virtualItems.length > 0) { + this.contentSize = this._virtualItems.length * this.tombstoneSize; + } + }); + } + + /** @internal */ + private calcRenderedRange(): void { + const dataLengthChanged$ = this.viewRepeater!.values$.pipe( + map( + (values) => + (Array.isArray(values) + ? values + : values != null + ? Array.from(values) + : [] + ).length + ), + distinctUntilChanged() + ); + const onScroll$ = this.viewport!.elementScrolled$.pipe( + coalesceWith(unpatchedAnimationFrameTick()), + map(() => this.viewport!.getScrollTop()), + startWith(0), + tap((scrollTop) => { + this.direction = scrollTop > this.scrollTop ? 'down' : 'up'; + this.scrollTop = scrollTop; + }) + ); + combineLatest([ + dataLengthChanged$.pipe( + tap((length) => { + this.contentLength = length; + }) + ), + this.viewport!.containerRect$.pipe( + map(({ height }) => height), + distinctUntilChanged() + ), + onScroll$, + this.afterViewResized$.pipe(startWith(void 0)), + this.runwayStateChanged$.pipe(startWith(void 0)), + ]) + .pipe( + map(([length, containerSize]) => { + this.containerSize = containerSize; + const range = { start: 0, end: 0 }; + const delta = this.scrollTop - this.anchorScrollTop; + if (this._scrollToAnchor) { + this.anchorItem = this._scrollToAnchor; + this._scrollToAnchor = null; + } else { + // reset scrollToIndex after consequent scroll or any other update + if (this._scrollToEdgeIndex !== null) { + this._scrollToEdgeIndex = null; + } + if (this.scrollTop == 0) { + this.anchorItem = { index: 0, offset: 0 }; + } else { + this.anchorItem = this.calculateAnchoredItem( + this.anchorItem, + delta + ); + } + } + this.scrolledIndex = this.anchorItem.index; + this.anchorScrollTop = this.scrollTop; + this.lastScreenItem = this.calculateAnchoredItem( + this.anchorItem, + containerSize + ); + if (this.direction === 'up') { + range.start = Math.max(0, this.anchorItem.index - this.runwayItems); + range.end = Math.min( + length, + this.lastScreenItem.index + this.runwayItemsOpposite + ); + } else { + range.start = Math.max( + 0, + this.anchorItem.index - this.runwayItemsOpposite + ); + range.end = Math.min( + length, + this.lastScreenItem.index + this.runwayItems + ); + } + return range; + }) + ) + .pipe( + distinctUntilChanged( + ({ start: prevStart, end: prevEnd }, { start, end }) => + prevStart === start && prevEnd === end + ), + this.until$() + ) + .subscribe((range: ListRange) => (this.renderedRange = range)); + } + + /** @internal */ + private positionElements(): void { + this.viewRepeater!.renderingStart$.pipe( + switchMap(() => { + const renderedRange = this.renderedRange; + const adjustIndexWith = renderedRange.start; + let scrolledIndex: number | null = null; + let position = 0; + let renderedViews: { + view: EmbeddedViewRef; + index: number; + item: T; + }[] = []; + return merge( + this.viewRepeater!.viewRendered$.pipe( + tap(({ view, index: viewIndex, item }) => { + const adjustedIndex = viewIndex + adjustIndexWith; + // this most of the time causes a forced reflow per rendered view. + // it doesn't sound good, but it's still way more stable than + // having one large reflow in a microtask after the actual + // scheduler tick. + // Right here, we can insert work into the task which is currently + // executed as part of the concurrent scheduler tick. + // causing the forced reflow here, also makes it count for the + // schedulers frame budget. This way we will always run with the + // configured FPS. The only case where this is not true is when rendering 1 single view + // already explodes the budget + this.updateElementSize(view, adjustedIndex); + renderedViews.push({ + view, + index: adjustedIndex, + item, + }); + }), + // coalescing the layout to the next microtask + // -> layout happens in the same tick as the scheduler. it will + // be executed at the end and causes a forced reflow + // to not have a reflow, we would need to let the browser stabilize the + // current layout before reading `offsetHeight` of the nodes. + // while the flames get smoother, other issues arise with that approach. + // but here is def. room for improvement + coalesceWith(unpatchedMicroTask()), + map(() => { + if (position === 0) { + position = this.calcInitialPosition(renderedRange); + } + let lastIndex = 0; + let scrollTo = this.anchorScrollTop; + // update DOM happens here + // layout should be stable by now, we are thrashing it again, though + for (let i = 0; i < renderedViews.length; i++) { + const { view, index, item } = renderedViews[i]; + const element = this.getElement(view); + this.positionElement(element, position); + if (index === this._scrollToEdgeIndex) { + scrollTo = position; + this._scrollToEdgeIndex = null; + } + position += this._virtualItems[index].size; + if (scrolledIndex == null && position > this.scrollTop) { + scrolledIndex = index; + } + lastIndex = index; + this.viewRenderCallback.next({ + index, + view, + item, + }); + } + renderedViews = []; + // this condition only covers the edge case when we scroll to the + // lower edge of the viewport and we have to wait until the whole + // renderedRange actually got rendered + if (this._scrollToEdgeIndex === null) { + this.contentSize = + position + this.getRemainingSizeFrom(lastIndex + 1); + if ( + scrolledIndex != null && + scrolledIndex !== this.scrolledIndex + ) { + this.scrolledIndex = scrolledIndex; + } + if (scrollTo !== this.scrollTop) { + const maxScrollTo = this._contentSize - this.containerSize; + if ( + scrollTo >= maxScrollTo && + Math.ceil(this.scrollTop) >= maxScrollTo + ) { + // just trigger re-calculation of the renderedRange in case this happens + this.afterViewResized$.next(); + } else { + this.viewport!.scrollTo(scrollTo); + } + } else if ( + position < this.containerSize && + lastIndex < this.contentLength + ) { + this.afterViewResized$.next(); + } + } + }) + ), + this.viewRepeater!.viewsRendered$.pipe( + switchMap((views) => + this.observeViewSizes$(adjustIndexWith, views).pipe( + tap((lowestId) => { + let i = lowestId; + const range = { + start: i + adjustIndexWith, + end: renderedRange.end, + }; + let position = this.calcInitialPosition(range); + let index = range.start; + for (i; i < views.length; i++) { + const element = this.getElement(views[i]); + this.positionElement(element, position); + index = i + adjustIndexWith; + position += this.getItemSize(index); + } + this.contentSize = + position + this.getRemainingSizeFrom(index + 1); + this.afterViewResized$.next(); + }) + ) + ) + ) + ); + }), + this.until$() + ).subscribe(); + } + + /** + * @internal + * heavily inspired by + * https://github.com/GoogleChromeLabs/ui-element-samples/blob/gh-pages/infinite-scroller/scripts/infinite-scroll.js + */ + private calculateAnchoredItem( + initialAnchor: AnchorItem, + delta: number + ): AnchorItem { + if (delta == 0) return initialAnchor; + delta += initialAnchor.offset; + let i = initialAnchor.index; + let tombstones = 0; + const items = this._virtualItems; + if (delta < 0) { + while (delta < 0 && i > 0 && items[i - 1].size) { + delta += items[i - 1].size; + i--; + } + tombstones = Math.max( + -i, + Math.ceil(Math.min(delta, 0) / this.tombstoneSize) + ); + } else { + while ( + delta > 0 && + i < this.contentLength && + items[i]?.size && + items[i].size < delta + ) { + delta -= items[i].size; + i++; + } + if (i >= this.contentLength) { + tombstones = 0; + } else if (!items[i].size) { + tombstones = Math.floor(Math.max(delta, 0) / this.tombstoneSize); + } + } + i += tombstones; + delta -= tombstones * this.tombstoneSize; + return { + index: Math.min(i, this.contentLength), + offset: Math.max(0, delta), + }; + } + /** @internal */ + private calcInitialPosition(range: ListRange): number { + let pos = 0; + let i = 0; + this.anchorScrollTop = 0; + for (i = 0; i < this.anchorItem.index; i++) { + this.anchorScrollTop += this.getItemSize(i); + } + this.anchorScrollTop += this.anchorItem.offset; + + // Calculate position of starting node + pos = this.anchorScrollTop - this.anchorItem.offset; + i = this.anchorItem.index; + while (i > range.start) { + const itemSize = this.getItemSize(i - 1); + pos -= itemSize; + i--; + } + while (i < range.start) { + const itemSize = this.getItemSize(i); + pos += itemSize; + i++; + } + return pos; + } + + /** @internal */ + private updateElementSize(view: EmbeddedViewRef, index: number): number { + const oldSize = this._virtualItems[index].size; + const isCached = this._cachedSet.has(index) && oldSize > 0; + const size = isCached + ? oldSize + : this.getElementSize(this.getElement(view)); + this._virtualItems[index] = { size }; + this._cachedSet.add(index); + return size; + } + + /** @internal */ + private observeViewSizes$( + adjustIndexWith: number, + views: EmbeddedViewRef[] + ): Observable { + const elementCache = new WeakMap(); + let lowestResizedId: number | undefined; + for (let i = 0; i < views.length; i++) { + const element = this.getElement(views[i]); + this.resizeObserver!.observe(element, this.resizeObserverConfig?.options); + elementCache.set(element, i); + } + return new Observable((observer) => { + const inner = this.viewsResized$.subscribe((events) => { + events.forEach((event) => { + if (!event.target.isConnected) { + return; + } + const cachedId = elementCache.get(event.target); + if (cachedId !== undefined) { + const adjustedId = cachedId + adjustIndexWith; + const size = Math.round(this.extractSize(event)); + if (this._virtualItems[adjustedId].size !== size) { + lowestResizedId = Math.min( + lowestResizedId ?? Number.MAX_SAFE_INTEGER, + cachedId + ); + this._virtualItems[adjustedId].size = size; + } + } + }); + if (lowestResizedId !== undefined) { + observer.next(lowestResizedId); + } + }); + return () => { + for (let i = 0; i < views.length; i++) { + const element = this.getElement(views[i]); + this.resizeObserver?.unobserve(element); + } + inner.unsubscribe(); + }; + }).pipe( + coalesceWith(unpatchedAnimationFrameTick()), + map((lowestId) => { + lowestResizedId = undefined; + return lowestId; + }) + ); + } + + /** @internal */ + private getRemainingSizeFrom(from: number): number { + let remaining = 0; + for (let i = from; i < this.contentLength; i++) { + remaining += this.getItemSize(i); + } + return remaining; + } + + /** @internal */ + private getItemSize(index: number): number { + return this._virtualItems[index].size || this.tombstoneSize; + } + /** @internal */ + private getElementSize(element: HTMLElement): number { + return element.offsetHeight; + } + /** @internal */ + private positionElement(element: HTMLElement, scrollTop: number): void { + element.style.position = 'absolute'; + element.style.transform = `translateY(${scrollTop}px)`; + } + /** @internal */ + private getDiffer(values: U | null | undefined): IterableDiffer | null { + if (this.dataDiffer) { + return this.dataDiffer; + } + return values + ? (this.dataDiffer = this.differs + .find(values) + .create(this.viewRepeater!._trackBy)) + : null; + } +} + +@NgModule({ + imports: [], + exports: [AutosizeVirtualScrollStrategy], + declarations: [AutosizeVirtualScrollStrategy], + providers: [], +}) +export class AutosizeVirtualScrollStrategyModule {} diff --git a/libs/template/experimental/virtual-scrolling/src/lib/scroll-strategies/dynamic-size-virtual-scroll-strategy.ts b/libs/template/experimental/virtual-scrolling/src/lib/scroll-strategies/dynamic-size-virtual-scroll-strategy.ts new file mode 100644 index 0000000000..e5a26f0859 --- /dev/null +++ b/libs/template/experimental/virtual-scrolling/src/lib/scroll-strategies/dynamic-size-virtual-scroll-strategy.ts @@ -0,0 +1,505 @@ +import { + Directive, + Inject, + Input, + IterableDiffer, + IterableDiffers, + NgIterable, + NgModule, + OnChanges, + OnDestroy, + Optional, + SimpleChanges, +} from '@angular/core'; +import { + combineLatest, + MonoTypeOperatorFunction, + ReplaySubject, + Subject, +} from 'rxjs'; +import { + debounce, + distinctUntilChanged, + map, + startWith, + switchMap, + takeUntil, + tap, +} from 'rxjs/operators'; + +import { + ListRange, + RxVirtualScrollStrategy, + RxVirtualScrollViewport, + RxVirtualViewRepeater, +} from '../model'; +import { unpatchedAnimationFrameTick } from '../util'; +import { + DEFAULT_ITEM_SIZE, + DEFAULT_RUNWAY_ITEMS, + DEFAULT_RUNWAY_ITEMS_OPPOSITE, + RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS, + RxVirtualScrollDefaultOptions, +} from '../virtual-scroll.config'; + +/** @internal */ +type VirtualViewItem = { + size: number; +}; + +/** @internal */ +type AnchorItem = { + index: number; + offset: number; +}; + +/** @internal */ +function removeFromArray(arr: any[], index: number): any { + // perf: array.pop is faster than array.splice! + if (index >= arr.length - 1) { + return arr.pop(); + } else { + return arr.splice(index, 1)[0]; + } +} + +const defaultItemSize = () => DEFAULT_ITEM_SIZE; + +/** + * @Directive DynamicSizeVirtualScrollStrategy + * + * @description + * + * The `DynamicSizeVirtualScrollStrategy` is very similar to the `AutosizeVirtualScrollStrategy`. + * It positions items based on a function determining its size. + * + * @docsCategory RxVirtualFor + * @docsPage RxVirtualFor + * @publicApi + */ +@Directive({ + // eslint-disable-next-line @angular-eslint/directive-selector + selector: 'rx-virtual-scroll-viewport[dynamic]', + providers: [ + { + provide: RxVirtualScrollStrategy, + useExisting: DynamicSizeVirtualScrollStrategy, + }, + ], +}) +// eslint-disable-next-line @angular-eslint/directive-class-suffix +export class DynamicSizeVirtualScrollStrategy< + T, + U extends NgIterable = NgIterable + > + extends RxVirtualScrollStrategy + implements OnChanges, OnDestroy +{ + /** + * @description + * The amount of items to render upfront in scroll direction + */ + @Input() runwayItems = this.defaults?.runwayItems ?? DEFAULT_RUNWAY_ITEMS; + + /** + * @description + * The amount of items to render upfront in reverse scroll direction + */ + @Input() runwayItemsOpposite = + this.defaults?.runwayItemsOpposite ?? DEFAULT_RUNWAY_ITEMS_OPPOSITE; + + /** + * @description + * Function returning the size of an item + */ + @Input('dynamic') + set itemSize(fn: (item: T) => number) { + if (fn) { + this._itemSizeFn = fn; + } + } + get itemSize() { + return this._itemSizeFn; + } + private _itemSizeFn: (item: T) => number = defaultItemSize; + + /** @internal */ + private viewport: RxVirtualScrollViewport | null = null; + /** @internal */ + private viewRepeater: RxVirtualViewRepeater | null = null; + /** @internal */ + private dataDiffer: IterableDiffer | null = null; + + /** @internal */ + private readonly _contentSize$ = new ReplaySubject(1); + /** @internal */ + readonly contentSize$ = this._contentSize$.asObservable(); + + /** @internal */ + private _contentSize = 0; + /** @internal */ + private set contentSize(size: number) { + this._contentSize = size; + this._contentSize$.next(size); + } + + /** @internal */ + private readonly _renderedRange$ = new ReplaySubject(1); + /** @internal */ + readonly renderedRange$ = this._renderedRange$.asObservable(); + /** @internal */ + private _renderedRange: ListRange = { start: 0, end: 0 }; + // range of items where size is known and doesn't need to be re-calculated + + /** @internal */ + private set renderedRange(range: ListRange) { + this._renderedRange = range; + this._renderedRange$.next(range); + } + /** @internal */ + private get renderedRange(): ListRange { + return this._renderedRange; + } + /** @internal */ + private readonly _scrolledIndex$ = new ReplaySubject(1); + /** @internal */ + readonly scrolledIndex$ = this._scrolledIndex$.pipe(distinctUntilChanged()); + /** @internal */ + private set scrolledIndex(index: number) { + this._scrolledIndex$.next(index); + } + /** @internal */ + private contentLength = 0; + /** @internal */ + private _virtualItems: VirtualViewItem[] = []; + /** @internal */ + private scrollTop = 0; + /** @internal */ + private direction: 'up' | 'down' = 'down'; + /** @internal */ + private anchorScrollTop = 0; + /** @internal */ + private anchorItem = { + index: 0, + offset: 0, + }; + /** @internal */ + private lastScreenItem = { + index: 0, + offset: 0, + }; + + /** @internal */ + private readonly detached$ = new Subject(); + /** @internal */ + private readonly runwayStateChanged$ = new Subject(); + /** @internal */ + private until$(): MonoTypeOperatorFunction { + return (o$) => o$.pipe(takeUntil(this.detached$)); + } + + /** @internal */ + constructor( + private differs: IterableDiffers, + @Optional() + @Inject(RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS) + private defaults?: RxVirtualScrollDefaultOptions + ) { + super(); + } + + /** @internal */ + ngOnChanges(changes: SimpleChanges) { + if ( + (changes['runwayItemsOpposite'] && + !changes['runwayItemsOpposite'].firstChange) || + (changes['runwayItems'] && !changes['runwayItems'].firstChange) + ) { + this.runwayStateChanged$.next(); + } + } + + /** @internal */ + ngOnDestroy() { + this.detach(); + } + + /** @internal */ + attach( + viewport: RxVirtualScrollViewport, + viewRepeater: RxVirtualViewRepeater + ): void { + this.viewport = viewport; + this.viewRepeater = viewRepeater; + this.maintainVirtualItems(); + this.calcRenderedRange(); + this.positionElements(); + } + + /** @internal */ + detach(): void { + this.viewport = null; + this.viewRepeater = null; + this._virtualItems = []; + this.detached$.next(); + } + + scrollToIndex(index: number, behavior?: ScrollBehavior): void { + const _index = Math.min(Math.max(index, 0), this.contentLength - 1); + let offset = 0; + for (let i = 0; i < _index; i++) { + offset += this._virtualItems[i].size; + } + this.viewport!.scrollTo(offset, behavior); + } + + /** @internal */ + private maintainVirtualItems(): void { + this.viewRepeater!.values$.pipe(this.until$()).subscribe((items) => { + const changes = this.getDiffer(items)?.diff(items); + if (changes) { + changes.forEachOperation( + (change, adjustedPreviousIndex, currentIndex) => { + if (change.previousIndex == null) { + const entry = { + size: this.itemSize(change.item), + }; + if ( + currentIndex !== null && + currentIndex < this._virtualItems.length + ) { + this._virtualItems.splice(currentIndex, 0, entry); + } else { + this._virtualItems.push(entry); + } + } else if (currentIndex == null) { + const removeIdx = + adjustedPreviousIndex == null + ? this._virtualItems.length - 1 + : adjustedPreviousIndex; + removeFromArray(this._virtualItems, removeIdx); + } else if (adjustedPreviousIndex !== null) { + this._virtualItems[currentIndex] = + this._virtualItems[adjustedPreviousIndex]; + } + } + ); + changes.forEachIdentityChange((record) => { + this._virtualItems[record.currentIndex as number] = { + size: this.itemSize(record.item), + }; + }); + let contentSize = 0; + for (let i = 0; i < this._virtualItems.length; i++) { + contentSize += this._virtualItems[i].size; + } + this.contentSize = contentSize; + } + }); + } + + /** @internal */ + private calcRenderedRange(): void { + const dataLengthChanged$ = this.viewRepeater!.values$.pipe( + map( + (values) => + (Array.isArray(values) + ? values + : values != null + ? Array.from(values) + : [] + ).length + ), + distinctUntilChanged() + ); + const onScroll$ = this.viewport!.elementScrolled$.pipe( + debounce(() => unpatchedAnimationFrameTick()), + map(() => this.viewport!.getScrollTop()), + startWith(0), + tap((_scrollTop) => { + this.direction = _scrollTop > this.scrollTop ? 'down' : 'up'; + this.scrollTop = _scrollTop; + }) + ); + combineLatest([ + dataLengthChanged$.pipe( + tap((length) => { + this.contentLength = length; + }) + ), + this.viewport!.containerRect$.pipe( + map(({ height }) => height), + distinctUntilChanged() + ), + onScroll$, + this.runwayStateChanged$.pipe(startWith(void 0)), + ]) + .pipe( + map(([length, containerHeight]) => { + const range = { start: 0, end: 0 }; + + const delta = this.scrollTop - this.anchorScrollTop; + if (this.scrollTop == 0) { + this.anchorItem = { index: 0, offset: 0 }; + } else { + this.anchorItem = this.calculateAnchoredItem( + this.anchorItem, + delta + ); + } + this.scrolledIndex = this.anchorItem.index; + this.anchorScrollTop = this.scrollTop; + this.lastScreenItem = this.calculateAnchoredItem( + this.anchorItem, + containerHeight + ); + if (this.direction === 'up') { + range.start = Math.max(0, this.anchorItem.index - this.runwayItems); + range.end = Math.min( + length, + this.lastScreenItem.index + this.runwayItemsOpposite + ); + } else { + range.start = Math.max( + 0, + this.anchorItem.index - this.runwayItemsOpposite + ); + range.end = Math.min( + length, + this.lastScreenItem.index + this.runwayItems + ); + } + return range; + }) + ) + .pipe( + distinctUntilChanged( + ({ start: prevStart, end: prevEnd }, { start, end }) => + prevStart === start && prevEnd === end + ), + this.until$() + ) + .subscribe((range: ListRange) => (this.renderedRange = range)); + } + + /** @internal */ + private positionElements(): void { + this.viewRepeater!.renderingStart$.pipe( + switchMap(() => { + const renderedRange = this.renderedRange; + const adjustIndexWith = renderedRange.start; + let scrolledIndex: number | null = null; + let position = 0; + return this.viewRepeater!.viewRendered$.pipe( + map(({ view, index: viewIndex, item }, idx) => { + const index = viewIndex + adjustIndexWith; + if (idx === 0) { + position = this.calcInitialPosition(renderedRange); + } + const size = this.getItemSize(index); + this.positionElement(this.getElement(view), position); + position += size; + if (scrolledIndex == null && position > this.scrollTop) { + scrolledIndex = index; + } + if (scrolledIndex != null) { + this.scrolledIndex = scrolledIndex; + } + this.viewRenderCallback.next({ + index, + view, + item, + }); + }) + ); + }), + this.until$() + ).subscribe(); + } + + /** + * @internal + * heavily inspired by + * https://github.com/GoogleChromeLabs/ui-element-samples/blob/gh-pages/infinite-scroller/scripts/infinite-scroll.js + */ + private calculateAnchoredItem( + initialAnchor: AnchorItem, + delta: number + ): AnchorItem { + if (delta == 0) return initialAnchor; + delta += initialAnchor.offset; + let i = initialAnchor.index; + const items = this._virtualItems; + if (delta < 0) { + while (delta < 0 && i > 0) { + delta += items[i - 1].size; + i--; + } + } else { + while (delta > 0 && i < this.contentLength && items[i].size < delta) { + delta -= items[i].size; + i++; + } + } + return { + index: Math.min(i, this.contentLength), + offset: delta, + }; + } + + /** @internal */ + private calcInitialPosition(range: ListRange): number { + let pos = 0; + let i = 0; + this.anchorScrollTop = 0; + for (i = 0; i < this.anchorItem.index; i++) { + this.anchorScrollTop += this.getItemSize(i); + } + this.anchorScrollTop += this.anchorItem.offset; + + // Calculate position of starting node + pos = this.anchorScrollTop - this.anchorItem.offset; + i = this.anchorItem.index; + while (i > range.start) { + const itemSize = this.getItemSize(i - 1); + pos -= itemSize; + i--; + } + while (i < range.start) { + const itemSize = this.getItemSize(i); + pos += itemSize; + i++; + } + return pos; + } + /** @internal */ + private getItemSize(index: number): number { + return this._virtualItems[index].size; + } + + /** @internal */ + private positionElement(element: HTMLElement, scrollTop: number): void { + element.style.position = 'absolute'; + element.style.transform = `translateY(${scrollTop}px)`; + } + + /** @internal */ + private getDiffer(values: U | null | undefined): IterableDiffer | null { + if (this.dataDiffer) { + return this.dataDiffer; + } + return values + ? (this.dataDiffer = this.differs + .find(values) + .create(this.viewRepeater!._trackBy)) + : null; + } +} + +@NgModule({ + imports: [], + exports: [DynamicSizeVirtualScrollStrategy], + declarations: [DynamicSizeVirtualScrollStrategy], + providers: [], +}) +export class DynamicSizeVirtualScrollStrategyModule {} diff --git a/libs/template/experimental/virtual-scrolling/src/lib/scroll-strategies/fixed-size-virtual-scroll-strategy.ts b/libs/template/experimental/virtual-scrolling/src/lib/scroll-strategies/fixed-size-virtual-scroll-strategy.ts new file mode 100644 index 0000000000..a5c908f959 --- /dev/null +++ b/libs/template/experimental/virtual-scrolling/src/lib/scroll-strategies/fixed-size-virtual-scroll-strategy.ts @@ -0,0 +1,304 @@ +import { + Directive, + EmbeddedViewRef, + Inject, + Input, + NgIterable, + NgModule, + OnChanges, + OnDestroy, + Optional, + SimpleChanges, +} from '@angular/core'; +import { coalesceWith } from '@rx-angular/cdk/coalescing'; +import { + combineLatest, + MonoTypeOperatorFunction, + ReplaySubject, + Subject, +} from 'rxjs'; +import { + distinctUntilChanged, + map, + startWith, + switchMap, + takeUntil, + tap, +} from 'rxjs/operators'; + +import { + ListRange, + RxVirtualForViewContext, + RxVirtualScrollStrategy, + RxVirtualScrollViewport, + RxVirtualViewRepeater, +} from '../model'; +import { unpatchedAnimationFrameTick } from '../util'; +import { + DEFAULT_ITEM_SIZE, + DEFAULT_RUNWAY_ITEMS, + DEFAULT_RUNWAY_ITEMS_OPPOSITE, + RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS, + RxVirtualScrollDefaultOptions, +} from '../virtual-scroll.config'; + +/** + * @Directive FixedSizeVirtualScrollStrategy + * + * @description + * + * The `FixedSizeVirtualScrollStrategy` provides a very performant way of rendering + * items of a given size. It is comparable to \@angular/cdk `FixedSizeVirtualScrollStrategy`, but + * with a high performant layouting technique. + * + * @docsCategory RxVirtualFor + * @docsPage RxVirtualFor + * @publicApi + */ +@Directive({ + // eslint-disable-next-line @angular-eslint/directive-selector + selector: 'rx-virtual-scroll-viewport[itemSize]', + providers: [ + { + provide: RxVirtualScrollStrategy, + useExisting: FixedSizeVirtualScrollStrategy, + }, + ], +}) +// eslint-disable-next-line @angular-eslint/directive-class-suffix +export class FixedSizeVirtualScrollStrategy< + T, + U extends NgIterable = NgIterable + > + extends RxVirtualScrollStrategy + implements OnChanges, OnDestroy +{ + /** + * @description + * The size of the items in the virtually scrolled list + */ + @Input() + set itemSize(itemSize: number) { + if (typeof itemSize === 'number') { + this._itemSize = itemSize; + } + } + get itemSize() { + return this._itemSize; + } + + private _itemSize = DEFAULT_ITEM_SIZE; + + /** + * @description + * The amount of items to render upfront in scroll direction + */ + @Input() runwayItems = this.defaults?.runwayItems ?? DEFAULT_RUNWAY_ITEMS; + + /** + * @description + * The amount of items to render upfront in reverse scroll direction + */ + @Input() runwayItemsOpposite = + this.defaults?.runwayItemsOpposite ?? DEFAULT_RUNWAY_ITEMS_OPPOSITE; + + /** @internal */ + private readonly runwayStateChanged$ = new Subject(); + + private viewport: RxVirtualScrollViewport | null = null; + private viewRepeater: RxVirtualViewRepeater | null = null; + + private readonly _scrolledIndex$ = new ReplaySubject(1); + readonly scrolledIndex$ = this._scrolledIndex$.pipe(distinctUntilChanged()); + private _scrolledIndex = 0; + private set scrolledIndex(index: number) { + this._scrolledIndex = index; + this._scrolledIndex$.next(index); + } + + private readonly _contentSize$ = new ReplaySubject(1); + readonly contentSize$ = this._contentSize$.asObservable(); + private _contentSize = 0; + private set contentSize(size: number) { + this._contentSize = size; + this._contentSize$.next(size); + } + + private readonly _renderedRange$ = new ReplaySubject(1); + renderedRange$ = this._renderedRange$.asObservable(); + private _renderedRange: ListRange = { start: 0, end: 0 }; + private set renderedRange(range: ListRange) { + this._renderedRange = range; + this._renderedRange$.next(range); + } + private get renderedRange(): ListRange { + return this._renderedRange; + } + + private scrollTop = 0; + private direction: 'up' | 'down' = 'down'; + + private readonly detached$ = new Subject(); + + constructor( + @Optional() + @Inject(RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS) + private defaults?: RxVirtualScrollDefaultOptions + ) { + super(); + } + + /** @internal */ + ngOnChanges(changes: SimpleChanges) { + if ( + (changes['runwayItemsOpposite'] && + !changes['runwayItemsOpposite'].firstChange) || + (changes['runwayItems'] && !changes['runwayItems'].firstChange) + ) { + this.runwayStateChanged$.next(); + } + } + + ngOnDestroy() { + this.detach(); + } + + attach( + viewport: RxVirtualScrollViewport, + viewRepeater: RxVirtualViewRepeater + ): void { + this.viewport = viewport; + this.viewRepeater = viewRepeater; + this.calcRenderedRange(); + this.positionElements(); + } + + detach(): void { + this.viewport = null; + this.viewRepeater = null; + this.detached$.next(); + } + + private positionElements(): void { + this.viewRepeater!.renderingStart$.pipe( + switchMap(() => { + const start = this.renderedRange.start; + return this.viewRepeater!.viewRendered$.pipe( + tap(({ view, index, item }) => { + this._setViewPosition(view, (index + start) * this.itemSize); + this.viewRenderCallback.next({ + view, + item, + index, + }); + }) + ); + }), + this.untilDetached$() + ).subscribe(); + } + + private calcRenderedRange(): void { + const dataLengthChanged$ = this.viewRepeater!.values$.pipe( + map( + (values) => + (Array.isArray(values) + ? values + : values != null + ? Array.from(values) + : [] + ).length + ), + distinctUntilChanged(), + tap((dataLength) => (this.contentSize = dataLength * this.itemSize)) + ); + const onScroll$ = this.viewport!.elementScrolled$.pipe( + coalesceWith(unpatchedAnimationFrameTick()), + map(() => this.viewport!.getScrollTop()), + startWith(0), + tap((_scrollTop) => { + this.direction = _scrollTop > this.scrollTop ? 'down' : 'up'; + this.scrollTop = _scrollTop; + }) + ); + combineLatest([ + dataLengthChanged$, + this.viewport!.containerRect$.pipe( + map(({ height }) => height), + distinctUntilChanged() + ), + onScroll$, + this.runwayStateChanged$.pipe(startWith(void 0)), + ]) + .pipe( + map(([length, containerSize]) => { + const range: ListRange = { start: 0, end: 0 }; + if (this.direction === 'up') { + range.start = Math.floor( + Math.max(0, this.scrollTop - this.runwayItems * this.itemSize) / + this.itemSize + ); + range.end = Math.min( + length, + Math.ceil( + (this.scrollTop + + containerSize + + this.runwayItemsOpposite * this.itemSize) / + this.itemSize + ) + ); + } else { + range.start = Math.floor( + Math.max( + 0, + this.scrollTop - this.runwayItemsOpposite * this.itemSize + ) / this.itemSize + ); + range.end = Math.min( + length, + Math.ceil( + (this.scrollTop + + containerSize + + this.runwayItems * this.itemSize) / + this.itemSize + ) + ); + } + this.scrolledIndex = Math.floor(this.scrollTop / this.itemSize); + return range; + }), + distinctUntilChanged( + ({ start: prevStart, end: prevEnd }, { start, end }) => + prevStart === start && prevEnd === end + ), + this.untilDetached$() + ) + .subscribe((range) => (this.renderedRange = range)); + } + + scrollToIndex(index: number, behavior?: ScrollBehavior): void { + const scrollTop = this.itemSize * index; + this.viewport!.scrollTo(scrollTop, behavior); + } + + private untilDetached$(): MonoTypeOperatorFunction { + return (o$) => o$.pipe(takeUntil(this.detached$)); + } + + private _setViewPosition( + view: EmbeddedViewRef>, + scrollTop: number + ): void { + const element = this.getElement(view); + element.style.position = 'absolute'; + element.style.transform = `translateY(${scrollTop}px)`; + } +} + +@NgModule({ + imports: [], + exports: [FixedSizeVirtualScrollStrategy], + declarations: [FixedSizeVirtualScrollStrategy], + providers: [], +}) +export class FixedSizeVirtualScrollStrategyModule {} diff --git a/libs/template/experimental/virtual-scrolling/src/lib/util.ts b/libs/template/experimental/virtual-scrolling/src/lib/util.ts new file mode 100644 index 0000000000..6ce64bfba9 --- /dev/null +++ b/libs/template/experimental/virtual-scrolling/src/lib/util.ts @@ -0,0 +1,31 @@ +import { + cancelAnimationFrame, + Promise, + requestAnimationFrame, +} from '@rx-angular/cdk/zone-less/browser'; +import { from, Observable } from 'rxjs'; + +export function unpatchedAnimationFrameTick(): Observable { + return new Observable((observer) => { + const tick = requestAnimationFrame(() => { + observer.next(); + observer.complete(); + }); + return () => { + cancelAnimationFrame(tick); + }; + }); +} + +export function unpatchedMicroTask(): Observable { + return from(Promise.resolve()) as Observable; +} + +export function getZoneUnPatchedApi( + targetOrName: T, + name: N +) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + return targetOrName['__zone_symbol__' + name] || targetOrName[name]; +} diff --git a/libs/template/experimental/virtual-scrolling/src/lib/virtual-for.directive.ts b/libs/template/experimental/virtual-scrolling/src/lib/virtual-for.directive.ts new file mode 100644 index 0000000000..a03bbc5642 --- /dev/null +++ b/libs/template/experimental/virtual-scrolling/src/lib/virtual-for.directive.ts @@ -0,0 +1,643 @@ +import { + ChangeDetectorRef, + Directive, + ElementRef, + EmbeddedViewRef, + ErrorHandler, + Inject, + Input, + IterableDiffers, + NgIterable, + NgZone, + OnDestroy, + OnInit, + Optional, + TemplateRef, + TrackByFunction, + ViewContainerRef, +} from '@angular/core'; +import { coerceDistinctWith } from '@rx-angular/cdk/coercing'; +import { + RxStrategyNames, + RxStrategyProvider, +} from '@rx-angular/cdk/render-strategies'; +import { RxListViewComputedContext } from '@rx-angular/cdk/template'; +import { Observable, ReplaySubject, Subject } from 'rxjs'; +import { shareReplay, takeUntil } from 'rxjs/operators'; + +import { + RxVirtualForViewContext, + RxVirtualScrollStrategy, + RxVirtualViewRepeater, +} from './model'; +import { + DEFAULT_VIEW_CACHE_SIZE, + RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS, + RxVirtualScrollDefaultOptions, +} from './virtual-scroll.config'; +import { + createVirtualListManager, + VirtualListManager, +} from './virtual-template-manager'; + +/** + * @description Will be provided through Terser global definitions by Angular CLI + * during the production build. + */ +declare const ngDevMode: boolean; + +/** + * @Directive RxVirtualFor + * + * @description + * + * The `*rxVirtualFor` structural directive provides a convenient and performant + * way for rendering huge lists of items. It brings all the benefits `rxFor` does, + * and implements virtual rendering. + * + * Instead of rendering every item provided, rxVirtualFor only renders what is + * currently visible to the user, thus providing excellent runtime performance + * for huge sets of data. + * + * The technique to render items is comparable to the on used by twitter and + * explained in very much detail by @DasSurma in his blog post about the [complexities + * of infinite scrollers](https://developer.chrome.com/blog/infinite-scroller/). + * + * "Each recycling of a DOM element would normally relayout the entire runway which + * would bring us well below our target of 60 frames per second. + * To avoid this, we are taking the burden of layout onto ourselves and use + * absolutely positioned elements with transforms." (@DasSurma) + * + * ## API + * The API is a combination of \@rx-angular/template/for & + * \@angular/cdk `*cdkVirtualFor`. + * * trackBy: `(index: number, item: T) => any` | `keyof T` + * * strategy: `string` | `Observable` + * * parent: `boolean`; + * * renderCallback: `Subject` + * * viewCache: `number` + * * (Injected) scrollStrategy: `RxVirtualScrollStrategy` + * * provides itself as RxVirtualViewRepeater for RxVirtualViewPortComponent to operate + * + * ## Features + * * Push based architecture + * * Comprehensive set of context variables + * * Opt-out of `NgZone` with `patchZone` + * * Notify when rendering of child templates is finished (`renderCallback`) + * * Super efficient layouting with css transformations + * * Define a viewCache in order to re-use views instead of re-creating them + * * Configurable RxVirtualScrollStrategy providing the core logic to calculate the viewRange and position DOM + * Nodes + * + * ### Context Variables + * + * The following context variables are available for each template: + * + * - $implicit: `T` // the default variable accessed by `let val` + * - item$: `Observable` // the same value as $implicit, but as `Observable` + * - index: `number` // current index of the item + * - count: `number` // count of all items in the list + * - first: `boolean` // true if the item is the first in the list + * - last: `boolean` // true if the item is the last in the list + * - even: `boolean` // true if the item has on even index (index % 2 === 0) + * - odd: `boolean` // the opposite of even + * - index$: `Observable` // index as `Observable` + * - count$: `Observable` // count as `Observable` + * - first$: `Observable` // first as `Observable` + * - last$: `Observable` // last as `Observable` + * - even$: `Observable` // even as `Observable` + * - odd$: `Observable` // odd as `Observable` + * - select: `(keys: (keyof T)[], distinctByMap) => Observable>` + * // returns a selection function which + * // accepts an array of properties to pluck out of every list item. The function returns the selected properties of + * // the current list item as distinct `Observable` key-value-pair. See the example below: + * + * This example showcases the `select` view-context function used for deeply nested lists. + * + * ```html + * + *
+ *
+ * {{ hero.name }}
+ * Defeated enemies: + *
+ * + * {{ enemy.name }} + * + *
+ *
+ * ``` + * + * ### Using the context variables + * + * ```html + * + *
+ *
{{ count }}
+ *
{{ index }}
+ *
{{ item }}
+ *
{{ first }}
+ *
{{ last }}
+ *
{{ even }}
+ *
{{ odd }}
+ *
+ *
+ * ``` + * + * @docsCategory RxVirtualFor + * @docsPage RxVirtualFor + * @publicApi + */ +@Directive({ + selector: '[rxVirtualFor]', + providers: [{ provide: RxVirtualViewRepeater, useExisting: RxVirtualFor }], +}) +// eslint-disable-next-line @angular-eslint/directive-class-suffix +export class RxVirtualFor = NgIterable> + implements RxVirtualViewRepeater, OnInit, OnDestroy +{ + /** @internal */ + static ngTemplateGuard_rxVirtualFor: 'binding'; + + /** + * @description + * The iterable input + * + * @example + * + * + * + * + * @param potentialObservable + */ + @Input() + set rxVirtualFor( + potentialObservable: + | Observable> + | NgIterable + | null + | undefined + ) { + this.observables$.next(potentialObservable); + } + + /** + * @description + * The iterable input + * + * @example + * + * + * + * + * @param potentialObservable + */ + @Input() + set rxVirtualForOf( + potentialObservable: + | Observable> + | NgIterable + | null + | undefined + ) { + this.observables$.next(potentialObservable); + } + + /** + * @internal + * A reference to the template that is created for each item in the iterable. + * @see [template reference variable](guide/template-reference-variables) + * (inspired by @angular/common `ng_for_of.ts`) + */ + private _template?: TemplateRef>; + @Input() + set rxVirtualForTemplate(value: TemplateRef>) { + this._template = value; + } + + /** + * @description + * The rendering strategy to be used to render updates to the DOM. + * Use it to dynamically manage your rendering strategy. You can switch the strategy + * imperatively (with a string) or by binding an Observable. + * The default strategy is `'normal'` if not configured otherwise. + * + * @example + * \@Component({ + * selector: 'app-root', + * template: ` + * + * + * + * ` + * }) + * export class AppComponent { + * strategy = 'low'; + * } + * + * // OR + * + * \@Component({ + * selector: 'app-root', + * template: ` + * + * + * + * ` + * }) + * export class AppComponent { + * strategy$ = new BehaviorSubject('immediate'); + * } + * + * @param strategyName + * @see {@link strategies} + */ + @Input('rxVirtualForStrategy') + set strategy( + strategyName: RxStrategyNames | Observable> + ) { + this.strategyInput$.next(strategyName); + } + + /** + * @description + * 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. + */ + @Input('rxVirtualForViewCacheSize') viewCacheSize = + this.defaults?.viewCacheSize || DEFAULT_VIEW_CACHE_SIZE; + + /** + * @description + * If `parent` is set to `true` (default to `false`), `*rxVirtualFor` will + * automatically run change-detection for its parent component when its scheduled + * tasks are done in order to update any pending `@ContentChild` or `@ViewChild` + * relation to be updated according to the updated ViewContainer. + * + * @example + * \@Component({ + * selector: 'app-root', + * template: ` + * + * + * + * ` + * }) + * export class AppComponent { + * // those queries won't be in sync with what `rxVirtualFor` is rendering + * // when parent is set to false. + * \@ViewChildren(AppListItem) listItems: QueryList; + * + * items$ = itemService.getItems(); + * } + * + * @param renderParent + */ + @Input('rxVirtualForParent') renderParent = false; + + /** + * @description + * A flag to control whether `*rxVirtualFor` rendering happens within + * `NgZone` or not. The default value is set to `true` if not configured otherwise. + * If `patchZone` is set to `false` `*rxVirtualFor` will operate completely outside of `NgZone`. + * + * @example + * \@Component({ + * selector: 'app-root', + * template: ` + * + * + * + * ` + * }) + * export class AppComponent { + * items$ = itemService.getItems(); + * } + * + * @param patchZone + */ + @Input('rxVirtualForPatchZone') patchZone = + this.strategyProvider.config.patchZone; + + /*@Input('rxVirtualForTombstone') tombstone: TemplateRef< + RxVirtualForViewContext + > | null = null;*/ + + /** + * @description + * A function or key that defines how to track changes for items in the provided + * iterable data. + * + * When items are added, moved, or removed in the iterable, + * the directive must re-render the appropriate DOM nodes. + * To minimize operations on the DOM, only nodes that have changed + * are re-rendered. + * + * By default, `rxVirtualFor` assumes that the object instance identifies + * the node in the iterable (equality check `===`). + * When a function or key is supplied, `rxVirtualFor` uses the result to identify the item node. + * + * @example + * \@Component({ + * selector: 'app-root', + * template: ` + * + * + * + * + * ` + * }) + * export class AppComponent { + * items$ = itemService.getItems(); + * } + * + * // OR + * + * \@Component({ + * selector: 'app-root', + * template: ` + * + * + * + * + * ` + * }) + * export class AppComponent { + * items$ = itemService.getItems(); + * trackItem = (idx, item) => item.id; + * } + * + * @param trackByFnOrKey + */ + @Input('rxVirtualForTrackBy') + set trackBy(trackByFnOrKey: keyof T | ((idx: number, i: T) => unknown)) { + if (typeof trackByFnOrKey === 'function') { + this._trackBy = trackByFnOrKey; + } else if (typeof trackByFnOrKey === 'string') { + this._trackBy = (i, a) => a[trackByFnOrKey]; + } else if (ngDevMode) { + throw new Error( + `Received incorrect value for trackBy, expected string | ((idx: number, i: T) => unknown), got ${typeof trackByFnOrKey}` + ); + } + } + + /** + * @description + * A `Subject` which emits whenever `*rxVirtualFor` finished rendering a + * set of changes to the view. + * This enables developers to perform actions exactly at the timing when the + * updates passed are rendered to the DOM. + * The `renderCallback` is useful in situations where you rely on specific DOM + * properties like the `height` of a table after all items got rendered. + * It is also possible to use the renderCallback in order to determine if a + * view should be visible or not. This way developers can hide a list as + * long as it has not finished rendering. + * + * The result of the `renderCallback` will contain the currently rendered set + * of items in the iterable. + * + * @example + * \@Component({ + * selector: 'app-root', + * template: ` + * + * + * + * + * ` + * }) + * export class AppComponent { + * items$: Observable = itemService.getItems(); + * trackItem = (idx, item) => item.id; + * // this emits whenever rxVirtualFor finished rendering changes + * itemsRendered = new Subject(); + * } + * + * @param renderCallback + */ + @Input('rxVirtualForRenderCallback') set renderCallback( + renderCallback: Subject + ) { + this._renderCallback = renderCallback; + } + + /** @internal */ + readonly rendered$ = new Subject(); + /** @internal */ + readonly viewsRendered$ = new Subject[]>(); + /** @internal */ + readonly viewRendered$ = new Subject<{ + view: EmbeddedViewRef>; + index: number; + item: T; + }>(); + /** @internal */ + readonly renderingStart$ = new Subject(); + + /** @internal */ + private get template(): TemplateRef> { + return this._template || this.templateRef; + } + + /** @internal */ + constructor( + private scrollStrategy: RxVirtualScrollStrategy, + private iterableDiffers: IterableDiffers, + private cdRef: ChangeDetectorRef, + private ngZone: NgZone, + private eRef: ElementRef, + private readonly templateRef: TemplateRef>, + private readonly viewContainerRef: ViewContainerRef, + private strategyProvider: RxStrategyProvider, + private errorHandler: ErrorHandler, + @Optional() + @Inject(RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS) + private defaults?: RxVirtualScrollDefaultOptions + ) {} + + /** @internal */ + private strategyInput$ = new ReplaySubject>(1); + + /** @internal */ + private observables$ = new ReplaySubject< + | Observable | null | undefined> + | NgIterable + | null + | undefined + >(1); + + /** @internal */ + private _renderCallback?: Subject; + + /** @internal */ + readonly values$ = this.observables$.pipe( + coerceDistinctWith(), + shareReplay({ bufferSize: 1, refCount: true }) + ); + + /** @internal */ + private values?: NgIterable | null | undefined; + + /** @internal */ + private readonly strategy$ = this.strategyInput$.pipe(coerceDistinctWith()); + + /** @internal */ + private listManager!: VirtualListManager>; + + /** @internal */ + private _destroy$ = new Subject(); + + /** @internal */ + static ngTemplateContextGuard< + T, + U extends NgIterable = NgIterable, + K = keyof T + >( + dir: RxVirtualFor, + ctx: any + ): ctx is RxVirtualForViewContext { + return true; + } + + /** @internal */ + _trackBy: TrackByFunction = (i, a) => a; + + /** @internal */ + ngOnInit() { + this.listManager = createVirtualListManager< + T, + RxVirtualForViewContext, + RxListViewComputedContext + >({ + iterableDiffers: this.iterableDiffers, + renderSettings: { + cdRef: this.cdRef, + strategies: this.strategyProvider.strategies as any, // TODO: move strategyProvider + defaultStrategyName: this.strategyProvider.primaryStrategy, + parent: this.renderParent, + patchZone: this.patchZone ? this.ngZone : undefined, + errorHandler: this.errorHandler, + }, + templateSettings: { + viewContainerRef: this.viewContainerRef, + templateRef: this.template, + createViewContext: this.createViewContext.bind(this), + updateViewContext: this.updateViewContext.bind(this), + viewCacheSize: this.viewCacheSize, + }, + trackBy: this._trackBy, + }); + this.listManager.nextStrategy(this.strategy$); + this.values$.pipe(takeUntil(this._destroy$)).subscribe((values) => { + this.values = values; + }); + this.listManager + .render(this.values$, this.scrollStrategy.renderedRange$) + .pipe(takeUntil(this._destroy$)) + .subscribe((v) => { + this.rendered$.next(v); + this._renderCallback?.next(v); + }); + this.listManager.viewsRendered$ + .pipe(takeUntil(this._destroy$)) + .subscribe(this.viewsRendered$); + this.listManager.viewRendered$ + .pipe(takeUntil(this._destroy$)) + .subscribe(this.viewRendered$); + this.listManager.renderingStart$ + .pipe(takeUntil(this._destroy$)) + .subscribe(this.renderingStart$); + } + + /** @internal */ + createViewContext( + item: T, + computedContext: RxListViewComputedContext + ): RxVirtualForViewContext { + return new RxVirtualForViewContext( + item, + this.values! as U, + computedContext + ); + } + + /** @internal */ + updateViewContext( + item: T, + view: EmbeddedViewRef< + RxVirtualForViewContext + >, + computedContext?: RxListViewComputedContext + ): void { + view.context.updateContext(computedContext!); + view.context.$implicit = item; + view.context.rxVirtualForOf = this.values! as U; + } + + /** @internal */ + ngOnDestroy() { + this._destroy$.next(); + this.listManager.detach(); + } +} diff --git a/libs/template/experimental/virtual-scrolling/src/lib/virtual-list-view-handler.ts b/libs/template/experimental/virtual-scrolling/src/lib/virtual-list-view-handler.ts new file mode 100644 index 0000000000..8113d14b30 --- /dev/null +++ b/libs/template/experimental/virtual-scrolling/src/lib/virtual-list-view-handler.ts @@ -0,0 +1,383 @@ +import { EmbeddedViewRef, IterableChanges, NgZone } from '@angular/core'; +import { + onStrategy, + RxStrategyCredentials, +} from '@rx-angular/cdk/render-strategies'; +import { RxListViewContext } from '@rx-angular/cdk/template'; +import { Observable, Subject } from 'rxjs'; + +import { + ListRange, + ListTemplateChange, + ListTemplateChanges, + ListTemplateChangeType, + TemplateSettings, +} from './model'; + +/** + * @internal + * + * An object that holds methods needed to introduce actions to a list e.g. move, remove, insert + */ +export interface RxVirtualListTemplateManager { + toTemplateWork( + iterableChanges: IterableChanges, + items: T[], + renderedRange: ListRange, + strategy: RxStrategyCredentials, + count: number, + ngZone?: NgZone + ): { + insertedOrRemoved: boolean; + changes: ListTemplateChange[]; + work$: Observable>[]; + }; + viewRendered$: Observable<{ + view: EmbeddedViewRef; + index?: number; + item: T; + change: ListTemplateChangeType; + }>; + detach(): void; +} + +/** + * @internal + * + * Factory that returns a `ListTemplateManager` for the passed params. + * + * @param templateSettings + */ +export function getVirtualTemplateHandler, T>({ + viewContainerRef, + templateRef, + createViewContext, + updateViewContext, + viewCacheSize, +}: TemplateSettings): RxVirtualListTemplateManager { + let _viewCache: EmbeddedViewRef[] = []; + const _viewRendered$ = new Subject<{ + index?: number; + view: EmbeddedViewRef; + item: T; + change: ListTemplateChangeType; + }>(); + + return { + toTemplateWork, + viewRendered$: _viewRendered$, + detach: () => { + for (const view of _viewCache) { + view.destroy(); + } + _viewCache = []; + }, + }; + + function toTemplateWork( + iterableChanges: IterableChanges, + items: T[], + renderedRange: ListRange, + strategy: RxStrategyCredentials, + count: number, + ngZone?: NgZone + ) { + const listChanges = getListChanges(iterableChanges, items); + const changes = listChanges[0].sort(([, payloadA], [, payloadB]) => { + return (payloadA[1] || 0) - (payloadB[1] || 0); + }); + const insertedOrRemoved = listChanges[1]; + return { + insertedOrRemoved, + changes, + work$: + changes.length > 0 + ? changes.map((change) => { + return onStrategy( + change, + strategy, + (_change) => { + const [type, [item, index, adjustedPreviousIndex]] = _change; + const update = () => ({ + view: viewContainerRef.get( + index || 0 + ) as EmbeddedViewRef, + index, + change: type, + item, + }); + switch (type) { + case ListTemplateChangeType.insert: + _insertView( + item, + index!, + count, + renderedRange.start + index! + ); + // console.log('perform insert', update()); + _viewRendered$.next(update()); + break; + case ListTemplateChangeType.move: + _moveView( + item, + adjustedPreviousIndex!, + index!, + count, + renderedRange.start + index! + ); + // console.log('perform move', update()); + _viewRendered$.next(update()); + break; + case ListTemplateChangeType.remove: + if (index != null) { + _detachAndCacheView(index); + } + // console.log('perform remove', update()); + // _viewRendered$.next(update()); + break; + case ListTemplateChangeType.update: + _updateView( + item, + index!, + count, + renderedRange.start + index! + ); + // console.log('perform update', update()); + _viewRendered$.next(update()); + break; + case ListTemplateChangeType.context: + _updateView( + item, + index!, + count, + renderedRange.start + index! + ); + // console.log('perform context', update()); + _viewRendered$.next(update()); + break; + } + }, + { ngZone } + ); + }) + : [], + }; + } + + function _updateUnchangedContext( + index: number, + count: number, + contextIndex: number + ) { + const view = >viewContainerRef.get(index); + view.context.updateContext({ + count, + index: contextIndex, + } as any); + view.detectChanges(); + } + + function _updateView( + item: T, + index: number, + count: number, + contextIndex: number + ): void { + const view = >viewContainerRef.get(index); + updateViewContext(item, view, { + count, + index: contextIndex, + } as any); + view.detectChanges(); + } + + /** + * Inserts a view for a new item, either from the cache or by creating a new + * one. Returns `undefined` if the item was inserted into a cached view. + */ + function _insertView( + value: T, + currentIndex: number, + count: number, + contextIndex: number + ): EmbeddedViewRef | undefined { + const cachedView = _insertViewFromCache(currentIndex); + if (cachedView) { + updateViewContext(value, cachedView, { + count, + index: contextIndex, + } as any); + cachedView.detectChanges(); + return undefined; + } + const context = createViewContext(value, { + count, + index: contextIndex, + }); + const view = viewContainerRef.createEmbeddedView( + templateRef, + context, + currentIndex + ); + view.detectChanges(); + return view; + } + + /** Detaches the view at the given index and inserts into the view cache. */ + function _detachAndCacheView(index: number) { + const detachedView = viewContainerRef.detach(index) as EmbeddedViewRef; + _maybeCacheView(detachedView); + detachedView.detectChanges(); + } + + /** Moves view at the previous index to the current index. */ + function _moveView( + value: T, + adjustedPreviousIndex: number, + currentIndex: number, + count: number, + contextIndex: number + ): EmbeddedViewRef { + const view = viewContainerRef.get( + adjustedPreviousIndex + ) as EmbeddedViewRef; + viewContainerRef.move(view, currentIndex); + updateViewContext(value, view, { + count, + index: contextIndex, + } as any); + view.detectChanges(); + return view; + } + + /** + * Cache the given detached view. If the cache is full, the view will be + * destroyed. + */ + function _maybeCacheView(view: EmbeddedViewRef) { + if (_viewCache.length < viewCacheSize) { + _viewCache.push(view); + return true; + } else { + const index = viewContainerRef.indexOf(view); + + // The host component could remove views from the container outside of + // the view repeater. It's unlikely this will occur, but just in case, + // destroy the view on its own, otherwise destroy it through the + // container to ensure that all the references are removed. + if (index === -1) { + view.destroy(); + } else { + viewContainerRef.remove(index); + } + return false; + } + } + + /** Inserts a recycled view from the cache at the given index. */ + function _insertViewFromCache(index: number): EmbeddedViewRef | null { + const cachedView = _viewCache.pop(); + if (cachedView) { + viewContainerRef.insert(cachedView, index); + } + return cachedView || null; + } +} + +/** + * @internal + * + * @param changes + * @param items + */ +function getListChanges( + changes: IterableChanges, + items: T[] +): ListTemplateChanges { + const changedIdxs = new Set(); + const changesArr: ListTemplateChange[] = []; + let notifyParent = false; + changes.forEachOperation( + ({ item, previousIndex }, adjustedPreviousIndex, currentIndex) => { + if (previousIndex == null) { + // insert + changesArr.push(getInsertChange(item, currentIndex)); + changedIdxs.add(item); + notifyParent = true; + } else if (currentIndex == null) { + // remove + changesArr.push(getRemoveChange(item, adjustedPreviousIndex)); + notifyParent = true; + } else if (adjustedPreviousIndex !== null) { + // move + changesArr.push( + getMoveChange(item, currentIndex, adjustedPreviousIndex) + ); + changedIdxs.add(item); + notifyParent = true; + } + } + ); + changes.forEachIdentityChange(({ item, currentIndex }) => { + if (currentIndex != null && !changedIdxs.has(item)) { + changesArr.push(getUpdateChange(item, currentIndex)); + changedIdxs.add(item); + } + }); + if (notifyParent) { + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (!changedIdxs.has(item)) { + changesArr.push(getUnchangedChange(item, i)); + } + } + } + return [changesArr, notifyParent]; + + // ========== + + function getMoveChange( + item: T, + currentIndex: number, + adjustedPreviousIndex: number + ): ListTemplateChange { + return [ + ListTemplateChangeType.move, + [item, currentIndex, adjustedPreviousIndex], + ]; + } + + function getUpdateChange( + item: T, + currentIndex: number + ): ListTemplateChange { + return [ListTemplateChangeType.update, [item, currentIndex]]; + } + + function getUnchangedChange(item: T, index: number): ListTemplateChange { + return [ListTemplateChangeType.context, [item, index]]; + } + + function getInsertChange( + item: T, + currentIndex: number | null + ): ListTemplateChange { + return [ + ListTemplateChangeType.insert, + [item, currentIndex === null ? undefined : currentIndex], + ]; + } + + function getRemoveChange( + item: T, + adjustedPreviousIndex: number | null + ): ListTemplateChange { + return [ + ListTemplateChangeType.remove, + [ + item, + adjustedPreviousIndex === null ? undefined : adjustedPreviousIndex, + ], + ]; + } +} diff --git a/libs/template/experimental/virtual-scrolling/src/lib/virtual-scroll-viewport.component.ts b/libs/template/experimental/virtual-scrolling/src/lib/virtual-scroll-viewport.component.ts new file mode 100644 index 0000000000..ef2ae99bc3 --- /dev/null +++ b/libs/template/experimental/virtual-scrolling/src/lib/virtual-scroll-viewport.component.ts @@ -0,0 +1,248 @@ +import { + AfterContentInit, + AfterViewInit, + ChangeDetectionStrategy, + Component, + ContentChild, + ElementRef, + OnDestroy, + OnInit, + Optional, + Output, + ViewChild, +} from '@angular/core'; +import { defer, Observable, ReplaySubject, Subject } from 'rxjs'; +import { distinctUntilChanged, takeUntil } from 'rxjs/operators'; + +import { + RxVirtualScrollStrategy, + RxVirtualScrollViewport, + RxVirtualViewRepeater, +} from './model'; +import { observeElementSize } from './observe-element-size'; +import { getZoneUnPatchedApi } from './util'; + +/** + * @description Will be provided through Terser global definitions by Angular CLI + * during the production build. + */ +declare const ngDevMode: boolean; + +/** + * @Component RxVirtualScrollViewport + * + * @description + * 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`. + * + * @docsCategory RxVirtualFor + * @docsPage RxVirtualFor + * @publicApi + */ +@Component({ + selector: 'rx-virtual-scroll-viewport', + template: ` +
+
+ +
+ `, + providers: [ + { + provide: RxVirtualScrollViewport, + useExisting: RxVirtualScrollViewportComponent, + }, + ], + styles: [ + ` + :host { + display: block; + width: 100%; + height: 100%; + box-sizing: border-box; + contain: strict; + } + + :host:not([autosize]) .rx-virtual-scroll__viewport { + transform: translateZ(0); + will-change: scroll-position; + } + + .rx-virtual-scroll__viewport { + contain: strict; + -webkit-overflow-scrolling: touch; + width: 100%; + position: absolute; + top: 0; + bottom: 0; + overflow: auto; + } + + .rx-virtual-scroll__run-way { + width: 1px; + height: 1px; + contain: strict; + position: absolute; + will-change: transform; + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class RxVirtualScrollViewportComponent + implements + OnInit, + RxVirtualScrollViewport, + AfterViewInit, + AfterContentInit, + OnDestroy +{ + /** @internal */ + @ViewChild('runway', { static: true }) + private runway!: ElementRef; + + /** @internal */ + @ViewChild('scrollViewport', { static: true }) + private scrollViewport!: ElementRef; + + /** @internal */ + @ContentChild(RxVirtualViewRepeater) + viewRepeater!: RxVirtualViewRepeater; + + /** @internal */ + readonly rendered$ = defer(() => this.viewRepeater.rendered$); + + /** @internal */ + private _elementScrolled = new Subject(); + readonly elementScrolled$ = + this._elementScrolled.asObservable() as unknown as Observable; + + /** @internal */ + private _containerRect$ = new ReplaySubject<{ + width: number; + height: number; + }>(1); + readonly containerRect$ = this._containerRect$.asObservable(); + + /** + * @description + * + * 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. + */ + @Output() + readonly viewRange = this.scrollStrategy.renderedRange$; + + /** + * @description + * + * The index of the currently scrolled item. The scrolled item is the topmost + * item actually being visible to the user. + */ + @Output() + readonly scrolledIndexChange = this.scrollStrategy.scrolledIndex$; + + /** @internal */ + private readonly destroy$ = new Subject(); + + /** @internal */ + constructor( + private elementRef: ElementRef, + @Optional() private scrollStrategy: RxVirtualScrollStrategy + ) { + if (ngDevMode && !scrollStrategy) { + throw Error( + 'Error: rx-virtual-scroll-viewport requires an `RxVirtualScrollStrategy` to be set.' + ); + } + } + + /** @internal */ + ngOnInit(): void { + observeElementSize(this.elementRef.nativeElement, { + extract: (entries) => ({ + height: Math.round(entries[0].contentRect.height), + width: Math.round(entries[0].contentRect.width), + }), + }) + .pipe( + distinctUntilChanged( + ({ height: prevHeight, width: prevWidth }, { height, width }) => + prevHeight === height && prevWidth === width + ), + takeUntil(this.destroy$) + ) + .subscribe(this._containerRect$); + } + + ngAfterViewInit() { + getZoneUnPatchedApi(this.scrollContainer(), 'addEventListener').call( + this.scrollContainer(), + 'scroll', + this.scrollListener, + { + passive: true, + } + ); + } + + /** @internal */ + ngAfterContentInit(): void { + if (ngDevMode && !this.viewRepeater) { + throw Error( + 'Error: rx-virtual-scroll-viewport requires a `RxVirtualViewRepeater` to be provided.' + ); + } + this.scrollStrategy.attach(this, this.viewRepeater); + this.scrollStrategy.contentSize$ + .pipe(distinctUntilChanged(), takeUntil(this.destroy$)) + .subscribe((size) => this.updateContentSize(size)); + } + + /** @internal */ + ngOnDestroy(): void { + getZoneUnPatchedApi(this.scrollContainer(), 'removeEventListener').call( + this.scrollContainer(), + 'scroll', + this.scrollListener + ); + this.destroy$.next(); + this.scrollStrategy.detach(); + } + + elementScrolled(): Observable { + return this._elementScrolled.asObservable(); + } + + scrollContainer(): HTMLElement { + return this.scrollViewport.nativeElement; + } + + getScrollTop(): number { + return this.scrollContainer().scrollTop; + } + + scrollTo(index: number, behavior?: ScrollBehavior): void { + // TODO: implement more complex scroll scenarios + this.scrollContainer().scrollTo({ top: index, behavior: behavior }); + } + + scrollToIndex(index: number, behavior?: ScrollBehavior): void { + this.scrollStrategy.scrollToIndex(index, behavior); + } + + protected updateContentSize(size: number): void { + this.runway.nativeElement.style.transform = `translate(0, ${size}px)`; + } + + private scrollListener = (event: Event) => this._elementScrolled.next(event); +} diff --git a/libs/template/experimental/virtual-scrolling/src/lib/virtual-scroll.config.ts b/libs/template/experimental/virtual-scrolling/src/lib/virtual-scroll.config.ts new file mode 100644 index 0000000000..ccf9c522c5 --- /dev/null +++ b/libs/template/experimental/virtual-scrolling/src/lib/virtual-scroll.config.ts @@ -0,0 +1,40 @@ +import { InjectionToken } from '@angular/core'; + +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; +} +/** Injection token to be used to override the default options. */ +export const RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS = + new InjectionToken( + 'rx-virtual-scrolling-default-options', + { + providedIn: 'root', + factory: RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS_FACTORY, + } + ); + +/** @internal */ +export function RX_VIRTUAL_SCROLL_DEFAULT_OPTIONS_FACTORY(): RxVirtualScrollDefaultOptions { + return { + runwayItems: DEFAULT_RUNWAY_ITEMS, + runwayItemsOpposite: DEFAULT_RUNWAY_ITEMS_OPPOSITE, + viewCacheSize: DEFAULT_VIEW_CACHE_SIZE, + itemSize: DEFAULT_ITEM_SIZE, + }; +} + +/** @internal */ +export const DEFAULT_VIEW_CACHE_SIZE = 20; +/** @internal */ +export const DEFAULT_ITEM_SIZE = 50; +/** @internal */ +export const DEFAULT_RUNWAY_ITEMS = 10; +/** @internal */ +export const DEFAULT_RUNWAY_ITEMS_OPPOSITE = 2; diff --git a/libs/template/experimental/virtual-scrolling/src/lib/virtual-scrolling.module.ts b/libs/template/experimental/virtual-scrolling/src/lib/virtual-scrolling.module.ts new file mode 100644 index 0000000000..159c77ef48 --- /dev/null +++ b/libs/template/experimental/virtual-scrolling/src/lib/virtual-scrolling.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; + +import { RxVirtualFor } from './virtual-for.directive'; +import { RxVirtualScrollViewportComponent } from './virtual-scroll-viewport.component'; + +@NgModule({ + imports: [], + exports: [RxVirtualScrollViewportComponent, RxVirtualFor], + declarations: [RxVirtualScrollViewportComponent, RxVirtualFor], + providers: [], +}) +export class RxVirtualScrollingModule {} diff --git a/libs/template/experimental/virtual-scrolling/src/lib/virtual-template-manager.ts b/libs/template/experimental/virtual-scrolling/src/lib/virtual-template-manager.ts new file mode 100644 index 0000000000..5e4ba887a4 --- /dev/null +++ b/libs/template/experimental/virtual-scrolling/src/lib/virtual-template-manager.ts @@ -0,0 +1,250 @@ +import { + EmbeddedViewRef, + IterableChanges, + IterableDiffer, + IterableDiffers, + NgIterable, + TrackByFunction, +} from '@angular/core'; +import { + onStrategy, + RxStrategyCredentials, + strategyHandling, +} from '@rx-angular/cdk/render-strategies'; +import { RxListViewContext } from '@rx-angular/cdk/template'; +import { + combineLatest, + concat, + MonoTypeOperatorFunction, + NEVER, + Observable, + of, + OperatorFunction, + Subject, +} from 'rxjs'; +import { distinctUntilChanged, ignoreElements } from 'rxjs/operators'; +import { catchError, filter, map, switchMap, tap } from 'rxjs/operators'; + +import { + ListRange, + ListTemplateChangeType, + RenderSettings, + TemplateSettings, +} from './model'; +import { getVirtualTemplateHandler } from './virtual-list-view-handler'; + +export type VirtualListManager = { + render( + data$: Observable | null | undefined>, + range$: Observable + ): Observable; + nextStrategy: (config: string | Observable) => void; + viewsRendered$: Observable[]>; + viewRendered$: Observable<{ + index: number; + view: EmbeddedViewRef; + item: T; + }>; + renderingStart$: Observable; + detach(): void; +}; + +export function createVirtualListManager< + T, + C extends RxListViewContext, + U +>(config: { + renderSettings: RenderSettings; + templateSettings: TemplateSettings; + // + trackBy: TrackByFunction; + iterableDiffers: IterableDiffers; +}): VirtualListManager { + const { templateSettings, renderSettings, trackBy, iterableDiffers } = config; + const { + defaultStrategyName, + strategies, + cdRef: injectingViewCdRef, + parent, + patchZone, + } = renderSettings; + const errorHandler = renderSettings.errorHandler; + const strategyHandling$ = strategyHandling(defaultStrategyName, strategies); + let _differ: IterableDiffer | undefined; + function getDiffer(values: NgIterable): IterableDiffer | null { + if (_differ) { + return _differ; + } + return values + ? (_differ = iterableDiffers.find(values).create(trackBy)) + : null; + } + // type, payload + const listViewHandler = getVirtualTemplateHandler({ + ...templateSettings, + }); + const viewContainerRef = templateSettings.viewContainerRef; + + let partiallyFinished = false; + let renderedRange: ListRange; + const _viewsRendered$ = new Subject[]>(); + const _viewRendered$ = new Subject<{ + index: number; + view: EmbeddedViewRef; + item: T; + }>(); + const _renderingStart$ = new Subject(); + const viewRenderedSub = listViewHandler.viewRendered$ + .pipe( + filter( + ({ view, change }) => !!view && change !== ListTemplateChangeType.remove + ), + map( + ({ view, item, index }) => + ({ + view, + item, + index, + } as { + index: number; + view: EmbeddedViewRef; + item: T; + }) + ) + ) + .subscribe(_viewRendered$); + + function handleError(): MonoTypeOperatorFunction { + return (o$) => + o$.pipe( + catchError((err: Error) => { + partiallyFinished = false; + errorHandler?.handleError(err); + return of(null); + }) + ); + } + + return { + viewsRendered$: _viewsRendered$, + viewRendered$: _viewRendered$, + renderingStart$: _renderingStart$, + nextStrategy(nextConfig: Observable | string): void { + strategyHandling$.next(nextConfig); + }, + render( + values$: Observable | null | undefined>, + range$: Observable + ): Observable { + return combineLatest([ + values$.pipe( + map((values) => + Array.isArray(values) + ? values + : values != null + ? Array.from(values) + : [] + ) + ), + range$, + strategyHandling$.strategy$.pipe(distinctUntilChanged()), + ]).pipe(render(), handleError()); + }, + detach(): void { + viewRenderedSub.unsubscribe(); + listViewHandler.detach(); + }, + }; + + function render(): OperatorFunction< + [T[], ListRange, RxStrategyCredentials], + any + > { + return ( + o$: Observable<[T[], ListRange, RxStrategyCredentials]> + ): Observable => + o$.pipe( + // map iterable to latest diff + map(([items, range, strategy]) => { + renderedRange = range; + const iterable = items.slice(range.start, range.end); + const differ = getDiffer(iterable); + let changes: IterableChanges | null = null; + if (differ) { + if (partiallyFinished) { + const currentIterable = []; + for (let i = 0, ilen = viewContainerRef.length; i < ilen; i++) { + const viewRef = >viewContainerRef.get(i); + currentIterable[i] = viewRef.context.$implicit; + } + differ.diff(currentIterable); + // partiallyFinished = false; + } + changes = differ.diff(iterable); + } + return { + changes, + itemsToRender: iterable, + count: items.length, + strategy, + }; + }), + // Cancel old renders + switchMap(({ changes, count, itemsToRender, strategy }) => { + if (!changes) { + return NEVER; + } + const { work$, insertedOrRemoved } = listViewHandler.toTemplateWork( + changes, + itemsToRender, + renderedRange, + strategy, + count, + patchZone + ); + partiallyFinished = true; + const notifyParent = insertedOrRemoved && parent; + _renderingStart$.next(); + return combineLatest( + // emit after all changes are rendered + work$.length > 0 ? work$ : [of(itemsToRender)] + ).pipe( + tap(() => { + partiallyFinished = false; + const viewsRendered = []; + const end = viewContainerRef.length; + let i = 0; + for (i; i < end; i++) { + viewsRendered.push( + viewContainerRef.get(i) as EmbeddedViewRef + ); + } + _viewsRendered$.next(viewsRendered); + }), + notifyParent + ? switchMap((v) => + concat( + of(v), + onStrategy( + injectingViewCdRef, + strategy, + (_v, work, options) => { + work(injectingViewCdRef, options.scope); + }, + { + ngZone: patchZone, + scope: + (injectingViewCdRef as any).context || + injectingViewCdRef, + } + ).pipe(ignoreElements()) + ) + ) + : (o$) => o$, + handleError(), + map(() => itemsToRender) + ); + }) + ); + } +} diff --git a/libs/template/package.json b/libs/template/package.json index ab97cabbc8..29586ea380 100644 --- a/libs/template/package.json +++ b/libs/template/package.json @@ -1,6 +1,6 @@ { "name": "@rx-angular/template", - "version": "1.0.0", + "version": "1.1.0", "description": "**Fully** Reactive Component Template Rendering in Angular. @rx-angular/template aims to be a reflection of Angular's built in renderings just reactive.", "main": "index.js", "publishConfig": { diff --git a/libs/template/tsconfig.lib.json b/libs/template/tsconfig.lib.json index 9aa936d286..b9fbdd9298 100644 --- a/libs/template/tsconfig.lib.json +++ b/libs/template/tsconfig.lib.json @@ -7,13 +7,14 @@ "importHelpers": true, "lib": ["dom", "es2018"], "paths": { - "@rx-angular/cdk/zone-less": ["dist/libs/cdk/zone-less"], + "@rx-angular/cdk/zone-less/browser": ["dist/libs/cdk/zone-less/browser"], "@rx-angular/cdk/zone-configurations": [ "dist/libs/cdk/zone-configurations" ], "@rx-angular/cdk/render-strategies": ["dist/libs/cdk/render-strategies"], "@rx-angular/cdk/template": ["dist/libs/cdk/template"], "@rx-angular/cdk/coercing": ["dist/libs/cdk/coercing"], + "@rx-angular/cdk/coalescing": ["dist/libs/cdk/coalescing"], "@rx-angular/cdk/notifications": ["dist/libs/cdk/notifications"], "@rx-angular/cdk/internals/core": ["dist/libs/cdk/internals/core"] } diff --git a/tsconfig.base.json b/tsconfig.base.json index 8d8bdbf4c9..1a2650081a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -55,6 +55,9 @@ "@rx-angular/template/experimental/viewport-prio": [ "libs/template/experimental/viewport-prio/src/index.ts" ], + "@rx-angular/template/experimental/virtual-scrolling": [ + "libs/template/experimental/virtual-scrolling/src/index.ts" + ], "@rx-angular/template/for": ["libs/template/for/src/index.ts"], "@rx-angular/template/if": ["libs/template/if/src/index.ts"], "@rx-angular/template/let": ["libs/template/let/src/index.ts"],