feature(modal): support lazy loading of modal pages

This commit is contained in:
Dan Bucholtz
2017-03-03 15:09:06 -06:00
parent 3b43a872a6
commit 3ebc3656c1
11 changed files with 341 additions and 89 deletions

View File

@ -5,6 +5,7 @@ import { NavParams } from '../../navigation/nav-params';
import { NavOptions } from '../../navigation/nav-util';
import { ViewController } from '../../navigation/view-controller';
import { GestureController, BlockerDelegate, GESTURE_MENU_SWIPE, GESTURE_GO_BACK_SWIPE } from '../../gestures/gesture-controller';
import { ModuleLoader } from '../../util/module-loader';
import { assert } from '../../util/util';
/**
@ -31,7 +32,9 @@ export class ModalCmp {
public _renderer: Renderer,
public _navParams: NavParams,
public _viewCtrl: ViewController,
gestureCtrl: GestureController
gestureCtrl: GestureController,
public moduleLoader: ModuleLoader
) {
let opts = _navParams.get('opts');
assert(opts, 'modal data must be valid');
@ -49,7 +52,12 @@ export class ModalCmp {
/** @private */
_load(component: any) {
if (component) {
const componentFactory = this._cfr.resolveComponentFactory(component);
let cfr = this.moduleLoader.getComponentFactoryResolver(component);
if (!cfr) {
cfr = this._cfr;
}
const componentFactory = cfr.resolveComponentFactory(component);
// ******** DOM WRITE ****************
const componentRef = this._viewport.createComponent(componentFactory, this._viewport.length, this._viewport.parentInjector, []);

View File

@ -4,6 +4,7 @@ import { App } from '../app/app';
import { Config } from '../../config/config';
import { Modal } from './modal';
import { ModalOptions } from './modal-options';
import { DeepLinker } from '../../navigation/deep-linker';
/**
* @name ModalController
@ -116,7 +117,7 @@ import { ModalOptions } from './modal-options';
@Injectable()
export class ModalController {
constructor(private _app: App, public config: Config) { }
constructor(private _app: App, public config: Config, private deepLinker: DeepLinker) { }
/**
* Create a modal to display. See below for options.
@ -126,6 +127,6 @@ export class ModalController {
* @param {object} opts Modal options
*/
create(component: any, data: any = {}, opts: ModalOptions = {}) {
return new Modal(this._app, component, data, opts, this.config);
return new Modal(this._app, component, data, opts, this.config, this.deepLinker);
}
}

View File

@ -0,0 +1,69 @@
import { App } from '../app/app';
import { Config } from '../../config/config';
import { isPresent } from '../../util/util';
import { PORTAL_MODAL } from '../app/app-constants';
import { ModalCmp } from './modal-component';
import { ModalOptions } from './modal-options';
import { ModalSlideIn, ModalSlideOut, ModalMDSlideIn, ModalMDSlideOut } from './modal-transitions';
import { NavOptions } from '../../navigation/nav-util';
import { ViewController } from '../../navigation/view-controller';
/**
* @private
*/
export class ModalImpl extends ViewController {
private _app: App;
private _enterAnimation: string;
private _leaveAnimation: string;
constructor(app: App, component: any, data: any, opts: ModalOptions = {}, config: Config) {
data = data || {};
data.component = component;
opts.showBackdrop = isPresent(opts.showBackdrop) ? !!opts.showBackdrop : true;
opts.enableBackdropDismiss = isPresent(opts.enableBackdropDismiss) ? !!opts.enableBackdropDismiss : true;
data.opts = opts;
super(ModalCmp, data, null);
this._app = app;
this._enterAnimation = opts.enterAnimation;
this._leaveAnimation = opts.leaveAnimation;
this.isOverlay = true;
config.setTransition('modal-slide-in', ModalSlideIn);
config.setTransition('modal-slide-out', ModalSlideOut);
config.setTransition('modal-md-slide-in', ModalMDSlideIn);
config.setTransition('modal-md-slide-out', ModalMDSlideOut);
}
/**
* @private
*/
getTransitionName(direction: string): string {
let key: string;
if (direction === 'back') {
if (this._leaveAnimation) {
return this._leaveAnimation;
}
key = 'modalLeave';
} else {
if (this._enterAnimation) {
return this._enterAnimation;
}
key = 'modalEnter';
}
return this._nav && this._nav.config.get(key);
}
/**
* Present the action sheet instance.
*
* @param {NavOptions} [opts={}] Nav options to go with this transition.
* @returns {Promise} Returns a promise which is resolved when the transition has completed.
*/
present(navOptions: NavOptions = {}) {
navOptions.minClickBlockDuration = navOptions.minClickBlockDuration || 400;
return this._app.present(this, navOptions, PORTAL_MODAL);
}
}

View File

@ -1,70 +1,25 @@
import { App } from '../app/app';
import { Config } from '../../config/config';
import { isPresent } from '../../util/util';
import { PORTAL_MODAL } from '../app/app-constants';
import { ModalCmp } from './modal-component';
import { ModalOptions } from './modal-options';
import { ModalSlideIn, ModalSlideOut, ModalMDSlideIn, ModalMDSlideOut } from './modal-transitions';
import { NavOptions } from '../../navigation/nav-util';
import { ViewController } from '../../navigation/view-controller';
import { DeepLinker } from '../../navigation/deep-linker';
import { Overlay } from '../../navigation/overlay';
import { OverlayProxy } from '../../navigation/overlay-proxy';
import { ModalImpl } from './modal-impl';
/**
* @private
*/
export class Modal extends ViewController {
private _app: App;
private _enterAnimation: string;
private _leaveAnimation: string;
export class Modal extends OverlayProxy {
constructor(app: App, component: any, data: any, opts: ModalOptions = {}, config: Config) {
data = data || {};
data.component = component;
opts.showBackdrop = isPresent(opts.showBackdrop) ? !!opts.showBackdrop : true;
opts.enableBackdropDismiss = isPresent(opts.enableBackdropDismiss) ? !!opts.enableBackdropDismiss : true;
data.opts = opts;
public isOverlay: boolean = true;
super(ModalCmp, data, null);
this._app = app;
this._enterAnimation = opts.enterAnimation;
this._leaveAnimation = opts.leaveAnimation;
this.isOverlay = true;
config.setTransition('modal-slide-in', ModalSlideIn);
config.setTransition('modal-slide-out', ModalSlideOut);
config.setTransition('modal-md-slide-in', ModalMDSlideIn);
config.setTransition('modal-md-slide-out', ModalMDSlideOut);
constructor(app: App, component: any, private data: any, private opts: ModalOptions = {}, config: Config, deepLinker: DeepLinker) {
super(app, component, config, deepLinker);
}
/**
* @private
*/
getTransitionName(direction: string): string {
let key: string;
if (direction === 'back') {
if (this._leaveAnimation) {
return this._leaveAnimation;
getImplementation(): Overlay {
return new ModalImpl(this._app, this._component, this.data, this.opts, this._config);
}
key = 'modalLeave';
} else {
if (this._enterAnimation) {
return this._enterAnimation;
}
key = 'modalEnter';
}
return this._nav && this._nav.config.get(key);
}
/**
* Present the action sheet instance.
*
* @param {NavOptions} [opts={}] Nav options to go with this transition.
* @returns {Promise} Returns a promise which is resolved when the transition has completed.
*/
present(navOptions: NavOptions = {}) {
navOptions.minClickBlockDuration = navOptions.minClickBlockDuration || 400;
return this._app.present(this, navOptions, PORTAL_MODAL);
}
}

View File

@ -1,19 +1,16 @@
import { mockApp, mockConfig } from '../../../util/mock-providers';
import { mockApp, mockConfig, mockDeepLinker } from '../../../util/mock-providers';
import { Component } from '@angular/core';
import { ModalController } from '../modal-controller';
import { ModalCmp } from '../modal-component';
import { ViewController } from '../../../navigation/view-controller';
describe('Modal', () => {
describe('create', () => {
it('should have the correct properties on modal view controller instance', () => {
let modalCtrl = new ModalController(mockApp(), mockConfig());
let modalViewController = modalCtrl.create(ComponentToPresent);
expect(modalViewController.component).toEqual(ModalCmp);
expect(modalViewController.isOverlay).toEqual(true);
expect(modalViewController instanceof ViewController).toEqual(true);
it('should have the correct properties on modal view controller proxy instance', () => {
let modalCtrl = new ModalController(mockApp(), mockConfig(), mockDeepLinker());
let modalViewControllerProxy = modalCtrl.create(ComponentToPresent);
expect(modalViewControllerProxy._component).toEqual(ModalCmp);
});
});

View File

@ -125,8 +125,6 @@ export class DeepLinker {
_history: string[] = [];
/** @internal */
_indexAliasUrl: string;
/** @internal */
_cfrMap = new Map<any, ComponentFactoryResolver>();
constructor(
@ -277,12 +275,9 @@ export class DeepLinker {
if (link.loadChildren) {
// awesome, looks like we'll lazy load this component
// using loadChildren as the URL to request
return this._moduleLoader.load(link.loadChildren).then(loadedModule => {
// kerpow!! we just lazy loaded a component!!
// update the existing link with the loaded component
link.component = loadedModule.component;
this._cfrMap.set(link.component, loadedModule.componentFactoryResolver);
return link.component;
return this._moduleLoader.load(link.loadChildren).then((response) => {
link.component = response.component;
return response.component;
});
}
@ -294,7 +289,8 @@ export class DeepLinker {
* @internal
*/
resolveComponent(component: any): ComponentFactory<any> {
let cfr = this._cfrMap.get(component);
let cfr = this._moduleLoader.getComponentFactoryResolver(component);
if (!cfr) {
cfr = this._baseCfr;
}

View File

@ -0,0 +1,77 @@
import { App } from '../components/app/app';
import { Config } from '../config/config';
import { isString } from '../util/util';
import { DeepLinker } from './deep-linker';
import { NavOptions } from './nav-util';
import { Overlay } from './overlay';
export class OverlayProxy {
overlay: Overlay;
_onWillDismiss: Function;
_onDidDismiss: Function;
constructor(public _app: App, public _component: any, public _config: Config, public _deepLinker: DeepLinker) {
}
getImplementation(): Overlay {
throw new Error('Child class must implement "getImplementation" method');
}
/**
* Present the modal instance.
*
* @param {NavOptions} [opts={}] Nav options to go with this transition.
* @returns {Promise} Returns a promise which is resolved when the transition has completed.
*/
present(navOptions: NavOptions = {}) {
// check if it's a lazy loaded component, or not
const isLazyLoaded = isString(this._component);
if (isLazyLoaded) {
return this._deepLinker.getComponentFromName(this._component).then((loadedComponent: any) => {
this._component = loadedComponent;
return this.createAndPresentOverlay(navOptions);
});
} else {
return this.createAndPresentOverlay(navOptions);
}
}
dismiss(data?: any, role?: any, navOptions?: NavOptions): Promise<any> {
if (this.overlay) {
return this.overlay.dismiss();
}
}
/**
* Called when the current viewController has be successfully dismissed
*/
onDidDismiss(callback: Function) {
this._onDidDismiss = callback;
if (this.overlay) {
this.overlay.onDidDismiss(this._onDidDismiss);
}
}
createAndPresentOverlay(navOptions: NavOptions) {
this.overlay = this.getImplementation();
this.overlay.onWillDismiss(this._onWillDismiss);
this.overlay.onDidDismiss(this._onDidDismiss);
return this.overlay.present(navOptions);
}
/**
* Called when the current viewController will be dismissed
*/
onWillDismiss(callback: Function) {
this._onWillDismiss = callback;
if (this.overlay) {
this.overlay.onWillDismiss(this._onWillDismiss);
}
}
}

View File

@ -0,0 +1,8 @@
import { NavOptions } from './nav-util';
export interface Overlay {
present(opts?: NavOptions): Promise<any>;
dismiss(data?: any, role?: any, navOptions?: NavOptions): Promise<any>;
onDidDismiss(callback: Function): void;
onWillDismiss(callback: Function): void;
}

View File

@ -0,0 +1,119 @@
import { OverlayProxy } from '../overlay-proxy';
import { mockApp, mockConfig, mockDeepLinker, mockOverlay } from '../../util/mock-providers';
describe('Overlay Proxy', () => {
describe('dismiss', () => {
it('should call dismiss if overlay is loaded', (done: Function) => {
const instance = new OverlayProxy(mockApp(), 'my-component', mockConfig(), mockDeepLinker());
instance.overlay = mockOverlay();
spyOn(instance.overlay, instance.overlay.dismiss.name).and.returnValue(Promise.resolve());
const promise = instance.dismiss(null, null, null);
promise.then(() => {
expect(instance.overlay.dismiss).toHaveBeenCalled();
done();
}).catch((err: Error) => {
done(err);
});
});
});
describe('onWillDismiss', () => {
it('should update the handler on the overlay object', () => {
const instance = new OverlayProxy(mockApp(), 'my-component', mockConfig(), mockDeepLinker());
instance.overlay = mockOverlay();
spyOn(instance.overlay, instance.overlay.onWillDismiss.name);
const handler = () => { };
instance.onWillDismiss(handler);
expect(instance.overlay.onWillDismiss).toHaveBeenCalledWith(handler);
});
});
describe('onDidDismiss', () => {
it('should update the handler on the overlay object', () => {
const instance = new OverlayProxy(mockApp(), 'my-component', mockConfig(), mockDeepLinker());
instance.overlay = mockOverlay();
spyOn(instance.overlay, instance.overlay.onDidDismiss.name);
const handler = () => { };
instance.onDidDismiss(handler);
expect(instance.overlay.onDidDismiss).toHaveBeenCalledWith(handler);
});
});
describe('createAndPresentOverlay', () => {
it('should set onWillDismiss and onDidDismiss handlers', (done: Function) => {
const instance = new OverlayProxy(mockApp(), 'my-component', mockConfig(), mockDeepLinker());
const handler = () => { };
instance.onWillDismiss(handler);
instance.onDidDismiss(handler);
const knownOptions = {};
const knownOverlay = mockOverlay();
spyOn(knownOverlay, knownOverlay.present.name).and.returnValue(Promise.resolve());
spyOn(knownOverlay, knownOverlay.onDidDismiss.name);
spyOn(knownOverlay, knownOverlay.onWillDismiss.name);
spyOn(instance, 'getImplementation').and.returnValue(knownOverlay);
const promise = instance.createAndPresentOverlay(knownOptions);
promise.then(() => {
expect(knownOverlay.present).toHaveBeenCalledWith(knownOptions);
expect(knownOverlay.onDidDismiss).toHaveBeenCalledWith(handler);
expect(knownOverlay.onWillDismiss).toHaveBeenCalledWith(handler);
done();
}).catch((err: Error) => {
done(err);
});
});
});
describe('present', () => {
it('should use present the overlay immediately if the component is not a string', (done: Function) => {
const knownComponent = { };
const deepLinker = mockDeepLinker();
const knownOverlay = mockOverlay();
const instance = new OverlayProxy(mockApp(), knownComponent, mockConfig(), deepLinker);
const knownOptions = {};
spyOn(instance, 'getImplementation').and.returnValue(knownOverlay);
spyOn(deepLinker, 'getComponentFromName');
const promise = instance.present(knownOptions);
promise.then(() => {
expect(deepLinker.getComponentFromName).not.toHaveBeenCalled();
done();
}).catch((err: Error) => {
done(err);
});
});
it('should load the component if its a string before using it', (done: Function) => {
const knownComponent = { };
const deepLinker = mockDeepLinker();
const knownOverlay = mockOverlay();
const componentName = 'my-component';
const instance = new OverlayProxy(mockApp(), componentName, mockConfig(), deepLinker);
const knownOptions = {};
spyOn(instance, 'getImplementation').and.returnValue(knownOverlay);
spyOn(deepLinker, 'getComponentFromName').and.returnValue(Promise.resolve(knownComponent));
const promise = instance.present(knownOptions);
promise.then(() => {
expect(deepLinker.getComponentFromName).toHaveBeenCalledWith(componentName);
done();
}).catch((err: Error) => {
done(err);
});
});
});
});

View File

@ -12,6 +12,7 @@ import { Haptic } from '../tap-click/haptic';
import { IonicApp } from '../components/app/app-root';
import { Keyboard } from '../platform/keyboard';
import { Menu } from '../components/menu/menu';
import { NavOptions } from '../navigation/nav-util';
import { NavControllerBase } from '../navigation/nav-controller-base';
import { OverlayPortal } from '../components/nav/overlay-portal';
import { PageTransition } from '../transitions/page-transition';
@ -528,3 +529,12 @@ export function noop(): any { return 'noop'; };
export function mockModuleLoader() {
return { };
}
export function mockOverlay() {
return {
present: (opts?: NavOptions) => { return Promise.resolve(); },
dismiss: (data?: any, role?: any, navOptions?: NavOptions) => { return Promise.resolve(); },
onDidDismiss: (callback: Function) => { },
onWillDismiss: (callback: Function) => { }
};
}

View File

@ -4,12 +4,17 @@ import { NgModuleLoader } from './ng-module-loader';
export const LAZY_LOADED_TOKEN = new OpaqueToken('LZYCMP');
/**
* @private
*/
@Injectable()
export class ModuleLoader {
/** @internal */
_cfrMap = new Map<any, ComponentFactoryResolver>();
constructor(
private _ngModuleLoader: NgModuleLoader,
private _injector: Injector) {}
@ -20,17 +25,24 @@ export class ModuleLoader {
const splitString = modulePath.split(SPLITTER);
return this._ngModuleLoader.load(splitString[0], splitString[1])
.then(loadedModule => {
return this._ngModuleLoader.load(splitString[0], splitString[1]).then(loadedModule => {
console.timeEnd(`ModuleLoader, load: ${modulePath}'`);
const ref = loadedModule.create(this._injector);
const component = ref.injector.get(LAZY_LOADED_TOKEN);
this._cfrMap.set(component, ref.componentFactoryResolver);
return {
componentFactoryResolver: ref.componentFactoryResolver,
component: ref.injector.get(LAZY_LOADED_TOKEN)
component: component
};
});
}
getComponentFactoryResolver(component: Type<any>) {
return this._cfrMap.get(component);
}
}
const SPLITTER = '#';