From 34824905be1cff13b5efd30de51e1aa079c74a86 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Fri, 15 Sep 2017 22:07:59 +0200 Subject: [PATCH 1/2] feat(viewport-ruler): add common window resize handler 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. --- src/cdk/scrolling/scroll-dispatcher.spec.ts | 1 - src/cdk/scrolling/scroll-dispatcher.ts | 6 +-- src/cdk/scrolling/viewport-ruler.spec.ts | 40 ++++++++++++++- src/cdk/scrolling/viewport-ruler.ts | 52 ++++++++++++++++---- src/lib/tabs/tab-group.spec.ts | 6 +-- src/lib/tabs/tab-header.spec.ts | 4 +- src/lib/tabs/tab-header.ts | 14 ++++-- src/lib/tabs/tab-nav-bar/tab-nav-bar.spec.ts | 6 +-- src/lib/tabs/tab-nav-bar/tab-nav-bar.ts | 19 +++---- 9 files changed, 104 insertions(+), 44 deletions(-) diff --git a/src/cdk/scrolling/scroll-dispatcher.spec.ts b/src/cdk/scrolling/scroll-dispatcher.spec.ts index 68c94a5e3bef..6fcb17c619ff 100644 --- a/src/cdk/scrolling/scroll-dispatcher.spec.ts +++ b/src/cdk/scrolling/scroll-dispatcher.spec.ts @@ -72,7 +72,6 @@ describe('Scroll Dispatcher', () => { scroll.scrolled(0, () => {}); dispatchFakeEvent(document, 'scroll'); - dispatchFakeEvent(window, 'resize'); expect(spy).not.toHaveBeenCalled(); subscription.unsubscribe(); diff --git a/src/cdk/scrolling/scroll-dispatcher.ts b/src/cdk/scrolling/scroll-dispatcher.ts index 177f830ac2c0..0e0e5b6aa1d1 100644 --- a/src/cdk/scrolling/scroll-dispatcher.ts +++ b/src/cdk/scrolling/scroll-dispatcher.ts @@ -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'; @@ -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()); }); } diff --git a/src/cdk/scrolling/viewport-ruler.spec.ts b/src/cdk/scrolling/viewport-ruler.spec.ts index f5afd837e752..678e9fb116b9 100644 --- a/src/cdk/scrolling/viewport-ruler.spec.ts +++ b/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). @@ -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); @@ -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(); + })); + }); }); diff --git a/src/cdk/scrolling/viewport-ruler.ts b/src/cdk/scrolling/viewport-ruler.ts index fbd55ad9e2d6..1643cf687da0 100644 --- a/src/cdk/scrolling/viewport-ruler.ts +++ b/src/cdk/scrolling/viewport-ruler.ts @@ -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; + + /** 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(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. */ @@ -56,7 +82,6 @@ export class ViewportRuler { }; } - /** * Gets the (top, left) scroll position of the viewport. * @param documentRect @@ -75,7 +100,7 @@ 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; @@ -83,23 +108,32 @@ export class ViewportRuler { 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 { + 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 }; diff --git a/src/lib/tabs/tab-group.spec.ts b/src/lib/tabs/tab-group.spec.ts index cf1f72a54711..5134ee024677 100644 --- a/src/lib/tabs/tab-group.spec.ts +++ b/src/lib/tabs/tab-group.spec.ts @@ -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 {MatTab, MatTabGroup, MatTabHeaderPosition, MatTabsModule} from './index'; @@ -20,9 +19,6 @@ describe('MatTabGroup', () => { DisabledTabsTestApp, TabGroupWithSimpleApi, ], - providers: [ - {provide: ViewportRuler, useClass: FakeViewportRuler}, - ] }); TestBed.compileComponents(); diff --git a/src/lib/tabs/tab-header.spec.ts b/src/lib/tabs/tab-header.spec.ts index 19e6f2d67199..7dbe4c93c0c3 100644 --- a/src/lib/tabs/tab-header.spec.ts +++ b/src/lib/tabs/tab-header.spec.ts @@ -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 {MatTabHeader} from './tab-header'; import {MatRippleModule} from '@angular/material/core'; import {MatInkBar} from './ink-bar'; @@ -35,7 +34,6 @@ describe('MatTabHeader', () => { ], providers: [ {provide: Directionality, useFactory: () => ({value: dir, change: change.asObservable()})}, - {provide: ViewportRuler, useClass: FakeViewportRuler}, ] }); diff --git a/src/lib/tabs/tab-header.ts b/src/lib/tabs/tab-header.ts index 8132ebf6ab3a..cb9579f64357 100644 --- a/src/lib/tabs/tab-header.ts +++ b/src/lib/tabs/tab-header.ts @@ -8,7 +8,7 @@ import {Direction, Directionality} from '@angular/cdk/bidi'; import {ENTER, LEFT_ARROW, RIGHT_ARROW, SPACE} from '@angular/cdk/keycodes'; -import {auditTime, startWith} from '@angular/cdk/rxjs'; +import {startWith} from '@angular/cdk/rxjs'; import { AfterContentChecked, AfterContentInit, @@ -27,13 +27,18 @@ import { ViewChild, ViewEncapsulation, } from '@angular/core'; -import {CanDisableRipple, mixinDisableRipple} from '@angular/material/core'; +import { + CanDisableRipple, + MATERIAL_COMPATIBILITY_MODE, + mixinDisableRipple, +} from '@angular/material/core'; import {fromEvent} from 'rxjs/observable/fromEvent'; import {merge} from 'rxjs/observable/merge'; import {of as observableOf} from 'rxjs/observable/of'; import {Subscription} from 'rxjs/Subscription'; import {MatInkBar} from './ink-bar'; import {MatTabLabelWrapper} from './tab-label-wrapper'; +import {ViewportRuler} from '@angular/cdk/scrolling'; /** @@ -134,6 +139,7 @@ export class MatTabHeader extends _MatTabHeaderMixinBase constructor(private _elementRef: ElementRef, private _renderer: Renderer2, private _changeDetectorRef: ChangeDetectorRef, + private _viewportRuler: ViewportRuler, @Optional() private _dir: Directionality) { super(); } @@ -186,9 +192,7 @@ export class MatTabHeader extends _MatTabHeaderMixinBase */ 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(); diff --git a/src/lib/tabs/tab-nav-bar/tab-nav-bar.spec.ts b/src/lib/tabs/tab-nav-bar/tab-nav-bar.spec.ts index 19eb376cd0db..3aa7542e9c5a 100644 --- a/src/lib/tabs/tab-nav-bar/tab-nav-bar.spec.ts +++ b/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 {MatTabNav, MatTabsModule, MatTabLink} from '../index'; @@ -24,7 +23,6 @@ describe('MatTabNavBar', () => { value: dir, change: dirChange.asObservable() })}, - {provide: ViewportRuler, useClass: FakeViewportRuler}, ] }); @@ -173,7 +171,7 @@ describe('MatTabNavBar', () => { spyOn(inkBar, 'alignToElement'); dispatchFakeEvent(window, 'resize'); - tick(10); + tick(150); fixture.detectChanges(); expect(inkBar.alignToElement).toHaveBeenCalled(); diff --git a/src/lib/tabs/tab-nav-bar/tab-nav-bar.ts b/src/lib/tabs/tab-nav-bar/tab-nav-bar.ts index 34336e2fa8e4..4652a282718d 100644 --- a/src/lib/tabs/tab-nav-bar/tab-nav-bar.ts +++ b/src/lib/tabs/tab-nav-bar/tab-nav-bar.ts @@ -9,7 +9,8 @@ import {Directionality} from '@angular/cdk/bidi'; import {coerceBooleanProperty} from '@angular/cdk/coercion'; import {Platform} from '@angular/cdk/platform'; -import {auditTime, takeUntil} from '@angular/cdk/rxjs'; +import {takeUntil} from '@angular/cdk/rxjs'; +import {ViewportRuler} from '@angular/cdk/scrolling'; import { AfterContentInit, ChangeDetectionStrategy, @@ -41,7 +42,6 @@ import { RippleGlobalOptions, ThemePalette, } from '@angular/material/core'; -import {fromEvent} from 'rxjs/observable/fromEvent'; import {merge} from 'rxjs/observable/merge'; import {of as observableOf} from 'rxjs/observable/of'; import {Subject} from 'rxjs/Subject'; @@ -113,7 +113,8 @@ export class MatTabNav extends _MatTabNavMixinBase implements AfterContentInit, elementRef: ElementRef, @Optional() private _dir: Directionality, private _ngZone: NgZone, - private _changeDetectorRef: ChangeDetectorRef) { + private _changeDetectorRef: ChangeDetectorRef, + private _viewportRuler: ViewportRuler) { super(renderer, elementRef); } @@ -129,14 +130,10 @@ export class MatTabNav extends _MatTabNavMixinBase implements AfterContentInit, 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(); From c9a4f9359ee3f69ceaf7f51bbff56a64cd32f547 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Fri, 22 Sep 2017 21:25:13 +0200 Subject: [PATCH 2/2] fix: reposition connected overlay on resize --- src/cdk/overlay/overlay-ref.ts | 6 +++++- .../position/connected-position-strategy.ts | 16 +++++++++++++++- src/cdk/overlay/position/position-strategy.ts | 3 +++ src/lib/tabs/tab-header.spec.ts | 3 ++- src/lib/tabs/tab-header.ts | 7 +------ 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/cdk/overlay/overlay-ref.ts b/src/cdk/overlay/overlay-ref.ts index 8bac983a41f0..998b1d9bccbc 100644 --- a/src/cdk/overlay/overlay-ref.ts +++ b/src/cdk/overlay/overlay-ref.ts @@ -95,11 +95,15 @@ export class OverlayRef implements PortalHost { // pointer events therefore. Depends on the position strategy and the applied pane boundaries. this._togglePointerEvents(false); + if (this._config.positionStrategy && this._config.positionStrategy.detach) { + this._config.positionStrategy.detach(); + } + if (this._config.scrollStrategy) { this._config.scrollStrategy.disable(); } - const detachmentResult = this._portalHost.detach(); + const detachmentResult = this._portalHost.detach(); // Only emit after everything is detached. this._detachments.next(); diff --git a/src/cdk/overlay/position/connected-position-strategy.ts b/src/cdk/overlay/position/connected-position-strategy.ts index 19dc0c761597..a3c03873ca0b 100644 --- a/src/cdk/overlay/position/connected-position-strategy.ts +++ b/src/cdk/overlay/position/connected-position-strategy.ts @@ -17,6 +17,7 @@ import { ScrollingVisibility, } from './connected-position'; import {Subject} from 'rxjs/Subject'; +import {Subscription} from 'rxjs/Subscription'; import {Observable} from 'rxjs/Observable'; import {Scrollable} from '@angular/cdk/scrolling'; import {isElementScrolledOutsideView, isElementClippedByScrolling} from './scroll-clip'; @@ -35,6 +36,7 @@ export class ConnectedPositionStrategy implements PositionStrategy { /** The overlay to which this strategy is attached. */ private _overlayRef: OverlayRef; + /** Layout direction of the position strategy. */ private _dir = 'ltr'; /** The offset in pixels for the overlay connection point on the x-axis */ @@ -46,6 +48,9 @@ export class ConnectedPositionStrategy implements PositionStrategy { /** The Scrollable containers used to check scrollable view properties on position change. */ private scrollables: Scrollable[] = []; + /** Subscription to viewport resize events. */ + private _resizeSubscription = Subscription.EMPTY; + /** Whether the we're dealing with an RTL context */ get _isRtl() { return this._dir === 'rtl'; @@ -88,10 +93,19 @@ export class ConnectedPositionStrategy implements PositionStrategy { attach(overlayRef: OverlayRef): void { this._overlayRef = overlayRef; this._pane = overlayRef.overlayElement; + this._resizeSubscription.unsubscribe(); + this._resizeSubscription = this._viewportRuler.change().subscribe(() => this.apply()); } /** Performs any cleanup after the element is destroyed. */ - dispose() { } + dispose() { + this._resizeSubscription.unsubscribe(); + } + + /** @docs-private */ + detach() { + this._resizeSubscription.unsubscribe(); + } /** * Updates the position of the overlay element, using whichever preferred position relative diff --git a/src/cdk/overlay/position/position-strategy.ts b/src/cdk/overlay/position/position-strategy.ts index d848f4f4c51c..2483fb3594b0 100644 --- a/src/cdk/overlay/position/position-strategy.ts +++ b/src/cdk/overlay/position/position-strategy.ts @@ -18,6 +18,9 @@ export interface PositionStrategy { /** Updates the position of the overlay element. */ apply(): void; + /** Called when the overlay is detached. */ + detach?(): void; + /** Cleans up any DOM modifications made by the position strategy, if necessary. */ dispose(): void; } diff --git a/src/lib/tabs/tab-header.spec.ts b/src/lib/tabs/tab-header.spec.ts index 7dbe4c93c0c3..97cc051c33d1 100644 --- a/src/lib/tabs/tab-header.spec.ts +++ b/src/lib/tabs/tab-header.spec.ts @@ -13,7 +13,7 @@ import {MatRippleModule} from '@angular/material/core'; import {MatInkBar} from './ink-bar'; import {MatTabLabelWrapper} from './tab-label-wrapper'; import {Subject} from 'rxjs/Subject'; - +import {VIEWPORT_RULER_PROVIDER} from '@angular/cdk/scrolling'; describe('MatTabHeader', () => { @@ -33,6 +33,7 @@ describe('MatTabHeader', () => { SimpleTabHeaderApp, ], providers: [ + VIEWPORT_RULER_PROVIDER, {provide: Directionality, useFactory: () => ({value: dir, change: change.asObservable()})}, ] }); diff --git a/src/lib/tabs/tab-header.ts b/src/lib/tabs/tab-header.ts index cb9579f64357..668f0acf57a2 100644 --- a/src/lib/tabs/tab-header.ts +++ b/src/lib/tabs/tab-header.ts @@ -27,12 +27,7 @@ import { ViewChild, ViewEncapsulation, } from '@angular/core'; -import { - CanDisableRipple, - MATERIAL_COMPATIBILITY_MODE, - mixinDisableRipple, -} from '@angular/material/core'; -import {fromEvent} from 'rxjs/observable/fromEvent'; +import {CanDisableRipple, mixinDisableRipple} from '@angular/material/core'; import {merge} from 'rxjs/observable/merge'; import {of as observableOf} from 'rxjs/observable/of'; import {Subscription} from 'rxjs/Subscription';