From bca3452722c57484ef16df9fd38d8b916fe8a848 Mon Sep 17 00:00:00 2001 From: Dimitris-Rafail Katsampas Date: Sat, 12 Jul 2025 02:13:20 +0300 Subject: [PATCH] feat(ios): improved handling for navigation events (#10757) --- .../src/ui/tab-view/tab-view-root-tests.ts | 7 +- packages/core/ui/frame/frame-common.ts | 6 +- packages/core/ui/frame/frame-interfaces.ts | 6 +- packages/core/ui/frame/frame-stack.ts | 4 + packages/core/ui/frame/index.d.ts | 9 +- packages/core/ui/frame/index.ios.ts | 66 +++---- packages/core/ui/gestures/index.ios.ts | 2 +- packages/core/ui/page/index.ios.ts | 165 +++++++----------- packages/core/ui/tab-view/index.ios.ts | 9 +- 9 files changed, 118 insertions(+), 156 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 8d5129afc..62d0bc936 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,16 +140,11 @@ 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'], [], ['Tab2 Frame2 loaded', 'Tab2 Frame2 Page2 navigatingTo', 'Tab2 Frame2 Page2 loaded', 'Tab2 Frame2 Page2 navigatedTo']]; TKUnit.assertDeepEqual(actualEventsRaised, expectedEventsRaisedAfterSelectThirdTab); resetActualEventsRaised(); - waitUntilTabViewReady(items[0].page, () => (tabView.selectedIndex = 0)); const expectedEventsRaisedAfterReturnToFirstTab = [['Tab0 Frame0 Page0 loaded', 'Tab0 Frame0 loaded'], [], ['Tab2 Frame2 Page2 unloaded', 'Tab2 Frame2 unloaded']]; diff --git a/packages/core/ui/frame/frame-common.ts b/packages/core/ui/frame/frame-common.ts index 3a42c2979..f8f8380cb 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'; @@ -542,6 +542,10 @@ export class FrameBase extends CustomLayoutView { } } + public _isFrameStackEmpty() { + return _isFrameStackEmpty(); + } + public _pushInFrameStack() { _pushInFrameStack(this); } diff --git a/packages/core/ui/frame/frame-interfaces.ts b/packages/core/ui/frame/frame-interfaces.ts index 522fe6c36..5294d222f 100644 --- a/packages/core/ui/frame/frame-interfaces.ts +++ b/packages/core/ui/frame/frame-interfaces.ts @@ -36,8 +36,10 @@ export interface NavigationEntry extends ViewEntry { } export interface NavigationContext { - entry: BackstackEntry; - // TODO: remove isBackNavigation for NativeScript 7.0 + entry?: BackstackEntry; + /** + * @deprecated Use navigationType instead. + */ isBackNavigation: boolean; navigationType: NavigationType; } diff --git a/packages/core/ui/frame/frame-stack.ts b/packages/core/ui/frame/frame-stack.ts index c3f59d57f..47d3150d5 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 73e5ca2aa..1ed369707 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 */ @@ -404,7 +408,10 @@ export interface NavigationEntry extends ViewEntry { * Represents a context passed to navigation methods. */ export interface NavigationContext { - entry: BackstackEntry; + 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 33787b214..696bd5322 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); @@ -346,19 +356,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(); @@ -413,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; @@ -445,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 93696c026..879d44a2a 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 a36bce5fb..6a2de3a59 100644 --- a/packages/core/ui/page/index.ios.ts +++ b/packages/core/ui/page/index.ios.ts @@ -18,46 +18,6 @@ const DELEGATE = '_delegate'; const TRANSITION = '_transition'; const NON_ANIMATED_TRANSITION = 'non-animated'; -function isBackNavigationTo(page: Page, entry: BackstackEntry): 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) { - return false; - } - - if (frame.navigationQueueIsEmpty()) { - return true; - } - - const queueContext = frame.getNavigationQueueContextByEntry(entry); - return queueContext && queueContext.navigationType === NavigationType.back; -} - -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; - } - - if (controller.navigationController && controller.navigationController.viewControllers.containsObject(controller)) { - return false; - } - - return true; -} - @NativeClass class UIViewControllerImpl extends UIViewController { // TODO: a11y @@ -115,15 +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)) { - const isBack = isBackNavigationTo(owner, newEntry); - owner.onNavigatingTo(newEntry.entry.context, isBack, newEntry.entry.bindingContext); - } 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); + } + frame._resolvedPage = owner; if (owner.parent === frame) { @@ -131,7 +99,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(); } @@ -171,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; + // 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; + } - frame.setCurrent(newEntry, navigationContext.navigationType); + // 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; - 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, - }; + 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); + 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; + 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; } } } @@ -244,18 +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 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); - } - } owner.updateWithWillDisappear(animated); } @@ -263,14 +226,16 @@ 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(); } } diff --git a/packages/core/ui/tab-view/index.ios.ts b/packages/core/ui/tab-view/index.ios.ts index e82a97fbc..5da4665dd 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; } }