From 851aa838fab583b4432e6a27b2953b834ccbe513 Mon Sep 17 00:00:00 2001 From: Adam Bradley Date: Tue, 6 Mar 2018 19:57:45 -0600 Subject: [PATCH] refactor(router): init ng router refactor --- packages/angular/package.json | 2 +- packages/angular/src/index.ts | 17 +- packages/angular/src/module.ts | 19 ++- packages/angular/src/navigation/ion-nav.ts | 32 ++++ .../src/navigation/ion-router-outlet.ts | 160 ++++++++++++++++++ packages/angular/src/navigation/ion-tab.ts | 17 ++ packages/angular/src/navigation/ion-tabs.ts | 25 +++ .../src/navigation/router-controller.ts | 81 +++++++++ .../src/navigation/router-transition.ts | 34 ++++ 9 files changed, 369 insertions(+), 18 deletions(-) create mode 100644 packages/angular/src/navigation/ion-nav.ts create mode 100644 packages/angular/src/navigation/ion-router-outlet.ts create mode 100644 packages/angular/src/navigation/ion-tab.ts create mode 100644 packages/angular/src/navigation/ion-tabs.ts create mode 100644 packages/angular/src/navigation/router-controller.ts create mode 100644 packages/angular/src/navigation/router-transition.ts diff --git a/packages/angular/package.json b/packages/angular/package.json index d41652de8c..adfd12c617 100644 --- a/packages/angular/package.json +++ b/packages/angular/package.json @@ -21,7 +21,7 @@ }, "scripts": { "build": "npm run clean && npm run compile && npm run clean-generated", - "build.link": "node scripts/link-copy.js", + "build.link": "npm run build && node scripts/link-copy.js", "clean": "node scripts/clean.js", "clean-generated": "node ./scripts/clean-generated.js", "compile": "./node_modules/.bin/ngc", diff --git a/packages/angular/src/index.ts b/packages/angular/src/index.ts index 7aae62cc84..d880a04f28 100644 --- a/packages/angular/src/index.ts +++ b/packages/angular/src/index.ts @@ -1,29 +1,24 @@ export { IonicAngularModule } from './module'; +/* Navigation */ +export { IonNav } from './navigation/ion-nav'; +export { IonRouterOutlet } from './navigation/ion-router-outlet'; +export { IonTab } from './navigation/ion-tab'; +export { IonTabs } from './navigation/ion-tabs'; + /* Directives */ -export { IonNav } from './directives/ion-nav'; export { VirtualScroll } from './directives/virtual-scroll'; export { VirtualItem } from './directives/virtual-item'; export { VirtualHeader } from './directives/virtual-header'; export { VirtualFooter } from './directives/virtual-footer'; -/* Router */ -export { RouterOutlet } from './router/outlet'; -export { AsyncActivateRoutes } from './router/async-activated-routes'; -export { OutletInjector } from './router/outlet-injector'; -export { IonicRouterModule } from './router/router-module'; - /* Providers */ export { ActionSheetController, ActionSheetProxy } from './providers/action-sheet-controller'; export { AlertController, AlertProxy } from './providers/alert-controller'; -export { AngularComponentMounter } from './providers/angular-component-mounter'; -export { App } from './providers/app'; export { Events } from './providers/events'; export { LoadingController, LoadingProxy } from './providers/loading-controller'; export { MenuController } from './providers/menu-controller'; export { ModalController, ModalProxy } from './providers/modal-controller'; -export { NavController } from './providers/nav-controller'; -export { NavParams } from './providers/nav-params'; export { Platform } from './providers/platform'; export { PopoverController, PopoverProxy } from './providers/popover-controller'; export { ToastController, ToastProxy } from './providers/toast-controller'; diff --git a/packages/angular/src/module.ts b/packages/angular/src/module.ts index b47145908a..5b81265d00 100644 --- a/packages/angular/src/module.ts +++ b/packages/angular/src/module.ts @@ -12,8 +12,13 @@ import { RadioValueAccessor } from './control-value-accessors/radio-value-access import { SelectValueAccessor } from './control-value-accessors/select-value-accessor'; import { TextValueAccessor } from './control-value-accessors/text-value-accessor'; +/* Navigation */ +import { IonNav } from './navigation/ion-nav'; +import { IonRouterOutlet } from './navigation/ion-router-outlet'; +import { IonTab } from './navigation/ion-tab'; +import { IonTabs } from './navigation/ion-tabs'; + /* Directives */ -import { IonNav } from './directives/ion-nav'; import { VirtualScroll } from './directives/virtual-scroll'; import { VirtualItem } from './directives/virtual-item'; import { VirtualHeader } from './directives/virtual-header'; @@ -22,8 +27,6 @@ import { VirtualFooter } from './directives/virtual-footer'; /* Providers */ import { ActionSheetController } from './providers/action-sheet-controller'; import { AlertController } from './providers/alert-controller'; -import { AngularComponentMounter } from './providers/angular-component-mounter'; -import { App } from './providers/app'; import { Events, setupProvideEvents } from './providers/events'; import { LoadingController } from './providers/loading-controller'; import { MenuController } from './providers/menu-controller'; @@ -36,6 +39,9 @@ import { ToastController } from './providers/toast-controller'; declarations: [ BooleanValueAccessor, IonNav, + IonRouterOutlet, + IonTab, + IonTabs, NumericValueAccessor, RadioValueAccessor, SelectValueAccessor, @@ -48,6 +54,9 @@ import { ToastController } from './providers/toast-controller'; exports: [ BooleanValueAccessor, IonNav, + IonRouterOutlet, + IonTab, + IonTabs, NumericValueAccessor, RadioValueAccessor, SelectValueAccessor, @@ -63,8 +72,7 @@ import { ToastController } from './providers/toast-controller'; ], providers: [ ModalController, - PopoverController, - AngularComponentMounter + PopoverController ], schemas: [ CUSTOM_ELEMENTS_SCHEMA @@ -77,7 +85,6 @@ export class IonicAngularModule { providers: [ AlertController, ActionSheetController, - App, Events, LoadingController, MenuController, diff --git a/packages/angular/src/navigation/ion-nav.ts b/packages/angular/src/navigation/ion-nav.ts new file mode 100644 index 0000000000..87f99236bb --- /dev/null +++ b/packages/angular/src/navigation/ion-nav.ts @@ -0,0 +1,32 @@ +import { Component, ViewEncapsulation } from '@angular/core'; +import { + NavigationEnd, + NavigationStart, + Router +} from '@angular/router'; + + +@Component({ + selector: 'ion-nav', + template: '', + styles: [` + ion-nav > :not(.show-page) { display: none; } + `], + encapsulation: ViewEncapsulation.None +}) +export class IonNav { + + constructor(router: Router) { + console.log('ion-nav'); + + router.events.subscribe(ev => { + if (ev instanceof NavigationStart) { + console.log('NavigationStart', ev.url); + + } else if (ev instanceof NavigationEnd) { + console.log('NavigationEnd', ev.url); + } + }); + } + +} diff --git a/packages/angular/src/navigation/ion-router-outlet.ts b/packages/angular/src/navigation/ion-router-outlet.ts new file mode 100644 index 0000000000..64e0559e7c --- /dev/null +++ b/packages/angular/src/navigation/ion-router-outlet.ts @@ -0,0 +1,160 @@ +import { Attribute, ChangeDetectorRef, ComponentFactoryResolver, ComponentRef, Directive, EventEmitter, Injector, OnDestroy, OnInit, Output, ViewContainerRef } from '@angular/core'; +import { ActivatedRoute, ChildrenOutletContexts } from '@angular/router'; +import * as ctrl from './router-controller'; +import { runTransition } from './router-transition'; + + +@Directive({selector: 'ion-router-outlet', exportAs: 'ionOutlet'}) +export class IonRouterOutlet implements OnDestroy, OnInit { + private activated: ComponentRef|null = null; + private _activatedRoute: ActivatedRoute|null = null; + private name: string; + + private views: ctrl.RouteView[] = []; + + @Output('activate') activateEvents = new EventEmitter(); + @Output('deactivate') deactivateEvents = new EventEmitter(); + + constructor( + private parentContexts: ChildrenOutletContexts, private location: ViewContainerRef, + private resolver: ComponentFactoryResolver, @Attribute('name') name: string, + private changeDetector: ChangeDetectorRef) { + this.name = name || 'primary'; + parentContexts.onChildOutletCreated(this.name, this as any); + } + + ngOnDestroy(): void { + ctrl.destoryViews(this.views); + this.parentContexts.onChildOutletDestroyed(this.name); + } + + ngOnInit(): void { + if (!this.activated) { + // If the outlet was not instantiated at the time the route got activated we need to populate + // the outlet when it is initialized (ie inside a NgIf) + const context = this.parentContexts.getContext(this.name); + if (context && context.route) { + if (context.attachRef) { + // `attachRef` is populated when there is an existing component to mount + this.attach(context.attachRef, context.route); + } else { + // otherwise the component defined in the configuration is created + this.activateWith(context.route, context.resolver || null); + } + } + } + } + + get isActivated(): boolean { return !!this.activated; } + + get component(): Object { + if (!this.activated) throw new Error('Outlet is not activated'); + return this.activated.instance; + } + + get activatedRoute(): ActivatedRoute { + if (!this.activated) throw new Error('Outlet is not activated'); + return this._activatedRoute as ActivatedRoute; + } + + get activatedRouteData() { + if (this._activatedRoute) { + return this._activatedRoute.snapshot.data; + } + return {}; + } + + /** + * Called when the `RouteReuseStrategy` instructs to detach the subtree + */ + detach(): ComponentRef { + if (!this.activated) throw new Error('Outlet is not activated'); + this.location.detach(); + const cmp = this.activated; + this.activated = null; + this._activatedRoute = null; + return cmp; + } + + /** + * Called when the `RouteReuseStrategy` instructs to re-attach a previously detached subtree + */ + attach(ref: ComponentRef, activatedRoute: ActivatedRoute) { + this.activated = ref; + this._activatedRoute = activatedRoute; + + ctrl.attachView(this.views, this.location, ref, activatedRoute); + } + + deactivate(): void { + if (this.activated) { + const c = this.component; + + ctrl.deactivateView(this.views, this.activated); + + this.activated = null; + this._activatedRoute = null; + this.deactivateEvents.emit(c); + } + } + + activateWith(activatedRoute: ActivatedRoute, resolver: ComponentFactoryResolver|null) { + if (this.isActivated) { + throw new Error('Cannot activate an already activated outlet'); + } + + this._activatedRoute = activatedRoute; + + const existingView = ctrl.getExistingView(this.views, activatedRoute); + if (existingView) { + // we've already got a view hanging around + this.activated = existingView.ref; + + } else { + // haven't created this view yet + const snapshot = (activatedRoute as any)._futureSnapshot; + + const component = snapshot.routeConfig !.component; + resolver = resolver || this.resolver; + + const factory = resolver.resolveComponentFactory(component); + const childContexts = this.parentContexts.getOrCreateContext(this.name).children; + + const injector = new OutletInjector(activatedRoute, childContexts, this.location.injector); + this.activated = this.location.createComponent(factory, this.location.length, injector); + + // keep a ref + ctrl.initRouteViewElm(this.views, this.activated, activatedRoute); + } + + // Calling `markForCheck` to make sure we will run the change detection when the + // `RouterOutlet` is inside a `ChangeDetectionStrategy.OnPush` component. + this.changeDetector.markForCheck(); + + const lastDeactivatedRef = ctrl.getLastDeactivatedRef(this.views); + + runTransition(this.activated, lastDeactivatedRef).then(() => { + console.log('transition end'); + this.activateEvents.emit(this.activated.instance); + }); + } +} + + +class OutletInjector implements Injector { + constructor( + private route: ActivatedRoute, private childContexts: ChildrenOutletContexts, + private parent: Injector) {} + + get(token: any, notFoundValue?: any): any { + if (token === ActivatedRoute) { + return this.route; + } + + if (token === ChildrenOutletContexts) { + return this.childContexts; + } + + return this.parent.get(token, notFoundValue); + } +} diff --git a/packages/angular/src/navigation/ion-tab.ts b/packages/angular/src/navigation/ion-tab.ts new file mode 100644 index 0000000000..5cdfab7dc6 --- /dev/null +++ b/packages/angular/src/navigation/ion-tab.ts @@ -0,0 +1,17 @@ +import { Directive, ElementRef, Input, OnInit } from '@angular/core'; + + +@Directive({ + selector: 'ion-tab' +}) +export class IonTab implements OnInit { + + @Input() tabLink: string; + + constructor(private elementRef: ElementRef) {} + + ngOnInit() { + console.log('routerLink', this.tabLink, this.elementRef.nativeElement); + } + +} diff --git a/packages/angular/src/navigation/ion-tabs.ts b/packages/angular/src/navigation/ion-tabs.ts new file mode 100644 index 0000000000..6509c38d12 --- /dev/null +++ b/packages/angular/src/navigation/ion-tabs.ts @@ -0,0 +1,25 @@ +import { Directive, HostListener } from '@angular/core'; +import { Router } from '@angular/router'; + + +@Directive({ + selector: 'ion-tabs' +}) +export class IonTabs { + + constructor(private router: Router) {} + + @HostListener('ionTabbarClick', ['$event']) + ionTabbarClick(ev: UIEvent) { + console.log('ionTabbarClick', ev); + + const tabElm: HTMLIonTabElement = ev.detail as any; + if (tabElm && tabElm.href) { + console.log('tabElm', tabElm.href); + + this.router.navigateByUrl(tabElm.href); + } + } + +} + diff --git a/packages/angular/src/navigation/router-controller.ts b/packages/angular/src/navigation/router-controller.ts new file mode 100644 index 0000000000..08fdf330c8 --- /dev/null +++ b/packages/angular/src/navigation/router-controller.ts @@ -0,0 +1,81 @@ +import { ComponentRef, ViewContainerRef } from '@angular/core'; +import { ActivatedRoute, UrlSegment } from '@angular/router'; + + +export function attachView(views: RouteView[], location: ViewContainerRef, ref: ComponentRef, activatedRoute: ActivatedRoute) { + initRouteViewElm(views, ref, activatedRoute); + location.insert(ref.hostView); +} + + +export function initRouteViewElm(views: RouteView[], ref: ComponentRef, activatedRoute: ActivatedRoute) { + views.push({ + ref: ref, + urlKey: getUrlKey(activatedRoute), + deactivatedId: -1 + }); + + (ref.location.nativeElement as HTMLElement).classList.add('ion-page'); +} + + +export function getExistingView(views: RouteView[], activatedRoute: ActivatedRoute) { + return views.find(vw => { + return isMatchingActivatedRoute(vw.urlKey, activatedRoute); + }); +} + + +function isMatchingActivatedRoute(existingUrlKey: string, activatedRoute: ActivatedRoute) { + const activatedUrlKey = getUrlKey(activatedRoute); + + return activatedUrlKey === existingUrlKey; +} + + +export function getLastDeactivatedRef(views: RouteView[]) { + if (views.length < 2) { + return null; + } + + return views.sort((a, b) => { + if (a.deactivatedId > b.deactivatedId) return -1; + if (a.deactivatedId < b.deactivatedId) return 1; + return 0; + })[0].ref; +} + + +function getUrlKey(activatedRoute: ActivatedRoute) { + const url: UrlSegment[] = (activatedRoute.url as any).value; + + return url.map(u => { + return u.path + '$$' + JSON.stringify(u.parameters); + }).join('/'); +} + + +export function deactivateView(views: RouteView[], ref: ComponentRef) { + const view = views.find(vw => vw.ref === ref); + if (view) { + view.deactivatedId = deactivatedIds++; + } +} + + +export function destoryViews(views: RouteView[]) { + views.forEach(vw => { + vw.ref.destroy(); + }); + views.length = 0; +} + + +export interface RouteView { + urlKey: string; + ref: ComponentRef; + deactivatedId: number; +} + + +let deactivatedIds = 0; diff --git a/packages/angular/src/navigation/router-transition.ts b/packages/angular/src/navigation/router-transition.ts new file mode 100644 index 0000000000..c9a589560d --- /dev/null +++ b/packages/angular/src/navigation/router-transition.ts @@ -0,0 +1,34 @@ +import { ComponentRef } from '@angular/core'; + + +export function runTransition(enteringRef: ComponentRef, leavingRef: ComponentRef): Promise { + const enteringElm = (enteringRef && enteringRef.location && enteringRef.location.nativeElement); + const leavingElm = (leavingRef && leavingRef.location && leavingRef.location.nativeElement); + + if (!enteringElm && !leavingElm) { + return Promise.resolve(); + } + + return transition(enteringElm, leavingElm); +} + + +function transition(enteringElm: HTMLElement, leavingElm: HTMLElement): Promise { + console.log('transition start'); + + return new Promise(resolve => { + + setTimeout(() => { + + if (enteringElm) { + enteringElm.classList.add('show-page'); + } + + if (leavingElm) { + leavingElm.classList.remove('show-page'); + } + + resolve(); + }, 750); + }); +}