Skip to content

Commit

Permalink
feat(core): MediaObserver can report 1..n activations (#994)
Browse files Browse the repository at this point in the history
Previous versions of MediaObserver suffered from a significant design flaw. 

Those versions assumed that a breakpoint change would only activate/match a single mediaQuery. Additionally those versions would not (by default) report overlapping activations. Applications interested in notifications for all current activations would therefore not receive proper event-notifications.

The current enhancements provide several features:

* Report 1..n mediaQuery activations (matches === true) in a single event
* Report activations sorted by descending priority
* By default, reports include overlapping breakpoint activations
* Debounce notifications to a single grouped event
  > useful to reduce browser reflow thrashing

BREAKING CHANGE:

The stream data type for `asObservable` is now **MediaChange[]** instead of *MediaChange* and `media$` is deprecated in favor of `asObservable()`. 

* `filterOverlaps` now defaults to `false`
  • Loading branch information
ThomasBurleson authored and CaerusKaru committed Jan 15, 2019
1 parent ca4c03c commit 8307655
Show file tree
Hide file tree
Showing 10 changed files with 278 additions and 189 deletions.
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
import {ChangeDetectionStrategy, Component} from '@angular/core';
import {Component} from '@angular/core';
import {MediaChange, MediaObserver} from '@angular/flex-layout';
import {Observable} from 'rxjs';

@Component({
selector: 'media-query-status',
template: `
<div class="mqInfo" *ngIf="media$ | async as event">
<span title="Active MediaQuery">{{ extractQuery(event) }}</span>
<div class="mqInfo">
Active MediaQuery(s):
<ul>
<li *ngFor="let change of (media$ | async) as changes">
{{change.mqAlias}} = {{change.mediaQuery}}
</li>
</ul>
</div>
`,
styleUrls: ['./media-query-status.component.scss'],
changeDetection : ChangeDetectionStrategy.OnPush
})
export class MediaQueryStatusComponent {
media$: Observable<MediaChange>;
media$: Observable<MediaChange[]>;

constructor(mediaObserver: MediaObserver) {
this.media$ = mediaObserver.media$;
}

extractQuery(change: MediaChange): string {
return change ? `'${change.mqAlias}' = (${change.mediaQuery})` : '';
constructor(media: MediaObserver) {
this.media$ = media.asObservable();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ export class ResponsiveRowColumnComponent implements OnDestroy {
};
isVisible = true;

private activeMQC: MediaChange;
private activeMQC: MediaChange[];
private subscription: Subscription;

constructor(mediaObserver: MediaObserver) {
this.subscription = mediaObserver.media$
.subscribe((e: MediaChange) => {
this.activeMQC = e;
constructor(mediaService: MediaObserver) {
this.subscription = mediaService.asObservable()
.subscribe((events: MediaChange[]) => {
this.activeMQC = events;
});
}

Expand All @@ -32,16 +32,18 @@ export class ResponsiveRowColumnComponent implements OnDestroy {
}

toggleLayoutFor(col: number) {
switch (col) {
case 1:
const set1 = `firstCol${this.activeMQC ? this.activeMQC.suffix : ''}`;
this.cols[set1] = (this.cols[set1] === 'column') ? 'row' : 'column';
break;
this.activeMQC.forEach((change: MediaChange) => {
switch (col) {
case 1:
const set1 = `firstCol${change ? change.suffix : ''}`;
this.cols[set1] = (this.cols[set1] === 'column') ? 'row' : 'column';
break;

case 2:
const set2 = 'secondCol';
this.cols[set2] = (this.cols[set2] === 'row') ? 'column' : 'row';
break;
}
case 2:
const set2 = 'secondCol';
this.cols[set2] = (this.cols[set2] === 'row') ? 'column' : 'row';
break;
}
});
}
}
13 changes: 8 additions & 5 deletions src/lib/core/add-alias.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@
*/
import {MediaChange} from './media-change';
import {BreakPoint} from './breakpoints/break-point';
import {extendObject} from '../utils/object-extend';

/**
* For the specified MediaChange, make sure it contains the breakpoint alias
* and suffix (if available).
*/
export function mergeAlias(dest: MediaChange, source: BreakPoint | null): MediaChange {
return extendObject(dest || {}, source ? {
mqAlias: source.alias,
suffix: source.suffix
} : {});
dest = dest ? dest.clone() : new MediaChange();
if (source) {
dest.mqAlias = source.alias;
dest.mediaQuery = source.mediaQuery;
dest.suffix = source.suffix as string;
dest.priority = source.priority as number;
}
return dest;
}
71 changes: 38 additions & 33 deletions src/lib/core/match-media/match-media.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
* found in the LICENSE file at https://angular.io/license
*/
import {TestBed, inject} from '@angular/core/testing';
import {BreakPoint} from '@angular/flex-layout/core';
import {Subscription} from 'rxjs';

import {MediaChange} from '../media-change';
import {MockMatchMedia, MockMatchMediaProvider} from './mock/mock-match-media';
Expand All @@ -16,7 +18,6 @@ import {MediaObserver} from '../media-observer/media-observer';
describe('match-media', () => {
let breakPoints: BreakPointRegistry;
let mediaController: MockMatchMedia;
let mediaObserver: MediaObserver;

beforeEach(() => {
// Configure testbed to prepare services
Expand All @@ -31,11 +32,11 @@ describe('match-media', () => {
_breakPoints: BreakPointRegistry) => {
breakPoints = _breakPoints;
mediaController = _matchMedia; // inject only to manually activate mediaQuery ranges
mediaObserver = _mediaObserver;
}));

afterEach(() => {
mediaController.clearAll();
mediaController.clearAll();
mediaController.useOverlaps = false;
});

it('can observe the initial, default activation for mediaQuery == "all". ', () => {
Expand Down Expand Up @@ -101,38 +102,41 @@ describe('match-media', () => {
});

describe('match-media-observable', () => {
const watchMedia = (alias: string, observer: (value: MediaChange) => void): Subscription => {
return mediaController
.observe(alias ? [alias] : [])
.subscribe(observer);
};

it('can observe an existing activation', () => {
let current: MediaChange = new MediaChange();
let bp = breakPoints.findByAlias('md')!;
mediaController.activate(bp.mediaQuery);
let subscription = mediaObserver.media$.subscribe((change: MediaChange) => {
current = change;
});
const onChange = (change: MediaChange) => current = change;
const subscription = watchMedia('md', onChange);

mediaController.activate(bp.mediaQuery);
expect(current.mediaQuery).toEqual(bp.mediaQuery);
subscription.unsubscribe();
});

it('can observe the initial, default activation for mediaQuery == "all". ', () => {
let current: MediaChange = new MediaChange();
let subscription = mediaObserver.media$.subscribe((change: MediaChange) => {
current = change;
});
const onChange = (change: MediaChange) => current = change;
const subscription = watchMedia('', onChange);

expect(current.mediaQuery).toEqual('all');
subscription.unsubscribe();
});

it('can observe custom mediaQuery ranges', () => {
let current: MediaChange = new MediaChange();
let customQuery = 'screen and (min-width: 610px) and (max-width: 620px)';
let subscription = mediaObserver.media$.subscribe((change: MediaChange) => {
current = change;
});
const customQuery = 'screen and (min-width: 610px) and (max-width: 620px)';
const onChange = (change: MediaChange) => current = change;
const subscription = watchMedia(customQuery, onChange);

mediaController.useOverlaps = true;
let activated = mediaController.activate(customQuery);
const activated = mediaController.activate(customQuery);

expect(activated).toEqual(true);
expect(current.mediaQuery).toEqual(customQuery);

Expand All @@ -141,46 +145,47 @@ describe('match-media', () => {

it('can observe registered breakpoint activations', () => {
let current: MediaChange = new MediaChange();
let bp = breakPoints.findByAlias('md') !;
let subscription = mediaObserver.media$.subscribe((change: MediaChange) => {
current = change;
});
const onChange = (change: MediaChange) => current = change;
const subscription = watchMedia('md', onChange);

let bp = breakPoints.findByAlias('md') !;
let activated = mediaController.activate(bp.mediaQuery);
expect(activated).toEqual(true);

expect(activated).toEqual(true);
expect(current.mediaQuery).toEqual(bp.mediaQuery);

subscription.unsubscribe();
});

/**
* Only the MediaObserver ignores de-activations;
* MediaMonitor and MatchMedia report both activations and de-activations!
* Only the MediaObserver ignores de-activations;
*/
it('ignores mediaQuery de-activations', () => {
let activationCount = 0;
let deactivationCount = 0;

mediaObserver.filterOverlaps = false;
let subscription = mediaObserver.media$.subscribe((change: MediaChange) => {
it('reports mediaQuery de-activations', () => {
const lookupMediaQuery = (alias: string) => {
const bp: BreakPoint = breakPoints.findByAlias(alias) as BreakPoint;
return bp.mediaQuery;
};
let activationCount = 0, deactivationCount = 0;
let subscription = watchMedia('', (change: MediaChange) => {
if (change.matches) {
++activationCount;
activationCount += 1;
} else {
++deactivationCount;
deactivationCount += 1;
}
});

mediaController.activate(breakPoints.findByAlias('md')!.mediaQuery);
mediaController.activate(breakPoints.findByAlias('gt-md')!.mediaQuery);
mediaController.activate(breakPoints.findByAlias('lg')!.mediaQuery);
mediaController.activate(lookupMediaQuery('md'));
mediaController.activate(lookupMediaQuery('gt-md'));
mediaController.activate(lookupMediaQuery('lg'));

// 'all' mediaQuery is already active; total count should be (3)
expect(activationCount).toEqual(4);
expect(deactivationCount).toEqual(0);
expect(deactivationCount).toEqual(2);

subscription.unsubscribe();
});

});
});

15 changes: 14 additions & 1 deletion src/lib/core/match-media/match-media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ export class MatchMedia {
@Inject(DOCUMENT) protected _document: any) {
}

/**
* Publish list of all current activations
*/
get activations(): string[] {
const results: string[] = [];
this._registry.forEach((mql: MediaQueryList, key: string) => {
if (mql.matches) {
results.push(key);
}
});
return results;
}

/**
* For the specified mediaQuery?
*/
Expand Down Expand Up @@ -60,7 +73,7 @@ export class MatchMedia {
* subscribers of notifications.
*/
observe(mqList?: string[], filterOthers = false): Observable<MediaChange> {
if (mqList) {
if (mqList && mqList.length) {
const matchMedia$: Observable<MediaChange> = this._observable$.pipe(
filter((change: MediaChange) => {
return !filterOthers ? true : (mqList.indexOf(change.mediaQuery) > -1);
Expand Down
3 changes: 2 additions & 1 deletion src/lib/core/media-change.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ export class MediaChange {
constructor(public matches = false,
public mediaQuery = 'all',
public mqAlias = '',
public suffix = '') {
public suffix = '',
public priority = 0) {
}

/** Create an exact copy of the MediaChange */
Expand Down
Loading

0 comments on commit 8307655

Please sign in to comment.