import type { BackstackEntry, NavigationContext, NavigationEntry, NavigationTransition } from './frame-interfaces'; import { NavigationType } from './frame-interfaces'; import { Page } from '../page'; import { View, CustomLayoutView, CSSType } from '../core/view'; import { Property } from '../core/properties'; import { Trace } from '../../trace'; import { frameStack, topmost as frameStackTopmost, _pushInFrameStack, _popFromFrameStack, _removeFromFrameStack } from './frame-stack'; import { viewMatchesModuleContext } from '../core/view/view-common'; import { getAncestor } from '../core/view-base'; import { Builder } from '../builder'; import { sanitizeModuleName } from '../../utils/common'; import { profile } from '../../profiling'; import { FRAME_SYMBOL } from './frame-helpers'; import { SharedTransition } from '../transition/shared-transition'; export { NavigationType } from './frame-interfaces'; export type { AndroidActivityCallbacks, AndroidFragmentCallbacks, AndroidFrame, BackstackEntry, NavigationContext, NavigationEntry, NavigationTransition, TransitionState, ViewEntry, iOSFrame } from './frame-interfaces'; function buildEntryFromArgs(arg: any): NavigationEntry { let entry: NavigationEntry; if (typeof arg === 'string') { entry = { moduleName: arg, }; } else if (typeof arg === 'function') { entry = { create: arg, }; } else { entry = arg; } return entry; } @CSSType('Frame') export class FrameBase extends CustomLayoutView { public static navigatingToEvent = 'navigatingTo'; public static navigatedToEvent = 'navigatedTo'; private _animated: boolean; private _transition: NavigationTransition; private _backStack = new Array(); _navigationQueue = new Array(); public actionBarVisibility: 'auto' | 'never' | 'always'; public _currentEntry: BackstackEntry; /** * A reference of current page that is set earlier than current entry. * Using this property, methods like 'eachChildView' and '_childrenCount' gain access to page view * just in time for calls like '_addView' to perform view-tree iterations. */ public _resolvedPage: Page; public _animationInProgress = false; public _executingContext: NavigationContext; public _isInFrameStack = false; public static defaultAnimatedNavigation = true; public static defaultTransition: NavigationTransition; static getFrameById(id: string): FrameBase { return frameStack.find((frame) => frame.id && frame.id === id); } static topmost(): FrameBase { return frameStackTopmost(); } static goBack(): boolean { const top = FrameBase.topmost(); if (top && top.canGoBack()) { top.goBack(); return true; } else if (top) { let parentFrameCanGoBack = false; let parentFrame = getAncestor(top, 'Frame'); while (parentFrame && !parentFrameCanGoBack) { if (parentFrame && parentFrame.canGoBack()) { parentFrameCanGoBack = true; } else { parentFrame = getAncestor(parentFrame, 'Frame'); } } if (parentFrame && parentFrameCanGoBack) { parentFrame.goBack(); return true; } } if (frameStack.length > 1) { top._popFromFrameStack(); } return false; } /** * @private */ static reloadPage(): void { // Implemented in plat-specific file - only for android. } /** * @private */ static _stack(): Array { return frameStack; } // TODO: Currently our navigation will not be synchronized in case users directly call native navigation methods like Activity.startActivity. public _addChildFromBuilder(name: string, value: any) { throw new Error(`Frame should not have a view. Use 'defaultPage' property instead.`); } @profile public onLoaded() { super.onLoaded(); this._processNextNavigationEntry(); } public canGoBack(): boolean { let backstack = this._backStack.length; let previousForwardNotInBackstack = false; this._navigationQueue.forEach((item) => { const entry = item.entry; const isBackNavigation = item.navigationType === NavigationType.back; if (isBackNavigation) { previousForwardNotInBackstack = false; if (!entry) { backstack--; } else { const backstackIndex = this._backStack.indexOf(entry); if (backstackIndex !== -1) { backstack = backstackIndex; } else { // NOTE: We don't search for entries in navigationQueue because there is no way for // developer to get reference to BackstackEntry unless transition is completed. // At that point the entry is put in the backstack array. // If we start to return Backstack entry from navigate method then // here we should check also navigationQueue as well. backstack--; } } } else if (entry.entry.clearHistory) { previousForwardNotInBackstack = false; backstack = 0; } else { backstack++; if (previousForwardNotInBackstack) { backstack--; } previousForwardNotInBackstack = entry.entry.backstackVisible === false; } }); // this is our first navigation which is not completed yet. if (this._navigationQueue.length > 0 && !this._currentEntry) { backstack--; } return backstack > 0; } /** * Navigates to the previous entry (if any) in the back stack. * @param to The backstack entry to navigate back to. */ public goBack(backstackEntry?: BackstackEntry): void { if (Trace.isEnabled()) { Trace.write(`GO BACK`, Trace.categories.Navigation); } if (!this.canGoBack()) { return; } if (backstackEntry) { const index = this._backStack.indexOf(backstackEntry); if (index < 0) { return; } } const navigationContext: NavigationContext = { entry: backstackEntry, isBackNavigation: true, navigationType: NavigationType.back, }; this._navigationQueue.push(navigationContext); this._processNextNavigationEntry(); } public _removeEntry(removed: BackstackEntry): void { const page = removed.resolvedPage; if (page) { const frame = page.frame; if (frame) { frame._removeView(page); } else { page._tearDownUI(true); } } else { if (Trace.isEnabled()) { Trace.write(`_removeEntry: backstack entry missing page`, Trace.categories.Navigation); } } removed.resolvedPage = null; } protected _disposeBackstackEntry(entry: BackstackEntry): void { const page = entry.resolvedPage; if (page) { page._tearDownUI(true); } } public navigate(param: any) { if (Trace.isEnabled()) { Trace.write(`NAVIGATE`, Trace.categories.Navigation); } this._pushInFrameStack(); const entry = buildEntryFromArgs(param); const page = Builder.createViewFromEntry(entry) as Page; const backstackEntry: BackstackEntry = { entry: entry, resolvedPage: page, navDepth: undefined, fragmentTag: undefined, }; const navigationContext: NavigationContext = { entry: backstackEntry, isBackNavigation: false, navigationType: NavigationType.forward, }; this._navigationQueue.push(navigationContext); this._processNextNavigationEntry(); } public isCurrent(entry: BackstackEntry): boolean { return this._currentEntry === entry; } public setCurrent(entry: BackstackEntry, navigationType: NavigationType): void { const newPage = entry.resolvedPage; // In case we navigated forward to a page that was in the backstack // with clearHistory: true if (!newPage.frame) { this._resolvedPage = newPage; this._addView(newPage); } this._currentEntry = entry; const isBack = navigationType === NavigationType.back; if (isBack) { this._pushInFrameStack(); } newPage.onNavigatedTo(isBack); this.notify({ eventName: FrameBase.navigatedToEvent, object: this, isBack, entry, }); // Reset executing context after NavigatedTo is raised; // we do not want to execute two navigations in parallel in case // additional navigation is triggered from the NavigatedTo handler. this._executingContext = null; } public _updateBackstack(entry: BackstackEntry, navigationType: NavigationType): void { const isBack = navigationType === NavigationType.back; const isReplace = navigationType === NavigationType.replace; this.raiseCurrentPageNavigatedEvents(isBack); const current = this._currentEntry; // Do nothing for Hot Module Replacement if (isBack) { const index = this._backStack.indexOf(entry); this._backStack.splice(index + 1).forEach((e) => this._removeEntry(e)); this._backStack.pop(); } else if (!isReplace) { if (entry.entry.clearHistory) { this._backStack.forEach((e) => this._removeEntry(e)); this._backStack.length = 0; } else if (FrameBase._isEntryBackstackVisible(current)) { this._backStack.push(current); } } if (current && this._backStack.indexOf(current) < 0) { this._removeEntry(current); } } private isNestedWithin(parentFrameCandidate: FrameBase): boolean { let frameAncestor: FrameBase = this; while (frameAncestor) { frameAncestor = getAncestor(frameAncestor, FrameBase); if (frameAncestor === parentFrameCandidate) { return true; } } return false; } private raiseCurrentPageNavigatedEvents(isBack: boolean) { const page = this.currentPage; if (page) { if (page.isLoaded) { // Forward navigation does not remove page from frame so we raise unloaded manually. page.callUnloaded(); } page.onNavigatedFrom(isBack); } } public _processNavigationQueue(page: Page) { if (this._navigationQueue.length === 0) { // This could happen when showing recreated page after activity has been destroyed. return; } const entry = this._navigationQueue[0].entry; const currentNavigationPage = entry.resolvedPage; if (page !== currentNavigationPage) { // If the page is not the one that requested navigation - skip it. return; } // remove completed operation. this._navigationQueue.shift(); this._processNextNavigationEntry(); this._updateActionBar(); } public _findEntryForTag(fragmentTag: string): BackstackEntry { let entry: BackstackEntry; if (this._currentEntry && this._currentEntry.fragmentTag === fragmentTag) { entry = this._currentEntry; } else { entry = this._backStack.find((value) => value.fragmentTag === fragmentTag); // on API 26 fragments are recreated lazily after activity is destroyed. if (!entry) { const navigationItem = this._navigationQueue.find((value) => value.entry.fragmentTag === fragmentTag); entry = navigationItem ? navigationItem.entry : undefined; } } return entry; } public navigationQueueIsEmpty(): boolean { return this._navigationQueue.length === 0; } public static _isEntryBackstackVisible(entry: BackstackEntry): boolean { if (!entry) { return false; } const backstackVisibleValue = entry.entry.backstackVisible; const backstackHidden = backstackVisibleValue !== undefined && !backstackVisibleValue; return !backstackHidden; } public _updateActionBar(page?: Page, disableNavBarAnimation?: boolean) { //Trace.write("calling _updateActionBar on Frame", Trace.categories.Navigation); } protected _processNextNavigationEntry() { if (!this.isLoaded || this._executingContext) { return; } if (this._navigationQueue.length > 0) { const navigationContext = this._navigationQueue[0]; const isBackNavigation = navigationContext.navigationType === NavigationType.back; if (isBackNavigation) { this.performGoBack(navigationContext); } else { this.performNavigation(navigationContext); } } } @profile public performNavigation(navigationContext: NavigationContext) { this._executingContext = navigationContext; const backstackEntry = navigationContext.entry; const isBackNavigation = navigationContext.navigationType === NavigationType.back; this._onNavigatingTo(backstackEntry, isBackNavigation); const navigationTransition = this._getNavigationTransition(backstackEntry.entry); if (navigationTransition?.instance) { const state = SharedTransition.getState(navigationTransition?.instance.id); SharedTransition.updateState(navigationTransition?.instance.id, { // Allow setting custom page context to override default (from) page // helpful for deeply nested frame navigation setups (eg: Nested Tab Navigation) // when sharing elements in this condition, the (from) page would // get overridden on each frame preventing shared element matching page: state?.page || this.currentPage, toPage: this, }); } this._navigateCore(backstackEntry); } @profile performGoBack(navigationContext: NavigationContext) { let backstackEntry = navigationContext.entry; const backstack = this._backStack; if (!backstackEntry) { backstackEntry = backstack[backstack.length - 1]; navigationContext.entry = backstackEntry; } this._executingContext = navigationContext; this._onNavigatingTo(backstackEntry, true); this._goBackCore(backstackEntry); } public _goBackCore(backstackEntry: BackstackEntry) { if (Trace.isEnabled()) { Trace.write(`GO BACK CORE(${this._backstackEntryTrace(backstackEntry)}); currentPage: ${this.currentPage}`, Trace.categories.Navigation); } } public _navigateCore(backstackEntry: BackstackEntry) { if (Trace.isEnabled()) { Trace.write(`NAVIGATE CORE(${this._backstackEntryTrace(backstackEntry)}); currentPage: ${this.currentPage}`, Trace.categories.Navigation); } } public _onNavigatingTo(backstackEntry: BackstackEntry, isBack: boolean) { if (this.currentPage) { this.currentPage.onNavigatingFrom(isBack); } backstackEntry.resolvedPage.onNavigatingTo(backstackEntry.entry.context, isBack, backstackEntry.entry.bindingContext); this.notify({ eventName: FrameBase.navigatingToEvent, object: this, isBack, entry: backstackEntry, fromEntry: this._currentEntry, }); } public get animated(): boolean { return this._animated; } public set animated(value: boolean) { this._animated = value; } public get transition(): NavigationTransition { return this._transition; } public set transition(value: NavigationTransition) { this._transition = value; } get backStack(): Array { return this._backStack.slice(); } get currentPage(): Page { if (this._currentEntry) { return this._currentEntry.resolvedPage; } return null; } get currentEntry(): NavigationEntry { if (this._currentEntry) { return this._currentEntry.entry; } return null; } public _pushInFrameStackRecursive() { this._pushInFrameStack(); // make sure nested frames order is kept intact i.e. the nested one should always be on top; // see https://github.com/NativeScript/nativescript-angular/issues/1596 for more information const framesToPush = []; for (const frame of frameStack) { if (frame.isNestedWithin(this)) { framesToPush.push(frame); } } for (const frame of framesToPush) { frame._pushInFrameStack(); } } public _pushInFrameStack() { _pushInFrameStack(this); } public _popFromFrameStack() { _popFromFrameStack(this); } public _removeFromFrameStack() { _removeFromFrameStack(this); } public _dialogClosed(): void { // No super call as we do not support nested frames to clean up this._removeFromFrameStack(); } public _onRootViewReset(): void { super._onRootViewReset(); this._removeFromFrameStack(); } get _childrenCount(): number { if (this._resolvedPage) { return 1; } return 0; } public eachChildView(callback: (child: View) => boolean) { const page = this._resolvedPage; if (page) { callback(page); } } public _getIsAnimatedNavigation(entry: NavigationEntry): boolean { if (entry && entry.animated !== undefined) { return entry.animated; } if (this.animated !== undefined) { return this.animated; } return FrameBase.defaultAnimatedNavigation; } public _getNavigationTransition(entry: NavigationEntry): NavigationTransition { if (entry) { if (__APPLE__ && entry.transitioniOS !== undefined) { return entry.transitioniOS; } if (__ANDROID__ && entry.transitionAndroid !== undefined) { return entry.transitionAndroid; } if (entry.transition !== undefined) { return entry.transition; } } if (this.transition !== undefined) { return this.transition; } return FrameBase.defaultTransition; } public get navigationBarHeight(): number { return 0; } public _getNavBarVisible(page: Page): boolean { throw new Error(); } // We don't need to put Page as visual child. Don't call super. public _addViewToNativeVisualTree(child: View): boolean { return true; } // We don't need to put Page as visual child. Don't call super. public _removeViewFromNativeVisualTree(child: View): void { child._isAddedToNativeVisualTree = false; } public _printFrameBackStack() { const length = this.backStack.length; let i = length - 1; console.log(`Frame Back Stack: `); while (i >= 0) { const backstackEntry = this.backStack[i--]; console.log(`\t${backstackEntry.resolvedPage}`); } } public _backstackEntryTrace(b: BackstackEntry): string { let result = `${b.resolvedPage}`; const backstackVisible = FrameBase._isEntryBackstackVisible(b); if (!backstackVisible) { result += ` | INVISIBLE`; } if (b.entry.clearHistory) { result += ` | CLEAR HISTORY`; } const animated = this._getIsAnimatedNavigation(b.entry); if (!animated) { result += ` | NOT ANIMATED`; } const t = this._getNavigationTransition(b.entry); if (t) { result += ` | Transition[${JSON.stringify(t)}]`; } return result; } public _onLivesync(context?: ModuleContext): boolean { if (super._onLivesync(context)) { return true; } // Fallback if (!context) { return this._onLivesyncWithoutContext(); } return false; } public _handleLivesync(context?: ModuleContext): boolean { if (super._handleLivesync(context)) { return true; } // Handle markup/script changes in currentPage if (this.currentPage && viewMatchesModuleContext(this.currentPage, context, ['markup', 'script'])) { Trace.write(`Change Handled: Replacing page ${context.path}`, Trace.categories.Livesync); // Replace current page with a default fade transition this.replacePage({ moduleName: context.path, transition: { name: 'fade', duration: 100, }, }); return true; } return false; } private _onLivesyncWithoutContext(): boolean { // Reset activity/window content when: // + Changes are not handled on View // + There is no ModuleContext if (Trace.isEnabled()) { Trace.write(`${this}._onLivesyncWithoutContext()`, Trace.categories.Livesync); } if (!this._currentEntry || !this._currentEntry.entry) { return false; } const currentEntry = this._currentEntry.entry; // If create returns the same page instance we can't recreate it. // Instead of navigation set activity content. // This could happen if current page was set in XML as a Page instance. if (currentEntry.create) { const page = currentEntry.create(); if (page === this.currentPage) { return false; } } // Replace current page with a default fade transition this.replacePage({ moduleName: currentEntry.moduleName, create: currentEntry.create, transition: { name: 'fade', duration: 100, }, }); return true; } public replacePage(entry: string | NavigationEntry): void { const currentBackstackEntry = this._currentEntry; if (typeof entry === 'string') { const contextModuleName = sanitizeModuleName(entry); entry = { moduleName: contextModuleName }; } const newPage = Builder.createViewFromEntry(entry) as Page; const newBackstackEntry: BackstackEntry = { entry: Object.assign({}, currentBackstackEntry.entry, entry), resolvedPage: newPage, navDepth: currentBackstackEntry.navDepth, fragmentTag: currentBackstackEntry.fragmentTag, frameId: currentBackstackEntry.frameId, }; const navigationContext: NavigationContext = { entry: newBackstackEntry, isBackNavigation: false, navigationType: NavigationType.replace, }; this._navigationQueue.push(navigationContext); this._processNextNavigationEntry(); } } // Mark as a Frame with an unique Symbol FrameBase.prototype[FRAME_SYMBOL] = true; export function getFrameById(id: string): FrameBase { console.log('getFrameById() is deprecated. Use Frame.getFrameById() instead.'); return FrameBase.getFrameById(id); } export function topmost(): FrameBase { console.log('topmost() is deprecated. Use Frame.topmost() instead.'); return FrameBase.topmost(); } export function goBack(): boolean { console.log('goBack() is deprecated. Use Frame.goBack() instead.'); return FrameBase.goBack(); } export function _stack(): Array { console.log('_stack() is deprecated. Use Frame._stack() instead.'); return FrameBase._stack(); } export const defaultPageProperty = new Property({ name: 'defaultPage', valueChanged: (frame: FrameBase, oldValue: string, newValue: string) => { frame.navigate({ moduleName: newValue }); }, }); defaultPageProperty.register(FrameBase); export const actionBarVisibilityProperty = new Property({ name: 'actionBarVisibility', defaultValue: 'auto', affectsLayout: __APPLE__ }); actionBarVisibilityProperty.register(FrameBase);