feat(ion-router): dynamic routes

This commit is contained in:
Manu Mtz.-Almeida
2018-03-15 16:53:38 +01:00
parent 147a6090e4
commit 7c3cba0b92
8 changed files with 133 additions and 61 deletions

View File

@ -49,6 +49,11 @@ string
string string
## Events
#### ionRouteDataChanged
---------------------------------------------- ----------------------------------------------

View File

@ -1,12 +1,25 @@
import { Component, Prop } from '@stencil/core'; import { Component, Event, Prop } from '@stencil/core';
import { EventEmitter } from 'ionicons/dist/types/stencil.core';
@Component({ @Component({
tag: 'ion-route' tag: 'ion-route'
}) })
export class Route { export class Route {
@Prop() url = ''; @Prop() url = '';
@Prop() component: string; @Prop() component: string;
@Prop() redirectTo: string; @Prop() redirectTo: string;
@Prop() componentProps: {[key: string]: any}; @Prop() componentProps: {[key: string]: any};
@Event() ionRouteDataChanged: EventEmitter;
componentDidLoad() {
this.ionRouteDataChanged.emit();
}
componentDidUnload() {
this.ionRouteDataChanged.emit();
}
componentDidUpdate() {
this.ionRouteDataChanged.emit();
}
} }

View File

@ -29,6 +29,11 @@ string
boolean boolean
## Events
#### ionRouteChanged
## Methods ## Methods
#### navChanged() #### navChanged()

View File

