refactor(angular): lazy loading tabs (#16637)

Fixes #16619
This commit is contained in:
Manu MA
2018-12-08 17:23:39 +01:00
committed by GitHub
parent 86fc9a557e
commit 437ad09122
34 changed files with 11361 additions and 246 deletions

View File

@@ -5,10 +5,9 @@ export { RadioValueAccessor } from './control-value-accessors/radio-value-access
export { SelectValueAccessor } from './control-value-accessors/select-value-accessor';
export { TextValueAccessor } from './control-value-accessors/text-value-accessor';
export { IonTabs } from './navigation/ion-tabs';
export { IonBackButtonDelegate } from './navigation/ion-back-button';
export { NavDelegate } from './navigation/nav-delegate';
export { TabDelegate } from './navigation/tab-delegate';
export { TabsDelegate } from './navigation/tabs-delegate';
export { IonRouterOutlet } from './navigation/ion-router-outlet';
export { RouterLinkDelegate } from './navigation/router-link-delegate';
export { NavParams } from './navigation/nav-params';

View File

@@ -1,8 +1,9 @@
import { Attribute, ChangeDetectorRef, ComponentFactoryResolver, ComponentRef, Directive, ElementRef, EventEmitter, Injector, Input, NgZone, OnDestroy, OnInit, Optional, Output, ViewContainerRef } from '@angular/core';
import { ActivatedRoute, ChildrenOutletContexts, OutletContext, PRIMARY_OUTLET, Router } from '@angular/router';
import { RouteView, StackController } from './stack-controller';
import { StackController } from './stack-controller';
import { NavController } from '../../providers/nav-controller';
import { bindLifecycleEvents } from '../../providers/angular-delegate';
import { RouteView, getUrl } from './stack-utils';
@Directive({
selector: 'ion-router-outlet',
@@ -20,6 +21,8 @@ export class IonRouterOutlet implements OnDestroy, OnInit {
private nativeEl: HTMLIonRouterOutletElement;
private hasStack = false;
tabsPrefix: string | undefined;
@Output('activate') activateEvents = new EventEmitter<any>();
@Output('deactivate') deactivateEvents = new EventEmitter<any>();
@@ -43,18 +46,20 @@ export class IonRouterOutlet implements OnDestroy, OnInit {
private location: ViewContainerRef,
private resolver: ComponentFactoryResolver,
@Attribute('name') name: string,
@Optional() @Attribute('stack') stack: any,
@Optional() @Attribute('tabs') tabs: string,
private changeDetector: ChangeDetectorRef,
private navCtrl: NavController,
navCtrl: NavController,
elementRef: ElementRef,
router: Router,
zone: NgZone
zone: NgZone,
activatedRoute: ActivatedRoute
) {
this.nativeEl = elementRef.nativeElement;
this.name = name || PRIMARY_OUTLET;
this.tabsPrefix = tabs === 'true' ? getUrl(router, activatedRoute) : undefined;
this.stackCtrl = new StackController(this.tabsPrefix, this.nativeEl, router, navCtrl, zone);
parentContexts.onChildOutletCreated(this.name, this as any);
this.hasStack = stack !== 'false' && stack !== false;
this.stackCtrl = new StackController(this.hasStack, this.nativeEl, router, this.navCtrl, zone);
}
ngOnDestroy(): void {
@@ -169,32 +174,32 @@ export class IonRouterOutlet implements OnDestroy, OnInit {
enteringView = this.stackCtrl.createView(this.activated, activatedRoute);
}
const { direction, animated } = this.navCtrl.consumeTransition();
this.activatedView = enteringView;
this.stackCtrl.setActive(enteringView, direction, animated).then(() => {
this.stackCtrl.setActive(enteringView).then(() => {
this.activateEvents.emit(cmpRef.instance);
emitEvent(this.nativeEl);
emitEvent(this.nativeEl, enteringView!);
});
}
canGoBack(deep = 1) {
return this.stackCtrl.canGoBack(deep);
canGoBack(deep = 1, stackId?: string) {
return this.stackCtrl.canGoBack(deep, stackId);
}
pop(deep = 1) {
return this.stackCtrl.pop(deep);
pop(deep = 1, stackId?: string) {
return this.stackCtrl.pop(deep, stackId);
}
getLastUrl() {
const active = this.stackCtrl.getActive();
getLastUrl(stackId?: string) {
const active = this.stackCtrl.getLastUrl(stackId);
return active ? active.url : undefined;
}
}
function emitEvent(el: HTMLElement) {
function emitEvent(el: HTMLElement, view: RouteView) {
const ev = new CustomEvent('ionRouterOutletActivated', {
bubbles: true,
cancelable: true,
detail: { view }
});
el.dispatchEvent(ev);
}

View File

@@ -0,0 +1,69 @@
import { Component, ContentChild, HostListener, ViewChild } from '@angular/core';
import { TabButtonClickDetail } from '@ionic/core';
import { IonRouterOutlet } from './ion-router-outlet';
import { NavController } from '../../providers';
import { IonTabBar } from '../proxies';
import { RouteView } from './stack-utils';
@Component({
selector: 'ion-tabs',
template: `
<div class="tabs-inner">
<ion-router-outlet #outlet tabs="true"></ion-router-outlet>
</div>
<ng-content></ng-content>`,
styles: [`
:host {
display: flex;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
flex-direction: column;
width: 100%;
height: 100%;
contain: layout size style;
z-index: $z-index-page-container;
}
.tabs-inner {
position: relative;
flex: 1;
contain: layout size style;
}`
]
})
export class IonTabs {
@ViewChild('outlet', { read: IonRouterOutlet }) outlet: IonRouterOutlet;
@ContentChild(IonTabBar) tabBar: IonTabBar | undefined;
constructor(
private navCtrl: NavController,
) {}
@HostListener('ionRouterOutletActivated', ['$event.detail'])
onPageSelected(detail: {view: RouteView}) {
if (this.tabBar) {
this.tabBar.selectedTab = detail.view.stackId;
}
}
@HostListener('ionTabButtonClick', ['$event.detail'])
onTabButtonClick(detail: TabButtonClickDetail) {
const { tab, selected } = detail;
if (tab) {
const href = `${this.outlet.tabsPrefix}/${tab}`;
const url = selected
? href
: this.outlet.getLastUrl(tab) || href;
this.navCtrl.navigateBack(url, true);
}
}
}

View File

@@ -39,7 +39,6 @@ export class RouterLinkDelegate {
private updateTargetUrlAndHref() {
if (this.routerLink) {
const href = this.locationStrategy.prepareExternalUrl(this.router.serializeUrl(this.routerLink.urlTree));
console.log(href);
this.elementRef.nativeElement.href = href;
}
}

View File

@@ -2,6 +2,8 @@ import { ComponentRef, NgZone } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { NavController, NavDirection } from '../../providers/nav-controller';
import { RouterDirection } from '@ionic/core';
import { RouteView, computeStackId, destroyView, getUrl, insertView, isTabSwitch, toSegments } from './stack-utils';
export class StackController {
@@ -10,50 +12,65 @@ export class StackController {
private views: RouteView[] = [];
private runningTransition?: Promise<boolean>;
private skipTransition = false;
private tabsPrefix: string[] | undefined;
private activeView: RouteView | undefined;
private nextId = 0;
constructor(
private stack: boolean,
tabsPrefix: string | undefined,
private containerEl: HTMLIonRouterOutletElement,
private router: Router,
private navCtrl: NavController,
private zone: NgZone,
) {}
) {
this.tabsPrefix = tabsPrefix ? toSegments(tabsPrefix) : undefined;
}
createView(enteringRef: ComponentRef<any>, activatedRoute: ActivatedRoute): RouteView {
const url = getUrl(this.router, activatedRoute);
return {
id: this.nextId++,
ref: enteringRef,
element: (enteringRef && enteringRef.location && enteringRef.location.nativeElement) as HTMLElement,
url: this.getUrl(activatedRoute)
stackId: computeStackId(this.tabsPrefix, url),
url,
};
}
getExistingView(activatedRoute: ActivatedRoute): RouteView | undefined {
const activatedUrlKey = this.getUrl(activatedRoute);
const activatedUrlKey = getUrl(this.router, activatedRoute);
return this.views.find(vw => vw.url === activatedUrlKey);
}
async setActive(enteringView: RouteView, direction: NavDirection, animated: boolean) {
const leavingView = this.getActive();
async setActive(enteringView: RouteView) {
let { direction, animated } = this.navCtrl.consumeTransition();
const leavingView = this.activeView;
if (isTabSwitch(enteringView, leavingView)) {
direction = 'back';
animated = false;
}
this.insertView(enteringView, direction);
await this.transition(enteringView, leavingView, direction, animated, this.canGoBack(1), false);
this.cleanup();
}
canGoBack(deep: number): boolean {
return this.views.length > deep;
canGoBack(deep: number, stackId = this.getActiveStackId()): boolean {
return this.getStack(stackId).length > deep;
}
pop(deep: number) {
pop(deep: number, stackId = this.getActiveStackId()) {
this.zone.run(() => {
const view = this.views[this.views.length - deep - 1];
const views = this.getStack(stackId);
const view = views[views.length - deep - 1];
this.navCtrl.navigateBack(view.url);
});
}
startBackTransition() {
startBackTransition(stackId?: string) {
const views = this.getStack(stackId);
this.transition(
this.views[this.views.length - 2], // entering view
this.views[this.views.length - 1], // leaving view
views[views.length - 2], // entering view
views[views.length - 1], // leaving view
'back',
true,
true,
@@ -68,55 +85,41 @@ export class StackController {
}
}
getLastUrl(stackId?: string) {
const views = this.getStack(stackId);
return views.length > 0 ? views[views.length - 1] : undefined;
}
private insertView(enteringView: RouteView, direction: NavDirection) {
// no stack
if (!this.stack) {
this.views = [enteringView];
return;
}
private getActiveStackId(): string | undefined {
return this.activeView ? this.activeView.stackId : undefined;
}
// stack setRoot
if (direction === 'root') {
this.views = [enteringView];
return;
}
private getStack(stackId: string | undefined) {
return this.views.filter(v => v.stackId === stackId);
}
// stack
const index = this.views.indexOf(enteringView);
if (index >= 0) {
this.views = this.views.slice(0, index + 1);
} else {
if (direction === 'forward') {
this.views.push(enteringView);
} else {
this.views = [enteringView];
}
}
private insertView(enteringView: RouteView, direction: RouterDirection) {
this.activeView = enteringView;
this.views = insertView(this.views, enteringView, direction);
}
private cleanup() {
const activeRoute = this.activeView;
const views = this.views;
this.viewsSnapshot
.filter(view => !views.includes(view))
.forEach(view => destroyView(view));
for (let i = 0; i < views.length - 1; i++) {
const view = views[i];
const element = view.element;
element.setAttribute('aria-hidden', 'true');
element.classList.add('ion-page-hidden');
// TODO
// view.ref.changeDetectorRef.detach();
}
views.forEach(view => {
if (view !== activeRoute) {
const element = view.element;
element.setAttribute('aria-hidden', 'true');
element.classList.add('ion-page-hidden');
}
});
this.viewsSnapshot = views.slice();
}
getActive(): RouteView | undefined {
const views = this.views;
return views.length > 0 ? views[views.length - 1] : undefined;
}
private async transition(
enteringView: RouteView | undefined,
leavingView: RouteView | undefined,
@@ -158,23 +161,4 @@ export class StackController {
await this.runningTransition;
}
}
private getUrl(activatedRoute: ActivatedRoute) {
const urlTree = this.router.createUrlTree(['.'], { relativeTo: activatedRoute });
return this.router.serializeUrl(urlTree);
}
}
function destroyView(view: RouteView) {
if (view) {
// TODO lifecycle event
view.ref.destroy();
}
}
export interface RouteView {
url: string;
element: HTMLElement;
ref: ComponentRef<any>;
savedData?: any;
}

View File

@@ -0,0 +1,90 @@
import { ComponentRef } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { RouterDirection } from '@ionic/core';
export function insertView(views: RouteView[], view: RouteView, direction: RouterDirection) {
if (direction === 'root') {
return setRoot(views, view);
} else if (direction === 'forward') {
return setForward(views, view);
} else {
return setBack(views, view);
}
}
function setRoot(views: RouteView[], view: RouteView) {
views = views.filter(v => v.stackId !== view.stackId);
views.push(view);
return views;
}
function setForward(views: RouteView[], view: RouteView) {
const index = views.indexOf(view);
if (index >= 0) {
views = views.filter(v => v.stackId !== view.stackId || v.id <= view.id);
} else {
views.push(view);
}
return views;
}
function setBack(views: RouteView[], view: RouteView) {
const index = views.indexOf(view);
if (index >= 0) {
return views.filter(v => v.stackId !== view.stackId || v.id <= view.id);
} else {
return setRoot(views, view);
}
}
export function getUrl(router: Router, activatedRoute: ActivatedRoute) {
const urlTree = router.createUrlTree(['.'], { relativeTo: activatedRoute });
return router.serializeUrl(urlTree);
}
export function isTabSwitch(enteringView: RouteView, leavingView: RouteView | undefined) {
if (!leavingView) {
return false;
}
return enteringView.stackId !== leavingView.stackId;
}
export function computeStackId(prefixUrl: string[] | undefined, url: string) {
if (!prefixUrl) {
return undefined;
}
const segments = toSegments(url);
for (let i = 0; i < segments.length; i++) {
if (i >= prefixUrl.length) {
return segments[i];
}
if (segments[i] !== prefixUrl[i]) {
return undefined;
}
}
return undefined;
}
export function toSegments(path: string): string[] {
return path
.split('/')
.map(s => s.trim())
.filter(s => s !== '');
}
export function destroyView(view: RouteView) {
if (view) {
// TODO lifecycle event
view.ref.destroy();
}
}
export interface RouteView {
id: number;
url: string;
stackId: string | undefined;
element: HTMLElement;
ref: ComponentRef<any>;
savedData?: any;
}

View File

@@ -1,46 +0,0 @@
import { ComponentFactoryResolver, ContentChild, Directive, ElementRef, HostListener, Injector, ViewContainerRef } from '@angular/core';
import { AngularDelegate } from '../../providers/angular-delegate';
import { IonRouterOutlet } from './ion-router-outlet';
@Directive({
selector: 'ion-tab'
})
export class TabDelegate {
@ContentChild(IonRouterOutlet) outlet?: IonRouterOutlet;
private nativeEl: HTMLIonTabElement;
constructor(
elementRef: ElementRef,
resolver: ComponentFactoryResolver,
injector: Injector,
angularDelegate: AngularDelegate,
location: ViewContainerRef
) {
this.nativeEl = elementRef.nativeElement;
this.nativeEl.delegate = angularDelegate.create(resolver, injector, location);
}
get tab() {
return this.nativeEl.tab;
}
getLastUrl() {
return this.outlet ? this.outlet.getLastUrl() : undefined;
}
@HostListener('ionRouterOutletActivated')
async onNavChanged() {
const tab = this.nativeEl;
await tab.componentOnReady();
const tabs = tab.closest('ion-tabs');
if (tabs) {
await tabs.componentOnReady();
await tabs.select(tab);
}
}
}

View File

@@ -1,37 +0,0 @@
import { ContentChildren, Directive, ElementRef, HostListener, Optional, QueryList } from '@angular/core';
import { TabDelegate } from './tab-delegate';
import { TabButtonClickDetail } from '@ionic/core';
import { NavController } from '../../providers';
@Directive({
selector: 'ion-tabs'
})
export class TabsDelegate {
@ContentChildren(TabDelegate) tabs!: QueryList<TabDelegate>;
constructor(
@Optional() private navCtrl: NavController,
elementRef: ElementRef
) {
const nativeEl = elementRef.nativeElement as HTMLIonTabsElement;
nativeEl.useRouter = true;
}
urlForTab(tab: string) {
const tabDelegate = this.tabs.find(item => item.tab === tab);
return tabDelegate ? tabDelegate.getLastUrl() : undefined;
}
@HostListener('ionTabButtonClick', ['$event.detail'])
onTabbarClick(detail: TabButtonClickDetail) {
const { tab, href, selected } = detail;
if (tab && href) {
const url = selected
? href
: this.urlForTab(tab) || href;
this.navCtrl.navigateBack(url, true);
}
}
}

View File

@@ -67,10 +67,8 @@ export const DIRECTIVES = [
d.IonSlides,
d.IonSpinner,
d.IonSplitPane,
d.IonTab,
d.IonTabBar,
d.IonTabButton,
d.IonTabs,
d.IonText,
d.IonTextarea,
d.IonThumbnail,

View File

@@ -834,18 +834,6 @@ export class IonSplitPane {
}
}
export declare interface IonTab extends StencilComponents<'IonTab'> {}
@Component({ selector: 'ion-tab', changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: '<ng-content></ng-content>', inputs: ['active', 'delegate', 'tab', 'component'] })
export class IonTab {
constructor(c: ChangeDetectorRef, r: ElementRef) {
c.detach();
const el = r.nativeElement;
proxyMethods(this, el, ['setActive']);
proxyInputs(this, el, ['active', 'delegate', 'tab', 'component']);
}
}
export declare interface IonTabBar extends StencilComponents<'IonTabBar'> {}
@Component({ selector: 'ion-tab-bar', changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: '<ng-content></ng-content>', inputs: ['mode', 'color', 'selectedTab', 'translucent'] })
export class IonTabBar {
@@ -872,23 +860,6 @@ export class IonTabButton {
}
}
export declare interface IonTabs extends StencilComponents<'IonTabs'> {}
@Component({ selector: 'ion-tabs', changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: '<ng-content></ng-content>', inputs: ['useRouter'] })
export class IonTabs {
ionChange!: EventEmitter<CustomEvent>;
ionNavWillLoad!: EventEmitter<CustomEvent>;
ionNavWillChange!: EventEmitter<CustomEvent>;
ionNavDidChange!: EventEmitter<CustomEvent>;
constructor(c: ChangeDetectorRef, r: ElementRef) {
c.detach();
const el = r.nativeElement;
proxyMethods(this, el, ['select', 'setRouteId', 'getRouteId', 'getTab', 'getSelected']);
proxyInputs(this, el, ['useRouter']);
proxyOutputs(this, el, ['ionChange', 'ionNavWillLoad', 'ionNavWillChange', 'ionNavDidChange']);
}
}
export declare interface IonText extends StencilComponents<'IonText'> {}
@Component({ selector: 'ion-text', changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: '<ng-content></ng-content>', inputs: ['color', 'mode'] })
export class IonText {