Skip to content

Commit

Permalink
fix: observe element dimension changes natively
Browse files Browse the repository at this point in the history
`SciDimensionDirective` now listens natively to resize events of its host element instead of observing the Angular zone to detect if the size changed. It uses the built-in `ResizeObserver`, if supported by the user agent, or falls back to listening for resize events on a hidden HTML <object> element.

It is now possible to observe dimension changes programmatically by injecting `SciDimensionService`.

fixes #156

BREAKING CHANGE:
- removed 'viewportChange' output property from `<sci-viewport>` component: instead, add the dimension directive to the viewport and/or viewport client, and/or listen for viewport scroll events with 'scroll' output property
  • Loading branch information
danielwiehl committed Jul 22, 2019
1 parent 2c55f2f commit f53f4b3
Show file tree
Hide file tree
Showing 18 changed files with 849 additions and 202 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"test:workbench-application-platform": "ng test @scion/workbench-application-platform",
"test:workbench-application.core": "ng test @scion/workbench-application.core",
"test:workbench-application.angular": "ng test @scion/workbench-application.angular",
"test:dimension": "ng test @scion/dimension",
"test:viewport": "ng test @scion/viewport",

"lint": "run-p lint:*",
Expand Down
74 changes: 17 additions & 57 deletions projects/scion/dimension/src/lib/dimension.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,18 @@
*/

import { Directive, ElementRef, EventEmitter, Input, NgZone, OnDestroy, Output } from '@angular/core';
import { asapScheduler, fromEvent, merge, Observable, Subject, timer } from 'rxjs';
import { distinctUntilChanged, map, takeUntil } from 'rxjs/operators';

export const NULL_DIMENSION: SciDimension = {offsetWidth: 0, offsetHeight: 0, clientWidth: 0, clientHeight: 0};
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { captureElementDimension, SciDimension, SciDimensionService } from './dimension.service';