@ -1,10 +1,11 @@
import { Build, Component, Element, Listen, Method, Prop } from '@stencil/core'; import { Build, Component, Element, Event, EventEmitter, Listen, Method, Prop } from '@stencil/core';
import { Config, DomController } from '../../index'; import { Config, DomController } from '../../index';
import { flattenRouterTree, readRedirects, readRoutes } from './utils/parser'; import { flattenRouterTree, readRedirects, readRoutes } from './utils/parser';
import { readNavState, writeNavState } from './utils/dom'; import { readNavState, writeNavState } from './utils/dom';
import { chainToPath, generatePath, parsePath, readPath, writePath } from './utils/path'; import { chainToPath, parsePath, readPath, writePath } from './utils/path';
import { RouteChain, RouteRedirect } from './utils/interfaces'; import { RouteChain, RouteRedirect, RouterEventDetail } from './utils/interfaces';
import { routeRedirect, routerIDsToChain, routerPathToChain } from './utils/matching'; import { routeRedirect, routerIDsToChain, routerPathToChain } from './utils/matching';
import { printRoutes } from './utils/debug';
@Component({ @Component({
@ -13,9 +14,14 @@ import { routeRedirect, routerIDsToChain, routerPathToChain } from './utils/matc
export class Router { export class Router {
private routes: RouteChain[]; private routes: RouteChain[];
private previousPath: string[] = null;
private redirects: RouteRedirect[]; private redirects: RouteRedirect[];
private busy = false; private busy = false;
private init = false;
private state = 0; private state = 0;
private timer: any;
@Element() el: HTMLElement;
@Prop({ context: 'config' }) config: Config; @Prop({ context: 'config' }) config: Config;
@Prop({ context: 'dom' }) dom: DomController; @Prop({ context: 'dom' }) dom: DomController;
@ -23,26 +29,33 @@ export class Router {
@Prop() base = ''; @Prop() base = '';
@Prop() useHash = true; @Prop() useHash = true;
@Element() el: HTMLElement; @Event() ionRouteChanged: EventEmitter<RouterEventDetail>;
componentDidLoad() { componentDidLoad() {
this.init = true;
this.onRouteChanged();
}
@Listen('ionRouteDataChanged')
protected onRouteChanged() {
if (!this.init) {
return;
}
const tree = readRoutes(this.el); const tree = readRoutes(this.el);
this.routes = flattenRouterTree(tree); this.routes = flattenRouterTree(tree);
this.redirects = readRedirects(this.el); this.redirects = readRedirects(this.el);
if (Build.isDev) { if (Build.isDev) {
console.debug('%c[@ionic/core]', 'font-weight: bold', `ion-router registered ${this.routes.length} routes`); printRoutes(this.routes);
for (const chain of this.routes) {
const path: string[] = [];
chain.forEach(r => path.push(...r.path));
const ids = chain.map(r => r.id);
console.debug(`%c ${generatePath(path)}`, 'font-weight: bold; padding-left: 20px', '=>\t', `(${ids.join(', ')})`);
}
} }
// perform first write // schedule write
this.dom.raf(() => { if (this.timer) {
console.debug('[OUT] page load -> write nav state'); cancelAnimationFrame(this.timer);
this.timer = undefined;
}
this.timer = requestAnimationFrame(() => {
this.timer = undefined;
this.onPopState(); this.onPopState();
}); });
} }
@ -53,76 +66,91 @@ export class Router {
this.state++; this.state++;
window.history.replaceState(this.state, document.title, document.location.href); window.history.replaceState(this.state, document.title, document.location.href);
} }
this.writeNavStateRoot(this.readPath()); this.writeNavStateRoot(this.getPath());
} }
@Method() @Method()
navChanged(isPop: boolean) { navChanged(isPop: boolean): Promise<boolean> {
if (!this.busy) { if (this.busy) {
return Promise.resolve(false);
}
console.debug('[IN] nav changed -> update URL'); console.debug('[IN] nav changed -> update URL');
const { ids, pivot } = this.readNavState(); const { ids, pivot } = readNavState(document.body);
const chain = routerIDsToChain(ids, this.routes); const chain = routerIDsToChain(ids, this.routes);
if (chain) { if (!chain) {
const path = chainToPath(chain);
this.writePath(path, isPop);
if (chain.length > ids.length) {
// readNavState() found a pivot that is not initialized
console.debug('[IN] pivot uninitialized -> write partial nav state');
return this.writeNavState(pivot, chain.slice(ids.length), 0);
}
} else {
console.warn('no matching URL for ', ids.map(i => i.id)); console.warn('no matching URL for ', ids.map(i => i.id));
} return Promise.resolve(false);
}
return Promise.resolve();
} }
const path = chainToPath(chain);
this.setPath(path, isPop);
const promise = (chain.length > ids.length)
? this.writeNavState(pivot, chain.slice(ids.length), 0)
: Promise.resolve(true);
return promise.then(() => {
this.emitRouteChange(path, null);
return true;
});
}
@Method() @Method()
push(url: string, backDirection = false) { push(url: string, backDirection = false) {
const path = parsePath(url); const path = parsePath(url);
this.writePath(path, backDirection); this.setPath(path, backDirection);
return this.writeNavStateRoot(path); return this.writeNavStateRoot(path);
} }
private writeNavStateRoot(path: string[]): Promise<any> { private writeNavStateRoot(path: string[]): Promise<boolean> {
if (this.busy) { if (this.busy) {
return Promise.resolve(); return Promise.resolve(false);
} }
const redirect = routeRedirect(path, this.redirects); const redirect = routeRedirect(path, this.redirects);
let redirectFrom: string[] = null;
if (redirect) { if (redirect) {
this.writePath(redirect.to, true); this.setPath(redirect.to, true);
redirectFrom = redirect.path;
path = redirect.to; path = redirect.to;
} }
const direction = window.history.state >= this.state ? 1 : -1; const direction = window.history.state >= this.state ? 1 : -1;
const node = document.querySelector('ion-app');
const chain = routerPathToChain(path, this.routes); const chain = routerPathToChain(path, this.routes);
return this.writeNavState(node, chain, direction); return this.writeNavState(document.body, chain, direction).then(changed => {
if (changed) {
this.emitRouteChange(path, redirectFrom);
}
return changed;
});
} }
private writeNavState(node: any, chain: RouteChain, direction: number): Promise<any> { private writeNavState(node: any, chain: RouteChain, direction: number): Promise<boolean> {
if (this.busy) { if (this.busy) {
return Promise.resolve(); return Promise.resolve(false);
} }
this.busy = true; this.busy = true;
return writeNavState(node, chain, 0, direction) return writeNavState(node, chain, 0, direction).then(changed => {
.catch(err => console.error(err)) this.busy = false;
.then(() => this.busy = false); return changed;
});
} }
private readNavState() { private setPath(path: string[], isPop: boolean) {
const root = document.querySelector('ion-app') as HTMLElement;
return readNavState(root);
}
private writePath(path: string[], isPop: boolean) {
// busyURL is used to prevent reentering in the popstate event
this.state++; this.state++;
writePath(window.history, this.base, this.useHash, path, isPop, this.state); writePath(window.history, this.base, this.useHash, path, isPop, this.state);
} }
private readPath(): string[] | null { private getPath(): string[] | null {
return readPath(window.location, this.base, this.useHash); return readPath(window.location, this.base, this.useHash);
} }
private emitRouteChange(path: string[], redirectedFrom: string[]|null) {
const from = this.previousPath;
const to = path.slice();
this.previousPath = to;
this.ionRouteChanged.emit({
from,
redirectedFrom,
to: to
});
}
} }

View File

@ -0,0 +1,12 @@
import { generatePath } from './path';
import { RouteChain } from './interfaces';
export function printRoutes(routes: RouteChain[]) {
console.debug('%c[@ionic/core]', 'font-weight: bold', `ion-router registered ${routes.length} routes`);
for (const chain of routes) {
const path: string[] = [];
chain.forEach(r => path.push(...r.path));
const ids = chain.map(r => r.id);
console.debug(`%c ${generatePath(path)}`, 'font-weight: bold; padding-left: 20px', '=>\t', `(${ids.join(', ')})`);
}
}

View File

@ -1,13 +1,13 @@
import { NavOutlet, NavOutletElement, RouteChain, RouteID } from './interfaces'; import { NavOutlet, NavOutletElement, RouteChain, RouteID } from './interfaces';
export function writeNavState(root: HTMLElement, chain: RouteChain|null, index: number, direction: number): Promise<void> { export function writeNavState(root: HTMLElement, chain: RouteChain|null, index: number, direction: number): Promise<boolean> {
if (!chain || index >= chain.length) { if (!chain || index >= chain.length) {
return Promise.resolve(); return Promise.resolve(direction === 0);
} }
const route = chain[index]; const route = chain[index];
const node = searchNavNode(root); const node = searchNavNode(root);
if (!node) { if (!node) {
return Promise.resolve(); return Promise.resolve(direction === 0);
} }
return node.componentOnReady() return node.componentOnReady()
.then(() => node.setRouteId(route.id, route.params, direction)) .then(() => node.setRouteId(route.id, route.params, direction))
@ -18,10 +18,13 @@ export function writeNavState(root: HTMLElement, chain: RouteChain|null, index:
const nextEl = node.getContainerEl(); const nextEl = node.getContainerEl();
const promise = (nextEl) const promise = (nextEl)
? writeNavState(nextEl, chain, index + 1, direction) ? writeNavState(nextEl, chain, index + 1, direction)
: Promise.resolve(); : Promise.resolve(direction === 0);
if (result.markVisible) { if (result.markVisible) {
return promise.then(() => result.markVisible()); return promise.then((c) => {
result.markVisible();
return c;
});
} }
return promise; return promise;
}); });

View File

@ -6,6 +6,12 @@ export interface NavOutlet {
getContainerEl(): HTMLElement | null; getContainerEl(): HTMLElement | null;
} }
export interface RouterEventDetail {
from: string[]|null;
redirectedFrom: string[]|null;
to: string[];
}
export interface RouteRedirect { export interface RouteRedirect {
path: string[]; path: string[];
to: string[]; to: string[];

View File

@ -245,7 +245,7 @@ export class Tabs implements NavOutlet {
if (router) { if (router) {
return router.navChanged(false); return router.navChanged(false);
} }
return Promise.resolve(); return Promise.resolve(false);
} }
private shouldSwitch(selectedTab: HTMLIonTabElement) { private shouldSwitch(selectedTab: HTMLIonTabElement) {