From 9d16c0d45d65245a68dee8944b43548a59988517 Mon Sep 17 00:00:00 2001 From: Dimitris - Rafail Katsampas Date: Wed, 2 Jul 2025 05:08:30 +0300 Subject: [PATCH 1/6] fix(ios): TabView does not emit navigatingFrom in iOS 17 --- .../src/ui/tab-view/tab-view-root-tests.ts | 17 +++++++------ packages/core/ui/page/index.ios.ts | 24 +++++++++++++++---- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/apps/automated/src/ui/tab-view/tab-view-root-tests.ts b/apps/automated/src/ui/tab-view/tab-view-root-tests.ts index 8d5129afcd..dbca488ce0 100644 --- a/apps/automated/src/ui/tab-view/tab-view-root-tests.ts +++ b/apps/automated/src/ui/tab-view/tab-view-root-tests.ts @@ -1,5 +1,5 @@ import * as TKUnit from '../../tk-unit'; -import { Application, Frame, NavigationEntry, Page, TabView, TabViewItem, isAndroid } from '@nativescript/core'; +import { Application, EventData, Frame, NavigationEntry, Page, TabView, TabViewItem, isAndroid } from '@nativescript/core'; function waitUntilNavigatedToMaxTimeout(pages: Page[], action: Function) { const maxTimeout = 8; @@ -140,19 +140,22 @@ export function test_offset_zero_should_raise_same_events() { resetActualEventsRaised(); waitUntilNavigatedToMaxTimeout([items[2].page], () => (tabView.selectedIndex = 2)); - const startEvents = ['Tab0 Frame0 Page0 unloaded', 'Tab0 Frame0 unloaded']; - if (__APPLE__) { - startEvents.push('Tab0 Frame0 Page0 navigatingFrom'); - } - const expectedEventsRaisedAfterSelectThirdTab = [startEvents, [], ['Tab2 Frame2 loaded', 'Tab2 Frame2 Page2 navigatingTo', 'Tab2 Frame2 Page2 loaded', 'Tab2 Frame2 Page2 navigatedTo']]; + const expectedEventsRaisedAfterSelectThirdTab = [['Tab0 Frame0 Page0 unloaded', 'Tab0 Frame0 unloaded', 'Tab0 Frame0 Page0 navigatingFrom'], [], ['Tab2 Frame2 loaded', 'Tab2 Frame2 Page2 navigatingTo', 'Tab2 Frame2 Page2 loaded', 'Tab2 Frame2 Page2 navigatedTo']]; + let isNavigatingFromPage2 = false; + const page2NavigatingFrom = (args: EventData) => { + args.object.off('navigatingFrom', page2NavigatingFrom); + isNavigatingFromPage2 = true; + }; TKUnit.assertDeepEqual(actualEventsRaised, expectedEventsRaisedAfterSelectThirdTab); resetActualEventsRaised(); + items[2].page.on('navigatingFrom', page2NavigatingFrom); waitUntilTabViewReady(items[0].page, () => (tabView.selectedIndex = 0)); + TKUnit.waitUntilReady(() => isNavigatingFromPage2); - const expectedEventsRaisedAfterReturnToFirstTab = [['Tab0 Frame0 Page0 loaded', 'Tab0 Frame0 loaded'], [], ['Tab2 Frame2 Page2 unloaded', 'Tab2 Frame2 unloaded']]; + const expectedEventsRaisedAfterReturnToFirstTab = [['Tab0 Frame0 Page0 loaded', 'Tab0 Frame0 loaded'], [], ['Tab2 Frame2 Page2 unloaded', 'Tab2 Frame2 unloaded', 'Tab2 Frame2 Page2 navigatingFrom']]; TKUnit.assertDeepEqual(actualEventsRaised, expectedEventsRaisedAfterReturnToFirstTab); } diff --git a/packages/core/ui/page/index.ios.ts b/packages/core/ui/page/index.ios.ts index a36bce5fb7..e9d2266cb5 100644 --- a/packages/core/ui/page/index.ios.ts +++ b/packages/core/ui/page/index.ios.ts @@ -254,6 +254,12 @@ class UIViewControllerImpl extends UIViewController { if (!willSelectViewController || willSelectViewController === tab.selectedViewController) { const isBack = isBackNavigationFrom(this, owner); owner.onNavigatingFrom(isBack); + } else { + // Before iOS 18, certain versions had this method called too early in the tab lifecycle, resulting in not emitting navigatingFrom event. + // To maintain implementation for those versions, store a flag and emit the event upon calling viewDidDisappear. + if (tab && tab.selectedViewController === this.navigationController) { + this['_isPendingNavigatingFrom'] = true; + } } } owner.updateWithWillDisappear(animated); @@ -263,14 +269,24 @@ class UIViewControllerImpl extends UIViewController { public viewDidDisappear(animated: boolean): void { super.viewDidDisappear(animated); - const page = this._owner?.deref(); + const owner = this._owner?.deref(); + // Exit if no page or page is hiding because it shows another page modally. - if (!page || page.modal || page._presentedViewController) { + if (!owner || owner.modal || owner._presentedViewController) { return; } + // Forward navigation does not remove page from frame so we raise unloaded manually. - if (page.isLoaded) { - page.callUnloaded(); + if (owner.isLoaded) { + owner.callUnloaded(); + } + + // Emit the navigatingFrom event if it wasn't emitted during viewWillDisappear call + if (this['_isPendingNavigatingFrom']) { + delete this['_isPendingNavigatingFrom']; + + const isBack = isBackNavigationFrom(this, owner); + owner.onNavigatingFrom(isBack); } } From 4bc4b93e4d54bf5573b3f8afeabda27cace5d16e Mon Sep 17 00:00:00 2001 From: Dimitris - Rafail Katsampas Date: Sat, 5 Jul 2025 15:03:47 +0300 Subject: [PATCH 2/6] fix: Separate programmatic from use initiated navigation handling --- .../src/ui/tab-view/tab-view-root-tests.ts | 12 +--- packages/core/ui/frame/frame-common.ts | 7 ++- packages/core/ui/frame/frame-interfaces.ts | 5 +- packages/core/ui/frame/index.d.ts | 3 +- packages/core/ui/frame/index.ios.ts | 24 ++++---- packages/core/ui/page/index.ios.ts | 60 +++++++------------ packages/core/ui/tab-view/index.ios.ts | 9 +-- 7 files changed, 44 insertions(+), 76 deletions(-) diff --git a/apps/automated/src/ui/tab-view/tab-view-root-tests.ts b/apps/automated/src/ui/tab-view/tab-view-root-tests.ts index dbca488ce0..faf808f41b 100644 --- a/apps/automated/src/ui/tab-view/tab-view-root-tests.ts +++ b/apps/automated/src/ui/tab-view/tab-view-root-tests.ts @@ -140,22 +140,14 @@ export function test_offset_zero_should_raise_same_events() { resetActualEventsRaised(); waitUntilNavigatedToMaxTimeout([items[2].page], () => (tabView.selectedIndex = 2)); - const expectedEventsRaisedAfterSelectThirdTab = [['Tab0 Frame0 Page0 unloaded', 'Tab0 Frame0 unloaded', 'Tab0 Frame0 Page0 navigatingFrom'], [], ['Tab2 Frame2 loaded', 'Tab2 Frame2 Page2 navigatingTo', 'Tab2 Frame2 Page2 loaded', 'Tab2 Frame2 Page2 navigatedTo']]; - let isNavigatingFromPage2 = false; - const page2NavigatingFrom = (args: EventData) => { - args.object.off('navigatingFrom', page2NavigatingFrom); - isNavigatingFromPage2 = true; - }; + const expectedEventsRaisedAfterSelectThirdTab = [['Tab0 Frame0 Page0 unloaded', 'Tab0 Frame0 unloaded'], [], ['Tab2 Frame2 loaded', 'Tab2 Frame2 Page2 navigatingTo', 'Tab2 Frame2 Page2 loaded', 'Tab2 Frame2 Page2 navigatedTo']]; TKUnit.assertDeepEqual(actualEventsRaised, expectedEventsRaisedAfterSelectThirdTab); resetActualEventsRaised(); - - items[2].page.on('navigatingFrom', page2NavigatingFrom); waitUntilTabViewReady(items[0].page, () => (tabView.selectedIndex = 0)); - TKUnit.waitUntilReady(() => isNavigatingFromPage2); - const expectedEventsRaisedAfterReturnToFirstTab = [['Tab0 Frame0 Page0 loaded', 'Tab0 Frame0 loaded'], [], ['Tab2 Frame2 Page2 unloaded', 'Tab2 Frame2 unloaded', 'Tab2 Frame2 Page2 navigatingFrom']]; + const expectedEventsRaisedAfterReturnToFirstTab = [['Tab0 Frame0 Page0 loaded', 'Tab0 Frame0 loaded'], [], ['Tab2 Frame2 Page2 unloaded', 'Tab2 Frame2 unloaded']]; TKUnit.assertDeepEqual(actualEventsRaised, expectedEventsRaisedAfterReturnToFirstTab); } diff --git a/packages/core/ui/frame/frame-common.ts b/packages/core/ui/frame/frame-common.ts index 3a42c2979d..101b78da96 100644 --- a/packages/core/ui/frame/frame-common.ts +++ b/packages/core/ui/frame/frame-common.ts @@ -192,7 +192,6 @@ export class FrameBase extends CustomLayoutView { const navigationContext: NavigationContext = { entry: backstackEntry, - isBackNavigation: true, navigationType: NavigationType.back, }; @@ -244,7 +243,6 @@ export class FrameBase extends CustomLayoutView { const navigationContext: NavigationContext = { entry: backstackEntry, - isBackNavigation: false, navigationType: NavigationType.forward, }; @@ -481,6 +479,10 @@ export class FrameBase extends CustomLayoutView { } backstackEntry.resolvedPage.onNavigatingTo(backstackEntry.entry.context, isBack, backstackEntry.entry.bindingContext); + this._notifyFrameNavigatingTo(backstackEntry, isBack); + } + + public _notifyFrameNavigatingTo(backstackEntry: BackstackEntry, isBack: boolean): void { this.notify({ eventName: FrameBase.navigatingToEvent, object: this, @@ -760,7 +762,6 @@ export class FrameBase extends CustomLayoutView { const navigationContext: NavigationContext = { entry: newBackstackEntry, - isBackNavigation: false, navigationType: NavigationType.replace, }; diff --git a/packages/core/ui/frame/frame-interfaces.ts b/packages/core/ui/frame/frame-interfaces.ts index 522fe6c369..038d354314 100644 --- a/packages/core/ui/frame/frame-interfaces.ts +++ b/packages/core/ui/frame/frame-interfaces.ts @@ -8,6 +8,7 @@ export enum NavigationType { back, forward, replace, + user, } export interface TransitionState { @@ -36,9 +37,7 @@ export interface NavigationEntry extends ViewEntry { } export interface NavigationContext { - entry: BackstackEntry; - // TODO: remove isBackNavigation for NativeScript 7.0 - isBackNavigation: boolean; + entry?: BackstackEntry; navigationType: NavigationType; } diff --git a/packages/core/ui/frame/index.d.ts b/packages/core/ui/frame/index.d.ts index 73e5ca2aa7..e465915420 100644 --- a/packages/core/ui/frame/index.d.ts +++ b/packages/core/ui/frame/index.d.ts @@ -404,8 +404,7 @@ export interface NavigationEntry extends ViewEntry { * Represents a context passed to navigation methods. */ export interface NavigationContext { - entry: BackstackEntry; - isBackNavigation: boolean; + entry?: BackstackEntry; navigationType: NavigationType; } diff --git a/packages/core/ui/frame/index.ios.ts b/packages/core/ui/frame/index.ios.ts index 33787b214c..61d5d2f1f1 100644 --- a/packages/core/ui/frame/index.ios.ts +++ b/packages/core/ui/frame/index.ios.ts @@ -347,18 +347,18 @@ export class Frame extends FrameBase { // } - public _onNavigatingTo(backstackEntry: BackstackEntry, isBack: boolean) { - // for now to not break iOS events chain (calling navigation events from controller delegates) - // we dont call super(which would also trigger events) but only notify the frame of the navigation - // though it means events are not triggered at the same time (lifecycle) on iOS / Android - this.notify({ - eventName: Page.navigatingToEvent, - object: this, - isBack, - entry: backstackEntry, - fromEntry: this._currentEntry, - }); - } + // public _onNavigatingTo(backstackEntry: BackstackEntry, isBack: boolean) { + // // for now to not break iOS events chain (calling navigation events from controller delegates) + // // we dont call super(which would also trigger events) but only notify the frame of the navigation + // // though it means events are not triggered at the same time (lifecycle) on iOS / Android + // this.notify({ + // eventName: Page.navigatingToEvent, + // object: this, + // isBack, + // entry: backstackEntry, + // fromEntry: this._currentEntry, + // }); + // } } const transitionDelegates = new Array(); diff --git a/packages/core/ui/page/index.ios.ts b/packages/core/ui/page/index.ios.ts index e9d2266cb5..6a865e5d3d 100644 --- a/packages/core/ui/page/index.ios.ts +++ b/packages/core/ui/page/index.ios.ts @@ -18,27 +18,19 @@ const DELEGATE = '_delegate'; const TRANSITION = '_transition'; const NON_ANIMATED_TRANSITION = 'non-animated'; -function isBackNavigationTo(page: Page, entry: BackstackEntry): boolean { +function isBackNavigationTo(controller: UIViewControllerImpl, page: Page): boolean { const frame = page.frame; + if (!frame) { return false; } - // if executing context is null here this most probably means back navigation through iOS back button - const navigationContext = frame._executingContext || { - navigationType: NavigationType.back, - }; - const isReplace = navigationContext.navigationType === NavigationType.replace; - if (isReplace) { + if (!frame._executingContext) { return false; } - if (frame.navigationQueueIsEmpty()) { - return true; - } - - const queueContext = frame.getNavigationQueueContextByEntry(entry); - return queueContext && queueContext.navigationType === NavigationType.back; + // Make sure we are navigating to a controller that is already in the navigation stack + return !controller.movingToParentViewController; } function isBackNavigationFrom(controller: UIViewControllerImpl, page: Page): boolean { @@ -118,9 +110,13 @@ class UIViewControllerImpl extends UIViewController { const newEntry: BackstackEntry = this[ENTRY]; // Don't raise event if currentPage was showing modal page. - if (!owner._presentedViewController && newEntry && (!frame || frame.currentPage !== owner)) { - const isBack = isBackNavigationTo(owner, newEntry); + if (!owner._presentedViewController && newEntry && (!frame || (frame.currentPage !== owner && (!frame._executingContext || frame._executingContext.navigationType === NavigationType.user)))) { + const isBack = isBackNavigationTo(this, owner); owner.onNavigatingTo(newEntry.entry.context, isBack, newEntry.entry.bindingContext); + + if (frame) { + frame._notifyFrameNavigatingTo(newEntry, isBack); + } } if (frame) { @@ -131,7 +127,7 @@ class UIViewControllerImpl extends UIViewController { } else { if (!owner.parent) { if (!frame._styleScope) { - // Make sure page will have styleScope even if frame don't. + // Make sure page will have styleScope even if frame doesn't. owner._updateStyleScope(); } @@ -246,21 +242,15 @@ class UIViewControllerImpl extends UIViewController { const frame = owner.frame; // Skip navigation events if we are hiding because we are about to show a modal page, - // or because we are closing a modal page, - // or because we are in tab and another controller is selected. - const tab = this.tabBarController; - if (owner.onNavigatingFrom && !owner._presentedViewController && frame && (!this.presentingViewController || frame.backStack.length > 0) && frame.currentPage === owner) { - const willSelectViewController = tab && (tab)._willSelectViewController; - if (!willSelectViewController || willSelectViewController === tab.selectedViewController) { - const isBack = isBackNavigationFrom(this, owner); - owner.onNavigatingFrom(isBack); - } else { - // Before iOS 18, certain versions had this method called too early in the tab lifecycle, resulting in not emitting navigatingFrom event. - // To maintain implementation for those versions, store a flag and emit the event upon calling viewDidDisappear. - if (tab && tab.selectedViewController === this.navigationController) { - this['_isPendingNavigatingFrom'] = true; - } - } + // or because we are closing a modal page. + if (owner.onNavigatingFrom && this.movingFromParentViewController && !owner._presentedViewController && frame && !frame._executingContext && (!this.presentingViewController || frame.backStack.length > 0) && frame.currentPage === owner) { + const isBack = isBackNavigationFrom(this, owner); + + // Assign a truthy value to executing context since it's used in certain conditions + frame._executingContext = { + navigationType: NavigationType.user, + }; + owner.onNavigatingFrom(isBack); } owner.updateWithWillDisappear(animated); } @@ -280,14 +270,6 @@ class UIViewControllerImpl extends UIViewController { if (owner.isLoaded) { owner.callUnloaded(); } - - // Emit the navigatingFrom event if it wasn't emitted during viewWillDisappear call - if (this['_isPendingNavigatingFrom']) { - delete this['_isPendingNavigatingFrom']; - - const isBack = isBackNavigationFrom(this, owner); - owner.onNavigatingFrom(isBack); - } } public viewWillLayoutSubviews(): void { diff --git a/packages/core/ui/tab-view/index.ios.ts b/packages/core/ui/tab-view/index.ios.ts index e82a97fbc3..5da4665dd6 100644 --- a/packages/core/ui/tab-view/index.ios.ts +++ b/packages/core/ui/tab-view/index.ios.ts @@ -111,12 +111,10 @@ class UITabBarControllerDelegateImpl extends NSObject implements UITabBarControl owner._handleTwoNavigationBars(backToMoreWillBeVisible); } - if ((tabBarController).selectedViewController === viewController) { + if (tabBarController.selectedViewController === viewController) { return false; } - (tabBarController)._willSelectViewController = viewController; - return true; } @@ -129,8 +127,6 @@ class UITabBarControllerDelegateImpl extends NSObject implements UITabBarControl if (owner) { owner._onViewControllerShown(viewController); } - - (tabBarController)._willSelectViewController = undefined; } } @@ -535,7 +531,7 @@ export class TabView extends TabViewBase { private updateBarItemAppearance(tabBar: UITabBar, states: TabStates) { const appearance = this._getAppearance(tabBar); const itemAppearances = ['stackedLayoutAppearance', 'inlineLayoutAppearance', 'compactInlineLayoutAppearance']; - for (let itemAppearance of itemAppearances) { + for (const itemAppearance of itemAppearances) { appearance[itemAppearance].normal.titleTextAttributes = states.normalState; appearance[itemAppearance].selected.titleTextAttributes = states.selectedState; } @@ -564,7 +560,6 @@ export class TabView extends TabViewBase { } if (value > -1) { - (this._ios)._willSelectViewController = this._ios.viewControllers[value]; this._ios.selectedIndex = value; } } From fe4153a9b343b46bbf021a33a1feb14ddb7c4e46 Mon Sep 17 00:00:00 2001 From: Dimitris - Rafail Katsampas Date: Sat, 5 Jul 2025 15:21:14 +0300 Subject: [PATCH 3/6] chore: Cleanup and improvements --- .../src/ui/tab-view/tab-view-root-tests.ts | 2 +- packages/core/ui/frame/frame-common.ts | 3 +++ packages/core/ui/frame/frame-interfaces.ts | 4 ++++ packages/core/ui/frame/index.d.ts | 4 ++++ packages/core/ui/frame/index.ios.ts | 13 ------------- packages/core/ui/page/index.ios.ts | 3 ++- 6 files changed, 14 insertions(+), 15 deletions(-) diff --git a/apps/automated/src/ui/tab-view/tab-view-root-tests.ts b/apps/automated/src/ui/tab-view/tab-view-root-tests.ts index faf808f41b..62d0bc9363 100644 --- a/apps/automated/src/ui/tab-view/tab-view-root-tests.ts +++ b/apps/automated/src/ui/tab-view/tab-view-root-tests.ts @@ -1,5 +1,5 @@ import * as TKUnit from '../../tk-unit'; -import { Application, EventData, Frame, NavigationEntry, Page, TabView, TabViewItem, isAndroid } from '@nativescript/core'; +import { Application, Frame, NavigationEntry, Page, TabView, TabViewItem, isAndroid } from '@nativescript/core'; function waitUntilNavigatedToMaxTimeout(pages: Page[], action: Function) { const maxTimeout = 8; diff --git a/packages/core/ui/frame/frame-common.ts b/packages/core/ui/frame/frame-common.ts index 101b78da96..94f6b4888b 100644 --- a/packages/core/ui/frame/frame-common.ts +++ b/packages/core/ui/frame/frame-common.ts @@ -192,6 +192,7 @@ export class FrameBase extends CustomLayoutView { const navigationContext: NavigationContext = { entry: backstackEntry, + isBackNavigation: true, navigationType: NavigationType.back, }; @@ -243,6 +244,7 @@ export class FrameBase extends CustomLayoutView { const navigationContext: NavigationContext = { entry: backstackEntry, + isBackNavigation: true, navigationType: NavigationType.forward, }; @@ -762,6 +764,7 @@ export class FrameBase extends CustomLayoutView { const navigationContext: NavigationContext = { entry: newBackstackEntry, + isBackNavigation: false, navigationType: NavigationType.replace, }; diff --git a/packages/core/ui/frame/frame-interfaces.ts b/packages/core/ui/frame/frame-interfaces.ts index 038d354314..d46b9ee8c3 100644 --- a/packages/core/ui/frame/frame-interfaces.ts +++ b/packages/core/ui/frame/frame-interfaces.ts @@ -38,6 +38,10 @@ export interface NavigationEntry extends ViewEntry { export interface NavigationContext { entry?: BackstackEntry; + /** + * @deprecated Use navigationType instead. + */ + isBackNavigation: boolean; navigationType: NavigationType; } diff --git a/packages/core/ui/frame/index.d.ts b/packages/core/ui/frame/index.d.ts index e465915420..c24258ca5f 100644 --- a/packages/core/ui/frame/index.d.ts +++ b/packages/core/ui/frame/index.d.ts @@ -405,6 +405,10 @@ export interface NavigationEntry extends ViewEntry { */ export interface NavigationContext { entry?: BackstackEntry; + /** + * @deprecated Use navigationType instead. + */ + isBackNavigation: boolean; navigationType: NavigationType; } diff --git a/packages/core/ui/frame/index.ios.ts b/packages/core/ui/frame/index.ios.ts index 61d5d2f1f1..dbfe3f1a88 100644 --- a/packages/core/ui/frame/index.ios.ts +++ b/packages/core/ui/frame/index.ios.ts @@ -346,19 +346,6 @@ export class Frame extends FrameBase { public _setNativeViewFrame(nativeView: UIView, frame: CGRect) { // } - - // public _onNavigatingTo(backstackEntry: BackstackEntry, isBack: boolean) { - // // for now to not break iOS events chain (calling navigation events from controller delegates) - // // we dont call super(which would also trigger events) but only notify the frame of the navigation - // // though it means events are not triggered at the same time (lifecycle) on iOS / Android - // this.notify({ - // eventName: Page.navigatingToEvent, - // object: this, - // isBack, - // entry: backstackEntry, - // fromEntry: this._currentEntry, - // }); - // } } const transitionDelegates = new Array(); diff --git a/packages/core/ui/page/index.ios.ts b/packages/core/ui/page/index.ios.ts index 6a865e5d3d..159777c73e 100644 --- a/packages/core/ui/page/index.ios.ts +++ b/packages/core/ui/page/index.ios.ts @@ -246,8 +246,9 @@ class UIViewControllerImpl extends UIViewController { if (owner.onNavigatingFrom && this.movingFromParentViewController && !owner._presentedViewController && frame && !frame._executingContext && (!this.presentingViewController || frame.backStack.length > 0) && frame.currentPage === owner) { const isBack = isBackNavigationFrom(this, owner); - // Assign a truthy value to executing context since it's used in certain conditions + // Create an executing context as frame avoids some actions when it's defined frame._executingContext = { + isBackNavigation: true, // This property is no longer used so it doesn't really matter what it's set to navigationType: NavigationType.user, }; owner.onNavigatingFrom(isBack); From a75092a76540650ea4ccc21fe78d9e97260e63b3 Mon Sep 17 00:00:00 2001 From: Dimitris - Rafail Katsampas Date: Sat, 5 Jul 2025 15:22:11 +0300 Subject: [PATCH 4/6] fix: Incorrect isBackNavigation flag --- packages/core/ui/frame/frame-common.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/ui/frame/frame-common.ts b/packages/core/ui/frame/frame-common.ts index 94f6b4888b..d8db393078 100644 --- a/packages/core/ui/frame/frame-common.ts +++ b/packages/core/ui/frame/frame-common.ts @@ -244,7 +244,7 @@ export class FrameBase extends CustomLayoutView { const navigationContext: NavigationContext = { entry: backstackEntry, - isBackNavigation: true, + isBackNavigation: false, navigationType: NavigationType.forward, }; From 3a3a36ab47f18fec4ae2477bcb258833403f1a8b Mon Sep 17 00:00:00 2001 From: Dimitris - Rafail Katsampas Date: Sat, 5 Jul 2025 16:21:00 +0300 Subject: [PATCH 5/6] fix: Method canGoBack returned true when it shouldn't --- packages/core/ui/frame/frame-common.ts | 3 ++ packages/core/ui/frame/frame-interfaces.ts | 1 + packages/core/ui/frame/index.d.ts | 1 + packages/core/ui/page/index.ios.ts | 35 ++++++---------------- 4 files changed, 14 insertions(+), 26 deletions(-) diff --git a/packages/core/ui/frame/frame-common.ts b/packages/core/ui/frame/frame-common.ts index d8db393078..9a4cd3d3ca 100644 --- a/packages/core/ui/frame/frame-common.ts +++ b/packages/core/ui/frame/frame-common.ts @@ -193,6 +193,7 @@ export class FrameBase extends CustomLayoutView { const navigationContext: NavigationContext = { entry: backstackEntry, isBackNavigation: true, + isUserInitiated: false, navigationType: NavigationType.back, }; @@ -245,6 +246,7 @@ export class FrameBase extends CustomLayoutView { const navigationContext: NavigationContext = { entry: backstackEntry, isBackNavigation: false, + isUserInitiated: false, navigationType: NavigationType.forward, }; @@ -765,6 +767,7 @@ export class FrameBase extends CustomLayoutView { const navigationContext: NavigationContext = { entry: newBackstackEntry, isBackNavigation: false, + isUserInitiated: false, navigationType: NavigationType.replace, }; diff --git a/packages/core/ui/frame/frame-interfaces.ts b/packages/core/ui/frame/frame-interfaces.ts index d46b9ee8c3..1250edf24b 100644 --- a/packages/core/ui/frame/frame-interfaces.ts +++ b/packages/core/ui/frame/frame-interfaces.ts @@ -42,6 +42,7 @@ export interface NavigationContext { * @deprecated Use navigationType instead. */ isBackNavigation: boolean; + isUserInitiated: boolean; navigationType: NavigationType; } diff --git a/packages/core/ui/frame/index.d.ts b/packages/core/ui/frame/index.d.ts index c24258ca5f..d6517a4fc4 100644 --- a/packages/core/ui/frame/index.d.ts +++ b/packages/core/ui/frame/index.d.ts @@ -409,6 +409,7 @@ export interface NavigationContext { * @deprecated Use navigationType instead. */ isBackNavigation: boolean; + isUserInitiated: boolean; navigationType: NavigationType; } diff --git a/packages/core/ui/page/index.ios.ts b/packages/core/ui/page/index.ios.ts index 159777c73e..21835aaa27 100644 --- a/packages/core/ui/page/index.ios.ts +++ b/packages/core/ui/page/index.ios.ts @@ -18,21 +18,6 @@ const DELEGATE = '_delegate'; const TRANSITION = '_transition'; const NON_ANIMATED_TRANSITION = 'non-animated'; -function isBackNavigationTo(controller: UIViewControllerImpl, page: Page): boolean { - const frame = page.frame; - - if (!frame) { - return false; - } - - if (!frame._executingContext) { - return false; - } - - // Make sure we are navigating to a controller that is already in the navigation stack - return !controller.movingToParentViewController; -} - function isBackNavigationFrom(controller: UIViewControllerImpl, page: Page): boolean { if (!page.frame) { return false; @@ -43,11 +28,7 @@ function isBackNavigationFrom(controller: UIViewControllerImpl, page: Page): boo return false; } - if (controller.navigationController && controller.navigationController.viewControllers.containsObject(controller)) { - return false; - } - - return true; + return controller.movingFromParentViewController; } @NativeClass @@ -110,10 +91,10 @@ class UIViewControllerImpl extends UIViewController { const newEntry: BackstackEntry = this[ENTRY]; // Don't raise event if currentPage was showing modal page. - if (!owner._presentedViewController && newEntry && (!frame || (frame.currentPage !== owner && (!frame._executingContext || frame._executingContext.navigationType === NavigationType.user)))) { - const isBack = isBackNavigationTo(this, owner); - owner.onNavigatingTo(newEntry.entry.context, isBack, newEntry.entry.bindingContext); + if (!owner._presentedViewController && newEntry && (!frame || (frame.currentPage !== owner && (!frame._executingContext || frame._executingContext.isUserInitiated)))) { + const isBack = frame._executingContext && frame._executingContext.navigationType === NavigationType.back; + owner.onNavigatingTo(newEntry.entry.context, isBack, newEntry.entry.bindingContext); if (frame) { frame._notifyFrameNavigatingTo(newEntry, isBack); } @@ -242,14 +223,16 @@ class UIViewControllerImpl extends UIViewController { const frame = owner.frame; // Skip navigation events if we are hiding because we are about to show a modal page, + // or because we are not navigating back // or because we are closing a modal page. if (owner.onNavigatingFrom && this.movingFromParentViewController && !owner._presentedViewController && frame && !frame._executingContext && (!this.presentingViewController || frame.backStack.length > 0) && frame.currentPage === owner) { const isBack = isBackNavigationFrom(this, owner); - // Create an executing context as frame avoids some actions when it's defined + // Create an executing context will also make navigation more secure as frame avoids some actions when it's defined frame._executingContext = { - isBackNavigation: true, // This property is no longer used so it doesn't really matter what it's set to - navigationType: NavigationType.user, + isBackNavigation: isBack, + isUserInitiated: true, + navigationType: isBack ? NavigationType.back : NavigationType.forward, }; owner.onNavigatingFrom(isBack); } From 378e39b4a0d21ad96df5ec5eec524b46ecdec509 Mon Sep 17 00:00:00 2001 From: Dimitris - Rafail Katsampas Date: Wed, 9 Jul 2025 23:44:31 +0300 Subject: [PATCH 6/6] feat: Better handling for navigation events --- packages/core/ui/frame/frame-common.ts | 13 +- packages/core/ui/frame/frame-interfaces.ts | 2 - packages/core/ui/frame/frame-stack.ts | 4 + packages/core/ui/frame/index.d.ts | 5 +- packages/core/ui/frame/index.ios.ts | 53 ++++---- packages/core/ui/gestures/index.ios.ts | 2 +- packages/core/ui/page/index.ios.ts | 137 +++++++++------------ 7 files changed, 102 insertions(+), 114 deletions(-) diff --git a/packages/core/ui/frame/frame-common.ts b/packages/core/ui/frame/frame-common.ts index 9a4cd3d3ca..f8f8380cb5 100644 --- a/packages/core/ui/frame/frame-common.ts +++ b/packages/core/ui/frame/frame-common.ts @@ -4,7 +4,7 @@ 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 { frameStack, topmost as frameStackTopmost, _pushInFrameStack, _popFromFrameStack, _removeFromFrameStack, _isFrameStackEmpty } from './frame-stack'; import { viewMatchesModuleContext } from '../core/view/view-common'; import { getAncestor } from '../core/view-base'; import { Builder } from '../builder'; @@ -193,7 +193,6 @@ export class FrameBase extends CustomLayoutView { const navigationContext: NavigationContext = { entry: backstackEntry, isBackNavigation: true, - isUserInitiated: false, navigationType: NavigationType.back, }; @@ -246,7 +245,6 @@ export class FrameBase extends CustomLayoutView { const navigationContext: NavigationContext = { entry: backstackEntry, isBackNavigation: false, - isUserInitiated: false, navigationType: NavigationType.forward, }; @@ -483,10 +481,6 @@ export class FrameBase extends CustomLayoutView { } backstackEntry.resolvedPage.onNavigatingTo(backstackEntry.entry.context, isBack, backstackEntry.entry.bindingContext); - this._notifyFrameNavigatingTo(backstackEntry, isBack); - } - - public _notifyFrameNavigatingTo(backstackEntry: BackstackEntry, isBack: boolean): void { this.notify({ eventName: FrameBase.navigatingToEvent, object: this, @@ -548,6 +542,10 @@ export class FrameBase extends CustomLayoutView { } } + public _isFrameStackEmpty() { + return _isFrameStackEmpty(); + } + public _pushInFrameStack() { _pushInFrameStack(this); } @@ -767,7 +765,6 @@ export class FrameBase extends CustomLayoutView { const navigationContext: NavigationContext = { entry: newBackstackEntry, isBackNavigation: false, - isUserInitiated: false, navigationType: NavigationType.replace, }; diff --git a/packages/core/ui/frame/frame-interfaces.ts b/packages/core/ui/frame/frame-interfaces.ts index 1250edf24b..5294d222fc 100644 --- a/packages/core/ui/frame/frame-interfaces.ts +++ b/packages/core/ui/frame/frame-interfaces.ts @@ -8,7 +8,6 @@ export enum NavigationType { back, forward, replace, - user, } export interface TransitionState { @@ -42,7 +41,6 @@ export interface NavigationContext { * @deprecated Use navigationType instead. */ isBackNavigation: boolean; - isUserInitiated: boolean; navigationType: NavigationType; } diff --git a/packages/core/ui/frame/frame-stack.ts b/packages/core/ui/frame/frame-stack.ts index c3f59d57fa..47d3150d5f 100644 --- a/packages/core/ui/frame/frame-stack.ts +++ b/packages/core/ui/frame/frame-stack.ts @@ -11,6 +11,10 @@ export function topmost(): FrameBase { return undefined; } +export function _isFrameStackEmpty(): boolean { + return frameStack.length === 0; +} + export function _pushInFrameStack(frame: FrameBase): void { if (frame._isInFrameStack && frameStack[frameStack.length - 1] === frame) { return; diff --git a/packages/core/ui/frame/index.d.ts b/packages/core/ui/frame/index.d.ts index d6517a4fc4..1ed3697079 100644 --- a/packages/core/ui/frame/index.d.ts +++ b/packages/core/ui/frame/index.d.ts @@ -258,6 +258,10 @@ export class Frame extends FrameBase { * @private */ _updateBackstack(entry: BackstackEntry, navigationType: NavigationType): void; + /** + * @private + */ + _isFrameStackEmpty(): boolean; /** * @private */ @@ -409,7 +413,6 @@ export interface NavigationContext { * @deprecated Use navigationType instead. */ isBackNavigation: boolean; - isUserInitiated: boolean; navigationType: NavigationType; } diff --git a/packages/core/ui/frame/index.ios.ts b/packages/core/ui/frame/index.ios.ts index dbfe3f1a88..696bd53221 100644 --- a/packages/core/ui/frame/index.ios.ts +++ b/packages/core/ui/frame/index.ios.ts @@ -7,7 +7,6 @@ import { IOSHelper } from '../core/view/view-helper'; import { profile } from '../../profiling'; import { CORE_ANIMATION_DEFAULTS, ios as iOSUtils, layout } from '../../utils'; import { Trace } from '../../trace'; -import type { PageTransition } from '../transition/page-transition'; import { SlideTransition } from '../transition/slide-transition'; import { FadeTransition } from '../transition/fade-transition'; import { SharedTransition } from '../transition/shared-transition'; @@ -22,11 +21,11 @@ const NAV_DEPTH = '_navDepth'; const TRANSITION = '_transition'; const NON_ANIMATED_TRANSITION = 'non-animated'; -let navDepth = -1; +let navDepth: number = -1; +let navControllerDelegate: UINavigationControllerDelegate = null; export class Frame extends FrameBase { viewController: UINavigationControllerImpl; - _animatedDelegate: UINavigationControllerDelegate; public _ios: iOSFrame; iosNavigationBarClass: typeof NSObject; iosToolbarClass: typeof NSObject; @@ -38,6 +37,11 @@ export class Frame extends FrameBase { } createNativeView() { + // Push frame back in frame stack since it was removed in disposeNativeView() method. + if (this._currentEntry) { + this._pushInFrameStack(); + } + return this.viewController.view; } @@ -61,7 +65,12 @@ export class Frame extends FrameBase { this._removeFromFrameStack(); this.viewController = null; - this._animatedDelegate = null; + + // This was the last frame so we can get rid of the controller delegate reference + if (this._isFrameStackEmpty()) { + navControllerDelegate = null; + } + if (this._ios) { this._ios.controller = null; this._ios = null; @@ -120,11 +129,12 @@ export class Frame extends FrameBase { const nativeTransition = _getNativeTransition(navigationTransition, true); if (!nativeTransition && navigationTransition) { - if (!this._animatedDelegate) { - this._animatedDelegate = UINavigationControllerAnimatedDelegate.initWithOwner(new WeakRef(this)); + if (!navControllerDelegate) { + navControllerDelegate = UINavigationControllerAnimatedDelegate.new(); } - this._ios.controller.delegate = this._animatedDelegate; - viewController[DELEGATE] = this._animatedDelegate; + + this._ios.controller.delegate = navControllerDelegate; + viewController[DELEGATE] = navControllerDelegate; if (navigationTransition.instance) { this.transitionId = navigationTransition.instance.id; const transitionState = SharedTransition.getState(this.transitionId); @@ -400,14 +410,6 @@ class TransitionDelegate extends NSObject { @NativeClass class UINavigationControllerAnimatedDelegate extends NSObject implements UINavigationControllerDelegate { public static ObjCProtocols = [UINavigationControllerDelegate]; - owner: WeakRef; - transition: PageTransition; - - static initWithOwner(owner: WeakRef) { - const delegate = UINavigationControllerAnimatedDelegate.new(); - delegate.owner = owner; - return delegate; - } navigationControllerAnimationControllerForOperationFromViewControllerToViewController(navigationController: UINavigationController, operation: number, fromVC: UIViewController, toVC: UIViewController): UIViewControllerAnimatedTransitioning { let viewController: UIViewController; @@ -432,29 +434,30 @@ class UINavigationControllerAnimatedDelegate extends NSObject implements UINavig if (Trace.isEnabled()) { Trace.write(`UINavigationControllerImpl.navigationControllerAnimationControllerForOperationFromViewControllerToViewController(${operation}, ${fromVC}, ${toVC}), transition: ${JSON.stringify(navigationTransition)}`, Trace.categories.NativeLifecycle); } - this.transition = navigationTransition.instance; - if (!this.transition) { + let transition = navigationTransition.instance; + + if (!transition) { if (navigationTransition.name) { const curve = _getNativeCurve(navigationTransition); const name = navigationTransition.name.toLowerCase(); if (name.indexOf('slide') === 0) { - const direction = name.substring('slide'.length) || 'left'; //Extract the direction from the string - this.transition = new SlideTransition(direction, navigationTransition.duration, curve); + const direction = name.substring('slide'.length) || 'left'; // Extract the direction from the string + transition = new SlideTransition(direction, navigationTransition.duration, curve); } else if (name === 'fade') { - this.transition = new FadeTransition(navigationTransition.duration, curve); + transition = new FadeTransition(navigationTransition.duration, curve); } } } - if (this.transition?.iosNavigatedController) { - return this.transition.iosNavigatedController(navigationController, operation, fromVC, toVC); + if (transition?.iosNavigatedController) { + return transition.iosNavigatedController(navigationController, operation, fromVC, toVC); } return null; } - navigationControllerInteractionControllerForAnimationController(navigationController: UINavigationController, animationController: UIViewControllerAnimatedTransitioning): UIViewControllerInteractiveTransitioning { - const owner = this.owner?.deref(); + navigationControllerInteractionControllerForAnimationController(navigationController: UINavigationControllerImpl, animationController: UIViewControllerAnimatedTransitioning): UIViewControllerInteractiveTransitioning { + const owner = navigationController.owner; if (owner) { const state = SharedTransition.getState(owner.transitionId); if (state?.instance?.iosInteractionDismiss) { diff --git a/packages/core/ui/gestures/index.ios.ts b/packages/core/ui/gestures/index.ios.ts index 93696c026d..879d44a2ad 100644 --- a/packages/core/ui/gestures/index.ios.ts +++ b/packages/core/ui/gestures/index.ios.ts @@ -37,7 +37,7 @@ class UIGestureRecognizerDelegateImpl extends NSObject implements UIGestureRecog return false; } } -const recognizerDelegateInstance: UIGestureRecognizerDelegateImpl = UIGestureRecognizerDelegateImpl.new(); +const recognizerDelegateInstance = UIGestureRecognizerDelegateImpl.new(); @NativeClass class UIGestureRecognizerImpl extends NSObject { diff --git a/packages/core/ui/page/index.ios.ts b/packages/core/ui/page/index.ios.ts index 21835aaa27..6a2de3a59b 100644 --- a/packages/core/ui/page/index.ios.ts +++ b/packages/core/ui/page/index.ios.ts @@ -18,19 +18,6 @@ const DELEGATE = '_delegate'; const TRANSITION = '_transition'; const NON_ANIMATED_TRANSITION = 'non-animated'; -function isBackNavigationFrom(controller: UIViewControllerImpl, page: Page): boolean { - if (!page.frame) { - return false; - } - - // Controller is cleared or backstack skipped - if (controller.isBackstackCleared || controller.isBackstackSkipped) { - return false; - } - - return controller.movingFromParentViewController; -} - @NativeClass class UIViewControllerImpl extends UIViewController { // TODO: a11y @@ -88,19 +75,23 @@ class UIViewControllerImpl extends UIViewController { } const frame: Frame = this.navigationController ? (this.navigationController).owner : null; - const newEntry: BackstackEntry = this[ENTRY]; - - // Don't raise event if currentPage was showing modal page. - if (!owner._presentedViewController && newEntry && (!frame || (frame.currentPage !== owner && (!frame._executingContext || frame._executingContext.isUserInitiated)))) { - const isBack = frame._executingContext && frame._executingContext.navigationType === NavigationType.back; - owner.onNavigatingTo(newEntry.entry.context, isBack, newEntry.entry.bindingContext); - if (frame) { - frame._notifyFrameNavigatingTo(newEntry, isBack); + if (frame) { + const entry: BackstackEntry = this[ENTRY]; + const currentPage = frame.currentPage; + + // Don't raise event if currentPage was showing modal page. + if (!owner._presentedViewController && entry && currentPage !== owner && !frame._executingContext) { + const isBack: boolean = frame.backStack.includes(entry); + + frame._executingContext = { + entry, + isBackNavigation: isBack, + navigationType: isBack ? NavigationType.back : NavigationType.forward, + }; + frame._onNavigatingTo(entry, isBack); } - } - if (frame) { frame._resolvedPage = owner; if (owner.parent === frame) { @@ -148,52 +139,59 @@ class UIViewControllerImpl extends UIViewController { const navigationController = this.navigationController; const frame: Frame = navigationController ? (navigationController).owner : null; - // Skip navigation events if modal page is shown. - if (!owner._presentedViewController && frame) { + + if (frame) { const newEntry: BackstackEntry = this[ENTRY]; - // frame.setCurrent(...) will reset executing context so retrieve it here - // if executing context is null here this most probably means back navigation through iOS back button - const navigationContext = frame._executingContext || { - navigationType: NavigationType.back, - }; - const isReplace = navigationContext.navigationType === NavigationType.replace; - - frame.setCurrent(newEntry, navigationContext.navigationType); - - if (isReplace) { - const controller = newEntry.resolvedPage.ios; - if (controller) { - const animated = frame._getIsAnimatedNavigation(newEntry.entry); - if (animated) { - controller[TRANSITION] = frame._getNavigationTransition(newEntry.entry); - } else { - controller[TRANSITION] = { - name: NON_ANIMATED_TRANSITION, - }; + // There are cases like swipe back navigation that can be cancelled. + // When that's the case, stop here and unset executing context. + if (frame._executingContext && frame._executingContext.entry !== newEntry) { + frame._executingContext = null; + return; + } + + // Skip navigation events if modal page is shown. + if (!owner._presentedViewController) { + // frame.setCurrent(...) will reset executing context so retrieve it here + const navigationType = frame._executingContext?.navigationType ?? NavigationType.back; + const isReplace = navigationType === NavigationType.replace; + + frame.setCurrent(newEntry, navigationType); + + if (isReplace) { + const controller = newEntry.resolvedPage.ios; + if (controller) { + const animated = frame._getIsAnimatedNavigation(newEntry.entry); + if (animated) { + controller[TRANSITION] = frame._getNavigationTransition(newEntry.entry); + } else { + controller[TRANSITION] = { + name: NON_ANIMATED_TRANSITION, + }; + } } } - } - // If page was shown with custom animation - we need to set the navigationController.delegate to the animatedDelegate. - if (frame.ios?.controller) { - frame.ios.controller.delegate = this[DELEGATE]; - } + // If page was shown with custom animation - we need to set the navigationController.delegate to the animatedDelegate. + if (frame.ios?.controller) { + frame.ios.controller.delegate = this[DELEGATE]; + } - frame._processNavigationQueue(owner); - - if (!__VISIONOS__) { - // _processNavigationQueue will shift navigationQueue. Check canGoBack after that. - // Workaround for disabled backswipe on second custom native transition - if (frame.canGoBack()) { - const transitionState = SharedTransition.getState(owner.transitionId); - if (!transitionState?.interactive) { - // only consider when interactive transitions are not enabled - navigationController.interactivePopGestureRecognizer.delegate = navigationController; - navigationController.interactivePopGestureRecognizer.enabled = owner.enableSwipeBackNavigation; + frame._processNavigationQueue(owner); + + if (!__VISIONOS__) { + // _processNavigationQueue will shift navigationQueue. Check canGoBack after that. + // Workaround for disabled backswipe on second custom native transition + if (frame.canGoBack()) { + const transitionState = SharedTransition.getState(owner.transitionId); + if (!transitionState?.interactive) { + // only consider when interactive transitions are not enabled + navigationController.interactivePopGestureRecognizer.delegate = navigationController; + navigationController.interactivePopGestureRecognizer.enabled = owner.enableSwipeBackNavigation; + } + } else { + navigationController.interactivePopGestureRecognizer.enabled = false; } - } else { - navigationController.interactivePopGestureRecognizer.enabled = false; } } } @@ -221,21 +219,6 @@ class UIViewControllerImpl extends UIViewController { owner._presentedViewController = this.presentedViewController; } - const frame = owner.frame; - // Skip navigation events if we are hiding because we are about to show a modal page, - // or because we are not navigating back - // or because we are closing a modal page. - if (owner.onNavigatingFrom && this.movingFromParentViewController && !owner._presentedViewController && frame && !frame._executingContext && (!this.presentingViewController || frame.backStack.length > 0) && frame.currentPage === owner) { - const isBack = isBackNavigationFrom(this, owner); - - // Create an executing context will also make navigation more secure as frame avoids some actions when it's defined - frame._executingContext = { - isBackNavigation: isBack, - isUserInitiated: true, - navigationType: isBack ? NavigationType.back : NavigationType.forward, - }; - owner.onNavigatingFrom(isBack); - } owner.updateWithWillDisappear(animated); }