/**
* Allows observing changes to host element's size.
*
* This directive can be used in 'Default' or 'OnPush' change detection context.
* See {SciDimensionService} for more information.
*
* ---
* Usage:
*
* <div sciDimension (sciDimensionChange)="onDimensionChange($event)"></div>
*/
@Directive({
Expand All @@ -33,7 +33,7 @@ export class SciDimensionDirective implements OnDestroy {
private _destroy$ = new Subject<void>();

/**
* Emits when the dimension of the host element changes.
* Upon subscription, it emits the host element's dimension, and then continuously emits when the dimension of the host element changes.
*/
@Output('sciDimensionChange') // tslint:disable-line:no-output-rename
public dimensionChange = new EventEmitter<SciDimension>();
Expand All @@ -45,19 +45,23 @@ export class SciDimensionDirective implements OnDestroy {
@Input()
public emitOutsideAngular: boolean;

constructor(host: ElementRef, private _ngZone: NgZone) {
this._host = host.nativeElement as HTMLElement;
constructor(host: ElementRef<HTMLElement>,
private _dimensionService: SciDimensionService,
private _ngZone: NgZone) {
this._host = host.nativeElement;
this.installDimensionListener();
}

// run outside of the Angular zone to not trigger app ticks on every event
private installDimensionListener(): void {
this._ngZone.runOutsideAngular(() => {
this.dimensionChange$()
this._dimensionService.dimension$(this._host)
.pipe(takeUntil(this._destroy$))
.subscribe((dimension: SciDimension) => {
if (this.emitOutsideAngular) {
this.emitDimensionChange(dimension);
this.dimensionChange.emit(dimension);
}
else {
this._ngZone.run(() => this.emitDimensionChange(dimension));
this._ngZone.run(() => this.dimensionChange.emit(dimension));
}
});
});
Expand All @@ -67,55 +71,11 @@ export class SciDimensionDirective implements OnDestroy {
* Returns the current dimension of its host element.
*/
public get dimension(): SciDimension {
return {
clientWidth: this._host.clientWidth,
clientHeight: this._host.clientHeight,
offsetWidth: this._host.offsetWidth,
offsetHeight: this._host.offsetHeight,
};
}

/**
* Emits when the dimension of the host element changes.
*
* This is a workaround until it is possible to listen for element dimension changes natively.
* @see https://wicg.github.io/ResizeObserver/
*/
private dimensionChange$(): Observable<SciDimension> {
NgZone.assertNotInAngularZone();

return merge(
timer(0, asapScheduler), // Notify after directive construction to emit the initial dimension
this._ngZone.onStable, // When the Angular zone gets stable the dimension of the host element might have changed.
fromEvent(window, 'resize'), // When receiving a window size change, the Angular zone is not necessarily involved.
fromEvent(window, 'orientationchange'), // When receiving a window orientation change, the Angular zone is not necessarily involved.
)
.pipe(
map(() => this.dimension),
distinctUntilChanged((a, b) => {
return a.clientWidth === b.clientWidth &&
a.clientHeight === b.clientHeight &&
a.offsetWidth === b.offsetWidth &&
a.offsetHeight === b.offsetHeight;
}),
);
}

private emitDimensionChange(dimension: SciDimension): void {
this.dimensionChange.emit(dimension);
return captureElementDimension(this._host);
}

public ngOnDestroy(): void {
this._destroy$.next();
}
}

/**
* Emitted upon a host element size change.
*/
export interface SciDimension {
offsetWidth: number;
offsetHeight: number;
clientWidth: number;
clientHeight: number;
}
177 changes: 177 additions & 0 deletions projects/scion/dimension/src/lib/dimension.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/*
* Copyright (c) 2018-2019 Swiss Federal Railways
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/

import { TestBed } from '@angular/core/testing';
import { SciDimensionService } from './dimension.service';

describe('DimensionService', () => {

beforeEach(async () => {
await TestBed.configureTestingModule({
providers: [
SciDimensionService,
],
});
});

it('should emit on size change of the element', async () => {
const dimensionService = TestBed.get<SciDimensionService>(SciDimensionService);

// create the testee <div> and subscribe for dimension changes
const testeeDiv = document.createElement('div');
testeeDiv.style.width = '100px';

const reportedSizes: number[] = [];
dimensionService.dimension$(testeeDiv, {useNativeResizeObserver: false}).subscribe(dimension => reportedSizes.push(dimension.clientWidth));

// append the testee <div> to the DOM
document.body.appendChild(testeeDiv);

await waitUntilRendered();
expect(reportedSizes).toEqual([100]);

// change the size of the <div> to 200px and wait until the changed size is reported
testeeDiv.style.width = '200px';
await waitUntilRendered();
expect(reportedSizes).toEqual([100, 200]);

// change the size of the <div> to 300px and wait until the changed size is reported
testeeDiv.style.width = '300px';
await waitUntilRendered();
expect(reportedSizes).toEqual([100, 200, 300]);
});

it('should emit on size change of the parent element', async () => {
const dimensionService: SciDimensionService = TestBed.get(SciDimensionService);

// create a parent <div> with a width of 300px
const parentDiv = document.createElement('div');
parentDiv.style.width = '100px';
document.body.appendChild(parentDiv);

// create the testee <div> and subscribe for dimension changes
const testeeDiv = document.createElement('div');

const reportedSizes: number[] = [];
dimensionService.dimension$(testeeDiv, {useNativeResizeObserver: false}).subscribe(dimension => reportedSizes.push(dimension.clientWidth));

// append the testee <div> to the DOM
parentDiv.appendChild(testeeDiv);

await waitUntilRendered();
expect(reportedSizes).toEqual([100]);

// change the size of the parent <div> to 200px and wait until the changed size is reported
parentDiv.style.width = '200px';
await waitUntilRendered();
expect(reportedSizes).toEqual([100, 200]);

// change the size of the parent <div> to 300px and wait until the changed size is reported
parentDiv.style.width = '300px';
await waitUntilRendered();
expect(reportedSizes).toEqual([100, 200, 300]);
});

fit('should allocate a single HTML object element for multiple observers', async () => {
const dimensionService: SciDimensionService = TestBed.get(SciDimensionService);

// create the testee <div>
const testeeDiv = document.createElement('div');
testeeDiv.style.width = '100px';
document.body.appendChild(testeeDiv);
await waitUntilRendered();

// 1. subscription
const reportedSizes1: number[] = [];
const subscription1 = dimensionService.dimension$(testeeDiv, {useNativeResizeObserver: false}).subscribe(dimension => reportedSizes1.push(dimension.clientWidth));
await waitUntilRendered();

expect(reportedSizes1).toEqual([100]);
expect(dimensionService._objectObservableRegistry.size).toEqual(1);
expect(testeeDiv.querySelectorAll('object.synth-resize-observable').length).toEqual(1);

// 2. subscription
const reportedSizes2: number[] = [];
const subscription2 = dimensionService.dimension$(testeeDiv, {useNativeResizeObserver: false}).subscribe(dimension => reportedSizes2.push(dimension.clientWidth));
await waitUntilRendered();

expect(reportedSizes2).toEqual([100]);
expect(dimensionService._objectObservableRegistry.size).toEqual(1);
expect(testeeDiv.querySelectorAll('object.synth-resize-observable').length).toEqual(1);

// 3. subscription
const reportedSizes3: number[] = [];
const subscription3 = dimensionService.dimension$(testeeDiv, {useNativeResizeObserver: false}).subscribe(dimension => reportedSizes3.push(dimension.clientWidth));
await waitUntilRendered();

expect(reportedSizes3).toEqual([100]);
expect(dimensionService._objectObservableRegistry.size).toEqual(1);
expect(testeeDiv.querySelectorAll('object.synth-resize-observable').length).toEqual(1);

// change the size of the <div> to 200px and wait until the changed size is reported
testeeDiv.style.width = '200px';
await waitUntilRendered();
expect(reportedSizes1).toEqual([100, 200]);
expect(reportedSizes2).toEqual([100, 200]);
expect(reportedSizes3).toEqual([100, 200]);

// Unsubscribe the 3. subscriber
subscription3.unsubscribe();
await waitUntilRendered();
expect(dimensionService._objectObservableRegistry.size).toEqual(1);
expect(testeeDiv.querySelectorAll('object.synth-resize-observable').length).toEqual(1);

// change the size of the <div> to 300px and wait until the changed size is reported
testeeDiv.style.width = '300px';
await waitUntilRendered();
expect(reportedSizes1).toEqual([100, 200, 300]);
expect(reportedSizes2).toEqual([100, 200, 300]);
expect(reportedSizes3).toEqual([100, 200]);

// Unsubscribe the 2. subscriber
subscription2.unsubscribe();
await waitUntilRendered();
expect(dimensionService._objectObservableRegistry.size).toEqual(1);
expect(testeeDiv.querySelectorAll('object.synth-resize-observable').length).toEqual(1);

// change the size of the <div> to 400px and wait until the changed size is reported
testeeDiv.style.width = '400px';
await waitUntilRendered();
expect(reportedSizes1).toEqual([100, 200, 300, 400]);
expect(reportedSizes2).toEqual([100, 200, 300]);
expect(reportedSizes3).toEqual([100, 200]);

// Unsubscribe the 1. subscriber
subscription1.unsubscribe();
await waitUntilRendered();
expect(dimensionService._objectObservableRegistry.size).toEqual(0);
expect(testeeDiv.querySelectorAll('object.synth-resize-observable').length).toEqual(0);

// change the size of the <div> to 500px and wait until the changed size is reported
testeeDiv.style.width = '500px';
await waitUntilRendered();
expect(reportedSizes1).toEqual([100, 200, 300, 400]);
expect(reportedSizes2).toEqual([100, 200, 300]);
expect(reportedSizes3).toEqual([100, 200]);
});

/**
* Wait until the browser reported the dimension change.
*/
function waitUntilRendered(renderCyclesToWait: number = 2): Promise<void> {
if (renderCyclesToWait === 0) {
return Promise.resolve();
}

return new Promise(resolve => { // tslint:disable-line:typedef
requestAnimationFrame(() => waitUntilRendered(renderCyclesToWait - 1).then(() => resolve()));
});
}
});
Loading

0 comments on commit f53f4b3

Please sign in to comment.