Skip to content

Commit

Permalink
feat(viewport-ruler): add common window resize handler (angular#6680)
Browse files Browse the repository at this point in the history
Adds the `change` method to the `ViewportRuler`, allowing for components to hook up to a common window resize handler.

BREAKING CHANGE: Previously the `ScrollDispatcher.scrolled` subscription would react both on scroll events and on window resize events. Now it only reacts to scroll events. To react to resize events, subscribe to the `ViewportRuler.change()` stream.
  • Loading branch information
crisbeto authored and mmalerba committed Sep 12, 2017
1 parent bcd026f commit 881630f
Show file tree
Hide file tree
Showing 9 changed files with 99 additions and 45 deletions.
1 change: 0 additions & 1 deletion src/cdk/scrolling/scroll-dispatcher.spec.ts
Expand Up @@ -72,7 +72,6 @@ describe('Scroll Dispatcher', () => {

scroll.scrolled(0, () => {});
dispatchFakeEvent(document, 'scroll');
dispatchFakeEvent(window, 'resize');

expect(spy).not.toHaveBeenCalled();
subscription.unsubscribe();
Expand Down
6 changes: 1 addition & 5 deletions src/cdk/scrolling/scroll-dispatcher.ts
Expand Up @@ -11,7 +11,6 @@ import {Platform} from '@angular/cdk/platform';
import {Subject} from 'rxjs/Subject';
import {Subscription} from 'rxjs/Subscription';
import {fromEvent} from 'rxjs/observable/fromEvent';
import {merge} from 'rxjs/observable/merge';
import {auditTime} from 'rxjs/operator/auditTime';
import {Scrollable} from './scrollable';

Expand Down Expand Up @@ -87,10 +86,7 @@ export class ScrollDispatcher {

if (!this._globalSubscription) {
this._globalSubscription = this._ngZone.runOutsideAngular(() => {
return merge(
fromEvent(window.document, 'scroll'),
fromEvent(window, 'resize')
).subscribe(() => this._notify());
return fromEvent(window.document, 'scroll').subscribe(() => this._notify());
});
}

Expand Down
40 changes: 39 additions & 1 deletion src/cdk/scrolling/viewport-ruler.spec.ts
@@ -1,6 +1,7 @@
import {TestBed, inject} from '@angular/core/testing';
import {TestBed, inject, fakeAsync, tick} from '@angular/core/testing';
import {ScrollDispatchModule} from './public_api';
import {ViewportRuler, VIEWPORT_RULER_PROVIDER} from './viewport-ruler';
import {dispatchFakeEvent} from '@angular/cdk/testing';


// For all tests, we assume the browser window is 1024x786 (outerWidth x outerHeight).
Expand Down Expand Up @@ -32,6 +33,10 @@ describe('ViewportRuler', () => {
scrollTo(0, 0);
}));

afterEach(() => {
ruler.ngOnDestroy();
});

it('should get the viewport bounds when the page is not scrolled', () => {
let bounds = ruler.getViewportRect();
expect(bounds.top).toBe(0);
Expand Down Expand Up @@ -101,4 +106,37 @@ describe('ViewportRuler', () => {

document.body.removeChild(veryLargeElement);
});

describe('changed event', () => {
it('should dispatch an event when the window is resized', () => {
const spy = jasmine.createSpy('viewport changed spy');
const subscription = ruler.change(0).subscribe(spy);

dispatchFakeEvent(window, 'resize');
expect(spy).toHaveBeenCalled();
subscription.unsubscribe();
});

it('should dispatch an event when the orientation is changed', () => {
const spy = jasmine.createSpy('viewport changed spy');
const subscription = ruler.change(0).subscribe(spy);

dispatchFakeEvent(window, 'orientationchange');
expect(spy).toHaveBeenCalled();
subscription.unsubscribe();
});

it('should be able to throttle the callback', fakeAsync(() => {
const spy = jasmine.createSpy('viewport changed spy');
const subscription = ruler.change(1337).subscribe(spy);

dispatchFakeEvent(window, 'resize');
expect(spy).not.toHaveBeenCalled();

tick(1337);

expect(spy).toHaveBeenCalledTimes(1);
subscription.unsubscribe();
}));
});
});
52 changes: 43 additions & 9 deletions src/cdk/scrolling/viewport-ruler.ts
Expand Up @@ -6,23 +6,49 @@
* found in the LICENSE file at https://angular.io/license
*/

import {Injectable, Optional, SkipSelf} from '@angular/core';
import {Injectable, Optional, SkipSelf, NgZone, OnDestroy} from '@angular/core';
import {Platform} from '@angular/cdk/platform';
import {ScrollDispatcher} from './scroll-dispatcher';
import {Observable} from 'rxjs/Observable';
import {fromEvent} from 'rxjs/observable/fromEvent';
import {merge} from 'rxjs/observable/merge';
import {auditTime} from 'rxjs/operator/auditTime';
import {Subscription} from 'rxjs/Subscription';
import {of as observableOf} from 'rxjs/observable/of';

/** Time in ms to throttle the resize events by default. */
export const DEFAULT_RESIZE_TIME = 20;

/**
* Simple utility for getting the bounds of the browser viewport.
* @docs-private
*/
@Injectable()
export class ViewportRuler {
export class ViewportRuler implements OnDestroy {

/** Cached document client rectangle. */
private _documentRect?: ClientRect;

constructor(scrollDispatcher: ScrollDispatcher) {
/** Stream of viewport change events. */
private _change: Observable<Event>;

/** Subscriptions to streams that invalidate the cached viewport dimensions. */
private _invalidateCacheSubscriptions: Subscription[];

constructor(platform: Platform, ngZone: NgZone, scrollDispatcher: ScrollDispatcher) {
this._change = platform.isBrowser ? ngZone.runOutsideAngular(() => {
return merge<Event>(fromEvent(window, 'resize'), fromEvent(window, 'orientationchange'));
}) : observableOf();

// Subscribe to scroll and resize events and update the document rectangle on changes.
scrollDispatcher.scrolled(0, () => this._cacheViewportGeometry());
this._invalidateCacheSubscriptions = [
scrollDispatcher.scrolled(0, () => this._cacheViewportGeometry()),
this.change().subscribe(() => this._cacheViewportGeometry())
];
}

ngOnDestroy() {
this._invalidateCacheSubscriptions.forEach(subscription => subscription.unsubscribe());
}

/** Gets a ClientRect for the viewport's bounds. */
Expand Down Expand Up @@ -56,7 +82,6 @@ export class ViewportRuler {
};
}


/**
* Gets the (top, left) scroll position of the viewport.
* @param documentRect
Expand All @@ -75,31 +100,40 @@ export class ViewportRuler {
// `document.documentElement` works consistently, where the `top` and `left` values will
// equal negative the scroll position.
const top = -documentRect!.top || document.body.scrollTop || window.scrollY ||
document.documentElement.scrollTop || 0;
document.documentElement.scrollTop || 0;

const left = -documentRect!.left || document.body.scrollLeft || window.scrollX ||
document.documentElement.scrollLeft || 0;

return {top, left};
}

/**
* Returns a stream that emits whenever the size of the viewport changes.
* @param throttle Time in milliseconds to throttle the stream.
*/
change(throttleTime: number = DEFAULT_RESIZE_TIME): Observable<string> {
return throttleTime > 0 ? auditTime.call(this._change, throttleTime) : this._change;
}

/** Caches the latest client rectangle of the document element. */
_cacheViewportGeometry() {
this._documentRect = document.documentElement.getBoundingClientRect();
}

}

/** @docs-private */
export function VIEWPORT_RULER_PROVIDER_FACTORY(parentRuler: ViewportRuler,
platform: Platform,
ngZone: NgZone,
scrollDispatcher: ScrollDispatcher) {
return parentRuler || new ViewportRuler(scrollDispatcher);
return parentRuler || new ViewportRuler(platform, ngZone, scrollDispatcher);
}

/** @docs-private */
export const VIEWPORT_RULER_PROVIDER = {
// If there is already a ViewportRuler available, use that. Otherwise, provide a new one.
provide: ViewportRuler,
deps: [[new Optional(), new SkipSelf(), ViewportRuler], ScrollDispatcher],
deps: [[new Optional(), new SkipSelf(), ViewportRuler], Platform, NgZone, ScrollDispatcher],
useFactory: VIEWPORT_RULER_PROVIDER_FACTORY
};
6 changes: 1 addition & 5 deletions src/lib/tabs/tab-group.spec.ts
Expand Up @@ -2,8 +2,7 @@ import {async, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/t
import {Component, QueryList, ViewChild, ViewChildren} from '@angular/core';
import {BrowserAnimationsModule, NoopAnimationsModule} from '@angular/platform-browser/animations';
import {By} from '@angular/platform-browser';
import {ViewportRuler} from '@angular/cdk/scrolling';
import {dispatchFakeEvent, FakeViewportRuler} from '@angular/cdk/testing';
import {dispatchFakeEvent} from '@angular/cdk/testing';
import {Observable} from 'rxjs/Observable';
import {MdTab, MdTabGroup, MdTabHeaderPosition, MdTabsModule} from './index';

Expand All @@ -19,9 +18,6 @@ describe('MdTabGroup', () => {
AsyncTabsTestApp,
DisabledTabsTestApp,
TabGroupWithSimpleApi,
],
providers: [
{provide: ViewportRuler, useClass: FakeViewportRuler},
]
});

Expand Down
4 changes: 1 addition & 3 deletions src/lib/tabs/tab-header.spec.ts
Expand Up @@ -6,9 +6,8 @@ import {CommonModule} from '@angular/common';
import {By} from '@angular/platform-browser';
import {ENTER, LEFT_ARROW, RIGHT_ARROW, SPACE} from '@angular/cdk/keycodes';
import {PortalModule} from '@angular/cdk/portal';
import {ViewportRuler} from '@angular/cdk/scrolling';
import {Direction, Directionality} from '@angular/cdk/bidi';
import {dispatchFakeEvent, dispatchKeyboardEvent, FakeViewportRuler} from '@angular/cdk/testing';
import {dispatchFakeEvent, dispatchKeyboardEvent} from '@angular/cdk/testing';
import {MdTabHeader} from './tab-header';
import {MdRippleModule} from '../core/ripple/index';
import {MdInkBar} from './ink-bar';
Expand All @@ -35,7 +34,6 @@ describe('MdTabHeader', () => {
],
providers: [
{provide: Directionality, useFactory: () => ({value: dir, change: change.asObservable()})},
{provide: ViewportRuler, useClass: FakeViewportRuler},
]
});

Expand Down
9 changes: 4 additions & 5 deletions src/lib/tabs/tab-header.ts
Expand Up @@ -26,14 +26,14 @@ import {
} from '@angular/core';
import {Directionality, Direction} from '@angular/cdk/bidi';
import {RIGHT_ARROW, LEFT_ARROW, ENTER, SPACE} from '@angular/cdk/keycodes';
import {auditTime, startWith} from '@angular/cdk/rxjs';
import {startWith} from '@angular/cdk/rxjs';
import {Subscription} from 'rxjs/Subscription';
import {of as observableOf} from 'rxjs/observable/of';
import {merge} from 'rxjs/observable/merge';
import {fromEvent} from 'rxjs/observable/fromEvent';
import {MdTabLabelWrapper} from './tab-label-wrapper';
import {MdInkBar} from './ink-bar';
import {CanDisableRipple, mixinDisableRipple} from '../core/common-behaviors/disable-ripple';
import {ViewportRuler} from '@angular/cdk/scrolling';

/**
* The directions that scrolling can go in when the header's tabs exceed the header width. 'After'
Expand Down Expand Up @@ -132,6 +132,7 @@ export class MdTabHeader extends _MdTabHeaderMixinBase
constructor(private _elementRef: ElementRef,
private _renderer: Renderer2,
private _changeDetectorRef: ChangeDetectorRef,
private _viewportRuler: ViewportRuler,
@Optional() private _dir: Directionality) {
super();
}
Expand Down Expand Up @@ -184,9 +185,7 @@ export class MdTabHeader extends _MdTabHeaderMixinBase
*/
ngAfterContentInit() {
const dirChange = this._dir ? this._dir.change : observableOf(null);
const resize = typeof window !== 'undefined' ?
auditTime.call(fromEvent(window, 'resize'), 150) :
observableOf(null);
const resize = this._viewportRuler.change(150);

this._realignInkBar = startWith.call(merge(dirChange, resize), null).subscribe(() => {
this._updatePagination();
Expand Down
8 changes: 3 additions & 5 deletions src/lib/tabs/tab-nav-bar/tab-nav-bar.spec.ts
@@ -1,8 +1,7 @@
import {async, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
import {Component, ViewChild} from '@angular/core';
import {By} from '@angular/platform-browser';
import {ViewportRuler} from '@angular/cdk/scrolling';
import {dispatchFakeEvent, dispatchMouseEvent, FakeViewportRuler} from '@angular/cdk/testing';
import {dispatchFakeEvent, dispatchMouseEvent} from '@angular/cdk/testing';
import {Direction, Directionality} from '@angular/cdk/bidi';
import {Subject} from 'rxjs/Subject';
import {MdTabNav, MdTabsModule, MdTabLink} from '../index';
Expand All @@ -23,8 +22,7 @@ describe('MdTabNavBar', () => {
{provide: Directionality, useFactory: () => ({
value: dir,
change: dirChange.asObservable()
})},
{provide: ViewportRuler, useClass: FakeViewportRuler},
})}
]
});

Expand Down Expand Up @@ -173,7 +171,7 @@ describe('MdTabNavBar', () => {
spyOn(inkBar, 'alignToElement');

dispatchFakeEvent(window, 'resize');
tick(10);
tick(150);
fixture.detectChanges();

expect(inkBar.alignToElement).toHaveBeenCalled();
Expand Down
18 changes: 7 additions & 11 deletions src/lib/tabs/tab-nav-bar/tab-nav-bar.ts
Expand Up @@ -28,11 +28,10 @@ import {
import {ViewportRuler} from '@angular/cdk/scrolling';
import {Directionality} from '@angular/cdk/bidi';
import {Platform} from '@angular/cdk/platform';
import {auditTime, takeUntil} from '@angular/cdk/rxjs';
import {takeUntil} from '@angular/cdk/rxjs';
import {Subject} from 'rxjs/Subject';
import {of as observableOf} from 'rxjs/observable/of';
import {merge} from 'rxjs/observable/merge';
import {fromEvent} from 'rxjs/observable/fromEvent';
import {CanDisableRipple, mixinDisableRipple} from '../../core/common-behaviors/disable-ripple';
import {coerceBooleanProperty} from '@angular/cdk/coercion';
import {CanDisable, mixinDisabled} from '../../core/common-behaviors/disabled';
Expand Down Expand Up @@ -105,7 +104,8 @@ export class MdTabNav extends _MdTabNavMixinBase implements AfterContentInit, Ca
elementRef: ElementRef,
@Optional() private _dir: Directionality,
private _ngZone: NgZone,
private _changeDetectorRef: ChangeDetectorRef) {
private _changeDetectorRef: ChangeDetectorRef,
private _viewportRuler: ViewportRuler) {
super(renderer, elementRef);
}

Expand All @@ -121,14 +121,10 @@ export class MdTabNav extends _MdTabNavMixinBase implements AfterContentInit, Ca

ngAfterContentInit(): void {
this._ngZone.runOutsideAngular(() => {
let dirChange = this._dir ? this._dir.change : observableOf(null);
let resize = typeof window !== 'undefined' ?
auditTime.call(fromEvent(window, 'resize'), 10) :
observableOf(null);

return takeUntil.call(merge(dirChange, resize), this._onDestroy).subscribe(() => {
this._alignInkBar();
});
const dirChange = this._dir ? this._dir.change : observableOf(null);

return takeUntil.call(merge(dirChange, this._viewportRuler.change(10)), this._onDestroy)
.subscribe(() => this._alignInkBar());
});

this._setLinkDisableRipple();
Expand Down

0 comments on commit 881630f

Please sign in to comment.