diff --git a/apps/toolbox/src/pages/multiple-scenes.ts b/apps/toolbox/src/pages/multiple-scenes.ts index a35c1d2b20..7c592df2d9 100644 --- a/apps/toolbox/src/pages/multiple-scenes.ts +++ b/apps/toolbox/src/pages/multiple-scenes.ts @@ -1,4 +1,4 @@ -import { Observable, EventData, Page, Application, Frame, StackLayout, Label, Button, Dialogs, View, Color, SceneEvents, SceneEventData, Utils } from '@nativescript/core'; +import { Observable, EventData, Page, Application, StackLayout, Label, Button, Dialogs, View, Color, NativeWindowEvents, SceneEventData, Utils, WindowEvents, WindowOpenEventData, WindowCloseEventData, NativeWindow } from '@nativescript/core'; let page: Page; let viewModel: MultipleScenesModel; @@ -9,12 +9,21 @@ export function navigatingTo(args: EventData) { page.bindingContext = viewModel; } +export function navigatingFrom(args: EventData) { + if (viewModel) { + viewModel.destroy(); + viewModel = undefined; + } +} + export class MultipleScenesModel extends Observable { private _sceneCount = 0; private _isMultiSceneSupported = false; - private _currentScenes: any[] = []; private _currentWindows: any[] = []; private _sceneEvents: string[] = []; + private _windowOpenHandler: (args: WindowOpenEventData) => void; + private _windowCloseHandler: (args: WindowCloseEventData) => void; + private _sceneEventHandlers: Map void> = new Map(); constructor() { super(); @@ -32,10 +41,6 @@ export class MultipleScenesModel extends Observable { return this._isMultiSceneSupported; } - get currentScenes(): any[] { - return this._currentScenes; - } - get currentWindows(): any[] { return this._currentWindows; } @@ -105,39 +110,84 @@ export class MultipleScenesModel extends Observable { private setupSceneEventListeners() { if (!__APPLE__) return; - // Listen to all scene lifecycle events - Application.on(SceneEvents.sceneWillConnect, (args: SceneEventData) => { - this.addSceneEvent(`Scene Will Connect: ${this.getSceneDescription(args.scene)}`); + // Listen for window open/close on Application + this._windowOpenHandler = (args: WindowOpenEventData) => { + const nativeWindow = args.window; + if (!nativeWindow) return; + this.addSceneEvent(`Window opened: ${nativeWindow.id}`); + this.registerNativeWindowListeners(nativeWindow); this.updateSceneInfo(); - }); - - Application.on(SceneEvents.sceneDidActivate, (args: SceneEventData) => { - this.addSceneEvent(`Scene Did Activate: ${this.getSceneDescription(args.scene)}`); + }; + this._windowCloseHandler = (args: WindowCloseEventData) => { + const nativeWindow = args.window; + if (!nativeWindow) return; + this.addSceneEvent(`Window closed: ${nativeWindow.id}`); this.updateSceneInfo(); - }); + }; + Application.ios.on(WindowEvents.windowOpen, this._windowOpenHandler); + Application.ios.on(WindowEvents.windowClose, this._windowCloseHandler); - Application.on(SceneEvents.sceneWillResignActive, (args: SceneEventData) => { - this.addSceneEvent(`Scene Will Resign Active: ${this.getSceneDescription(args.scene)}`); - }); + // Register listeners on existing windows + for (const nativeWindow of Application.ios.getWindows()) { + this.registerNativeWindowListeners(nativeWindow); + } + } - Application.on(SceneEvents.sceneWillEnterForeground, (args: SceneEventData) => { - this.addSceneEvent(`Scene Will Enter Foreground: ${this.getSceneDescription(args.scene)}`); - }); + private registerNativeWindowListeners(nativeWindow: NativeWindow) { + const events = [ + { name: NativeWindowEvents.sceneWillConnect, label: 'Scene Will Connect' }, + { name: NativeWindowEvents.sceneDidActivate, label: 'Scene Did Activate' }, + { name: NativeWindowEvents.sceneWillResignActive, label: 'Scene Will Resign Active' }, + { name: NativeWindowEvents.sceneWillEnterForeground, label: 'Scene Will Enter Foreground' }, + { name: NativeWindowEvents.sceneDidEnterBackground, label: 'Scene Did Enter Background' }, + { name: NativeWindowEvents.sceneDidDisconnect, label: 'Scene Did Disconnect' }, + ]; + + for (const event of events) { + const handler = (args: SceneEventData) => { + this.addSceneEvent(`${event.label}: Window ${nativeWindow.id}`); + this.updateSceneInfo(); + + // Set up content for new scenes when they connect + if (event.name === NativeWindowEvents.sceneWillConnect) { + this.setupSceneContent(nativeWindow, args); + } + }; + const handlerKey = `${nativeWindow.id}:${event.name}`; + this._sceneEventHandlers.set(handlerKey, handler); + nativeWindow.on(event.name, handler as any); + } + } - Application.on(SceneEvents.sceneDidEnterBackground, (args: SceneEventData) => { - this.addSceneEvent(`Scene Did Enter Background: ${this.getSceneDescription(args.scene)}`); - }); + private unregisterNativeWindowListeners(nativeWindow: NativeWindow) { + const events = [NativeWindowEvents.sceneWillConnect, NativeWindowEvents.sceneDidActivate, NativeWindowEvents.sceneWillResignActive, NativeWindowEvents.sceneWillEnterForeground, NativeWindowEvents.sceneDidEnterBackground, NativeWindowEvents.sceneDidDisconnect]; - Application.on(SceneEvents.sceneDidDisconnect, (args: SceneEventData) => { - this.addSceneEvent(`Scene Did Disconnect: ${this.getSceneDescription(args.scene)}`); - this.updateSceneInfo(); - }); + for (const eventName of events) { + const handlerKey = `${nativeWindow.id}:${eventName}`; + const handler = this._sceneEventHandlers.get(handlerKey); + if (handler) { + nativeWindow.off(eventName, handler); + this._sceneEventHandlers.delete(handlerKey); + } + } + } - // Listen for scene content setup events to provide content for new scenes - Application.on(SceneEvents.sceneContentSetup, (args: SceneEventData) => { - this.addSceneEvent(`Setting up content for new scene: ${this.getSceneDescription(args.scene)}`); - this.setupSceneContent(args); - }); + destroy() { + if (!__APPLE__) return; + + // Unregister window open/close listeners + if (this._windowOpenHandler) { + Application.ios.off(WindowEvents.windowOpen, this._windowOpenHandler); + } + if (this._windowCloseHandler) { + Application.ios.off(WindowEvents.windowClose, this._windowCloseHandler); + } + + // Unregister all NativeWindow listeners + for (const nativeWindow of Application.ios.getWindows()) { + this.unregisterNativeWindowListeners(nativeWindow); + } + this._sceneEventHandlers.clear(); } private getSceneDescription(scene: UIWindowScene): string { @@ -149,9 +199,12 @@ export class MultipleScenesModel extends Observable { return scene?.hash ? `${scene?.hash}` : scene?.description || 'Unknown'; } - private setupSceneContent(args: SceneEventData) { + private setupSceneContent(nativeWindow: NativeWindow, args: SceneEventData) { if (!args.scene || !args.window || !__APPLE__) return; + // Skip the primary scene (it already has content) + if (nativeWindow === Application.ios.primaryWindow) return; + try { let nsViewId: string; if (args.connectionOptions?.userActivities?.count > 0) { @@ -170,9 +223,11 @@ export class MultipleScenesModel extends Observable { // Note: can implement any number of other scene views } - console.log('setWindowRootView for:', args.window); - Application.ios.setWindowRootView(args.window, page); - this.addSceneEvent(`Content successfully set for scene: ${this.getSceneDescription(args.scene)}`); + if (page) { + console.log('setContent for window:', nativeWindow.id); + nativeWindow.setContent(page); + this.addSceneEvent(`Content successfully set for window: ${nativeWindow.id}`); + } } catch (error) { this.addSceneEvent(`Error setting up scene content: ${error.message}`); } @@ -232,8 +287,6 @@ export class MultipleScenesModel extends Observable { this._closeButtons.set(sceneId, closeButton); layout.addChild(closeButton); - // Set up the layout as a root view (this creates the native iOS view) - page._setupAsRootView({}); return page; } @@ -283,8 +336,6 @@ export class MultipleScenesModel extends Observable { this._closeButtons.set(sceneId, closeButton); layout.addChild(closeButton); - // Set up the layout as a root view (this creates the native iOS view) - page._setupAsRootView({}); return page; } @@ -334,25 +385,20 @@ export class MultipleScenesModel extends Observable { private updateSceneInfo() { if (__APPLE__ && this._isMultiSceneSupported) { try { - this._currentScenes = Application.ios.getAllScenes() || []; - - this._currentWindows = Application.ios.getAllWindows() || []; - - this._sceneCount = this._currentScenes.length; + const windows = Application.ios.getWindows() || []; + this._currentWindows = windows; + this._sceneCount = windows.length; } catch (error) { console.log('Error getting scene info:', error); this._sceneCount = 0; - this._currentScenes = []; this._currentWindows = []; } } else { this._sceneCount = 1; // Traditional single window - this._currentScenes = []; this._currentWindows = []; } this.notifyPropertyChange('sceneCount', this._sceneCount); - this.notifyPropertyChange('currentScenes', this._currentScenes); this.notifyPropertyChange('currentWindows', this._currentWindows); this.notifyPropertyChange('statusText', this.statusText); } @@ -418,7 +464,7 @@ export class MultipleScenesModel extends Observable { this.addSceneEvent(`API Test: ${apiInfo}`); // Also log current scene/window counts - this.addSceneEvent(`Current state: ${this._currentScenes.length} scenes, ${this._currentWindows.length} windows`); + this.addSceneEvent(`Current state: ${this._currentWindows.length} windows`); // Add device and system info try { diff --git a/packages/core/application/application-common.ts b/packages/core/application/application-common.ts index 50b27b6f8d..e5489f720a 100644 --- a/packages/core/application/application-common.ts +++ b/packages/core/application/application-common.ts @@ -16,6 +16,7 @@ import { readyInitAccessibilityCssHelper, readyInitFontScale } from '../accessib import { getAppMainEntry, isAppInBackground, setAppInBackground, setAppMainEntry } from './helpers-common'; import { getNativeScriptGlobals } from '../globals/global-utils'; import { SDK_VERSION } from '../utils/constants'; +import type { WindowCloseEventData, WindowOpenEventData } from '../native-window'; // prettier-ignore const ORIENTATION_CSS_CLASSES = [ @@ -87,13 +88,23 @@ function applySdkVersionClass(rootView: View): void { const globalEvents = getNativeScriptGlobals().events; // Scene lifecycle event names +/** + * @deprecated Use `NativeWindowEvents` from `@nativescript/core/native-window` instead. + */ export const SceneEvents = { + /** @deprecated Use `NativeWindowEvents.sceneWillConnect` instead. */ sceneWillConnect: 'sceneWillConnect', + /** @deprecated Use `NativeWindowEvents.sceneDidActivate` instead. */ sceneDidActivate: 'sceneDidActivate', + /** @deprecated Use `NativeWindowEvents.sceneWillResignActive` instead. */ sceneWillResignActive: 'sceneWillResignActive', + /** @deprecated Use `NativeWindowEvents.sceneWillEnterForeground` instead. */ sceneWillEnterForeground: 'sceneWillEnterForeground', + /** @deprecated Use `NativeWindowEvents.sceneDidEnterBackground` instead. */ sceneDidEnterBackground: 'sceneDidEnterBackground', + /** @deprecated Use `NativeWindowEvents.sceneDidDisconnect` instead. */ sceneDidDisconnect: 'sceneDidDisconnect', + /** @deprecated Use `NativeWindowEvents.sceneContentSetup` instead. */ sceneContentSetup: 'sceneContentSetup', }; @@ -177,6 +188,9 @@ interface ApplicationEvents { on(event: 'layoutDirectionChanged', callback: (args: LayoutDirectionChangedEventData) => void, thisArg?: any): void; on(event: 'fontScaleChanged', callback: (args: FontScaleChangedEventData) => void, thisArg?: any): void; + + on(event: 'windowOpen', callback: (args: WindowOpenEventData) => void, thisArg?: any): void; + on(event: 'windowClose', callback: (args: WindowCloseEventData) => void, thisArg?: any): void; } export class ApplicationCommon { diff --git a/packages/core/application/application.android.ts b/packages/core/application/application.android.ts index 30e188fd2f..61a0cc7dea 100644 --- a/packages/core/application/application.android.ts +++ b/packages/core/application/application.android.ts @@ -8,6 +8,9 @@ import { ApplicationCommon, initializeSdkVersionClass } from './application-comm import type { AndroidActivityBundleEventData, AndroidActivityEventData, ApplicationEventData } from './application-interfaces'; import { Observable } from '../data/observable'; import { Trace } from '../trace'; +import { AndroidNativeWindow } from '../native-window/native-window.android'; +import { NativeWindow } from '../native-window/native-window-common'; +import { NativeWindowEvents, WindowEvents } from '../native-window/native-window-interfaces'; import { CommonA11YServiceEnabledObservable, SharedA11YObservable, @@ -78,7 +81,13 @@ function initNativeScriptLifecycleCallbacks() { this.nativescriptActivity = activity; } - this.notifyActivityCreated(activity, savedInstanceState); + // Create and register NativeWindow for this activity + const isPrimary = Application.android._getWindows().length === 0; + const nativeWindowId = AndroidNativeWindow.getActivityId(activity); + const nativeWindow = new AndroidNativeWindow(activity, nativeWindowId, isPrimary); + Application.android._registerWindow(nativeWindow); + + this.notifyActivityCreated(activity, savedInstanceState, nativeWindow); if (Application.hasListeners(Application.displayedEvent)) { this.subscribeForGlobalLayout(activity); @@ -105,6 +114,20 @@ function initNativeScriptLifecycleCallbacks() { } } + // Unregister NativeWindow for this activity + const nativeWindow = Application.android._getWindowForActivity(activity); + if (nativeWindow) { + nativeWindow._notifyEvent(NativeWindowEvents.close); + // Emit activityDestroyed on NativeWindow + nativeWindow.notify({ + eventName: NativeWindowEvents.activityDestroyed, + object: nativeWindow, + activity, + } as AndroidActivityEventData); + Application.android._unregisterWindow(nativeWindow); + } + + // @deprecated - Bridge to Application.android for backward compat Application.android.notify({ eventName: Application.android.activityDestroyedEvent, object: Application.android, @@ -126,6 +149,18 @@ function initNativeScriptLifecycleCallbacks() { }); } + const nativeWindow = Application.android._getWindowForActivity(activity); + if (nativeWindow) { + nativeWindow._notifyEvent(NativeWindowEvents.deactivate); + // Emit activityPaused on NativeWindow + nativeWindow.notify({ + eventName: NativeWindowEvents.activityPaused, + object: nativeWindow, + activity, + } as AndroidActivityEventData); + } + + // @deprecated - Bridge to Application.android for backward compat Application.android.notify({ eventName: Application.android.activityPausedEvent, object: Application.android, @@ -138,9 +173,21 @@ function initNativeScriptLifecycleCallbacks() { // console.log('NativeScriptLifecycleCallbacks onActivityResumed'); Application.android.setForegroundActivity(activity); + const nativeWindow = Application.android._getWindowForActivity(activity); + if (nativeWindow) { + nativeWindow._notifyEvent(NativeWindowEvents.activate); + // Emit activityResumed on NativeWindow + nativeWindow.notify({ + eventName: NativeWindowEvents.activityResumed, + object: nativeWindow, + activity, + } as AndroidActivityEventData); + } + // NOTE: setSuspended(false) is called in frame/index.android.ts inside onPostResume // This is done to ensure proper timing for the event to be raised + // @deprecated - Bridge to Application.android for backward compat Application.android.notify({ eventName: Application.android.activityResumedEvent, object: Application.android, @@ -152,6 +199,18 @@ function initNativeScriptLifecycleCallbacks() { public onActivitySaveInstanceState(activity: androidx.appcompat.app.AppCompatActivity, bundle: android.os.Bundle): void { // console.log('NativeScriptLifecycleCallbacks onActivitySaveInstanceState'); + // Emit on NativeWindow first + const nativeWindow = Application.android._getWindowForActivity(activity); + if (nativeWindow) { + nativeWindow.notify({ + eventName: NativeWindowEvents.saveActivityState, + object: nativeWindow, + activity, + bundle, + } as AndroidActivityBundleEventData); + } + + // @deprecated - Bridge to Application.android for backward compat Application.android.notify({ eventName: Application.android.saveActivityStateEvent, object: Application.android, @@ -173,6 +232,18 @@ function initNativeScriptLifecycleCallbacks() { }); } + const nativeWindow = Application.android._getWindowForActivity(activity); + if (nativeWindow) { + nativeWindow._notifyEvent(NativeWindowEvents.foreground); + // Emit activityStarted on NativeWindow + nativeWindow.notify({ + eventName: NativeWindowEvents.activityStarted, + object: nativeWindow, + activity, + } as AndroidActivityEventData); + } + + // @deprecated - Bridge to Application.android for backward compat Application.android.notify({ eventName: Application.android.activityStartedEvent, object: Application.android, @@ -192,6 +263,18 @@ function initNativeScriptLifecycleCallbacks() { }); } + const nativeWindow = Application.android._getWindowForActivity(activity); + if (nativeWindow) { + nativeWindow._notifyEvent(NativeWindowEvents.background); + // Emit activityStopped on NativeWindow + nativeWindow.notify({ + eventName: NativeWindowEvents.activityStopped, + object: nativeWindow, + activity, + } as AndroidActivityEventData); + } + + // @deprecated - Bridge to Application.android for backward compat Application.android.notify({ eventName: Application.android.activityStoppedEvent, object: Application.android, @@ -212,7 +295,17 @@ function initNativeScriptLifecycleCallbacks() { } @profile - notifyActivityCreated(activity: androidx.appcompat.app.AppCompatActivity, bundle: android.os.Bundle) { + notifyActivityCreated(activity: androidx.appcompat.app.AppCompatActivity, bundle: android.os.Bundle, nativeWindow?: NativeWindow) { + // Emit on NativeWindow first + if (nativeWindow) { + nativeWindow.notify({ + eventName: NativeWindowEvents.activityCreated, + object: nativeWindow, + activity, + bundle, + } as AndroidActivityBundleEventData); + } + // @deprecated - Bridge to Application.android for backward compat Application.android.notify({ eventName: Application.android.activityCreatedEvent, object: Application.android, @@ -517,6 +610,78 @@ export class AndroidApplication extends ApplicationCommon implements IAndroidApp } return []; } + + // --- NativeWindow registry --- + private _windows: AndroidNativeWindow[] = []; + + /** + * @internal - Register a NativeWindow created by the lifecycle callbacks. + */ + _registerWindow(nativeWindow: AndroidNativeWindow): void { + this._windows.push(nativeWindow); + this.notify({ + eventName: WindowEvents.windowOpen, + object: this, + window: nativeWindow, + }); + } + + /** + * @internal - Unregister a NativeWindow when its activity is destroyed. + */ + _unregisterWindow(nativeWindow: AndroidNativeWindow): void { + const idx = this._windows.indexOf(nativeWindow); + if (idx >= 0) { + this._windows.splice(idx, 1); + } + this.notify({ + eventName: WindowEvents.windowClose, + object: this, + window: nativeWindow, + }); + nativeWindow._destroy(); + + // If primary was removed, promote next window + if (nativeWindow.isPrimary && this._windows.length > 0) { + (this._windows[0] as any)._isPrimary = true; + } + } + + /** + * @internal - Get all registered NativeWindows. + */ + _getWindows(): NativeWindow[] { + return this._windows; + } + + /** + * @internal - Get a NativeWindow by its activity. + */ + _getWindowForActivity(activity: androidx.appcompat.app.AppCompatActivity): AndroidNativeWindow | undefined { + return this._windows.find((nw) => nw.activity === activity); + } + + /** + * @internal - Get a NativeWindow by its id. + */ + _getWindowById(id: string): NativeWindow | undefined { + return this._windows.find((nw) => nw.id === id); + } + + /** + * Get the primary NativeWindow. + */ + get primaryWindow(): NativeWindow | undefined { + return this._windows.find((nw) => nw.isPrimary); + } + + /** + * Get all active NativeWindows. + */ + getWindows(): NativeWindow[] { + return [...this._windows]; + } + getRootView(): View { const activity = this.foregroundActivity || this.startActivity; if (!activity) { @@ -726,7 +891,7 @@ function updateAccessibilityState(): void { if (!sharedA11YObservable) { return; } - + const accessibilityManager = getAndroidAccessibilityManager(); if (!accessibilityManager) { sharedA11YObservable.set(accessibilityStateEnabledPropName, false); diff --git a/packages/core/application/application.d.ts b/packages/core/application/application.d.ts index a305a88838..6c59b93e33 100644 --- a/packages/core/application/application.d.ts +++ b/packages/core/application/application.d.ts @@ -1,5 +1,7 @@ import { ApplicationCommon } from './application-common'; import { FontScaleCategory } from '../accessibility/font-scale-common'; +import type { NativeWindow } from '../native-window/native-window-common'; +import type { WindowOpenEventData, WindowCloseEventData } from '../native-window/native-window-interfaces'; export * from './application-common'; export * from './application-interfaces'; @@ -8,60 +10,93 @@ export const Application: ApplicationCommon; export class AndroidApplication extends ApplicationCommon { /** - * @deprecated Use `Application.android.activityCreatedEvent` instead. + * @deprecated Listen on a NativeWindow instance instead. */ static readonly activityCreatedEvent = 'activityCreated'; /** - * @deprecated Use `Application.android.activityDestroyedEvent` instead. + * @deprecated Listen on a NativeWindow instance instead. */ static readonly activityDestroyedEvent = 'activityDestroyed'; /** - * @deprecated Use `Application.android.activityStartedEvent` instead. + * @deprecated Listen on a NativeWindow instance instead. */ static readonly activityStartedEvent = 'activityStarted'; /** - * @deprecated Use `Application.android.activityPausedEvent` instead. + * @deprecated Listen on a NativeWindow instance instead. */ static readonly activityPausedEvent = 'activityPaused'; /** - * @deprecated Use `Application.android.activityResumedEvent` instead. + * @deprecated Listen on a NativeWindow instance instead. */ static readonly activityResumedEvent = 'activityResumed'; /** - * @deprecated Use `Application.android.activityStoppedEvent` instead. + * @deprecated Listen on a NativeWindow instance instead. */ static readonly activityStoppedEvent = 'activityStopped'; /** - * @deprecated Use `Application.android.saveActivityStateEvent` instead. + * @deprecated Listen on a NativeWindow instance instead. */ static readonly saveActivityStateEvent = 'saveActivityState'; /** - * @deprecated Use `Application.android.activityResultEvent` instead. + * @deprecated Listen on a NativeWindow instance instead. */ static readonly activityResultEvent = 'activityResult'; /** - * @deprecated Use `Application.android.activityBackPressedEvent` instead. + * @deprecated Listen on a NativeWindow instance instead. */ static readonly activityBackPressedEvent = 'activityBackPressed'; /** - * @deprecated Use `Application.android.activityNewIntentEvent` instead. + * @deprecated Listen on a NativeWindow instance instead. */ static readonly activityNewIntentEvent = 'activityNewIntent'; /** - * @deprecated Use `Application.android.activityRequestPermissionsEvent` instead. + * @deprecated Listen on a NativeWindow instance instead. */ static readonly activityRequestPermissionsEvent = 'activityRequestPermissions'; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ readonly activityCreatedEvent = AndroidApplication.activityCreatedEvent; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ readonly activityDestroyedEvent = AndroidApplication.activityDestroyedEvent; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ readonly activityStartedEvent = AndroidApplication.activityStartedEvent; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ readonly activityPausedEvent = AndroidApplication.activityPausedEvent; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ readonly activityResumedEvent = AndroidApplication.activityResumedEvent; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ readonly activityStoppedEvent = AndroidApplication.activityStoppedEvent; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ readonly saveActivityStateEvent = AndroidApplication.saveActivityStateEvent; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ readonly activityResultEvent = AndroidApplication.activityResultEvent; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ readonly activityBackPressedEvent = AndroidApplication.activityBackPressedEvent; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ readonly activityNewIntentEvent = AndroidApplication.activityNewIntentEvent; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ readonly activityRequestPermissionsEvent = AndroidApplication.activityRequestPermissionsEvent; getNativeApplication(): android.app.Application; @@ -136,17 +171,68 @@ export class AndroidApplication extends ApplicationCommon { */ getRegisteredBroadcastReceivers(intentFilter: string): android.content.BroadcastReceiver[]; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ on(event: 'activityCreated', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ on(event: 'activityDestroyed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ on(event: 'activityStarted', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ on(event: 'activityPaused', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ on(event: 'activityResumed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ on(event: 'activityStopped', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ on(event: 'saveActivityState', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ on(event: 'activityResult', callback: (args: AndroidActivityResultEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ on(event: 'activityBackPressed', callback: (args: AndroidActivityBackPressedEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ on(event: 'activityNewIntent', callback: (args: AndroidActivityNewIntentEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ on(event: 'activityRequestPermissions', callback: (args: AndroidActivityRequestPermissionsEventData) => void, thisArg?: any): void; + + on(event: 'windowOpen', callback: (args: WindowOpenEventData) => void, thisArg?: any): void; + on(event: 'windowClose', callback: (args: WindowCloseEventData) => void, thisArg?: any): void; + + /** + * @internal - Get a NativeWindow by its activity. + */ + _getWindowForActivity(activity: androidx.appcompat.app.AppCompatActivity): NativeWindow | undefined; + + /** + * Get the primary NativeWindow. + */ + get primaryWindow(): NativeWindow | undefined; + + /** + * Get all active NativeWindows. + */ + getWindows(): NativeWindow[]; } export class iOSApplication extends ApplicationCommon { @@ -230,28 +316,33 @@ export class iOSApplication extends ApplicationCommon { /** * Gets all windows for the application. + * @deprecated Use `getWindows()` instead. */ getAllWindows(): UIWindow[]; /** * Gets all scenes for the application. + * @deprecated Use `getWindows()` instead. */ getAllScenes(): UIScene[]; /** * Gets all window scenes for the application. + * @deprecated Use `getWindows()` instead. */ getWindowScenes(): UIWindowScene[]; /** * Gets the primary window for the application. + * @deprecated Use `primaryWindow?.iosWindow?.window` instead. */ getPrimaryWindow(): UIWindow; /** * Gets the primary scene for the application. + * @deprecated Use `primaryWindow?.iosWindow?.scene` instead. */ - getPrimaryScene(): UIWindowScene; + getPrimaryScene(): UIWindowScene | null; /** * Sets the root view for a specific window. @@ -266,6 +357,68 @@ export class iOSApplication extends ApplicationCommon { */ sceneDelegate: UIWindowSceneDelegate; + /** + * Register a callback to intercept scene configuration. + * + * Called for every new scene session. Return a `UISceneConfiguration` to handle + * the scene yourself (e.g. CarPlay, external display), or return `null`/`undefined` + * to let NativeScript handle it with the default SceneDelegate. + * + * NativeScript only auto-manages `UIWindowSceneSessionRoleApplication` scenes. + * All other scene roles are ignored unless you provide a configuration here. + * + * @example + * ```ts + * Application.ios.onSceneConfiguration = (app, session, options) => { + * if (session.role === CPTemplateApplicationSceneSessionRoleApplication) { + * const config = UISceneConfiguration.configurationWithNameSessionRole('CarPlay', session.role); + * config.delegateClass = MyCarPlaySceneDelegate; + * return config; + * } + * return null; + * }; + * ``` + */ + onSceneConfiguration: ((application: UIApplication, connectingSceneSession: UISceneSession, options: UISceneConnectionOptions) => UISceneConfiguration | null | undefined) | null; + + /** + * @deprecated Listen on a NativeWindow instance instead. + */ + on(event: 'sceneWillConnect', callback: (args: SceneEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ + on(event: 'sceneDidActivate', callback: (args: SceneEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ + on(event: 'sceneWillResignActive', callback: (args: SceneEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ + on(event: 'sceneWillEnterForeground', callback: (args: SceneEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ + on(event: 'sceneDidEnterBackground', callback: (args: SceneEventData) => void, thisArg?: any): void; + /** + * @deprecated Listen on a NativeWindow instance instead. + */ + on(event: 'sceneDidDisconnect', callback: (args: SceneEventData) => void, thisArg?: any): void; + + on(event: 'windowOpen', callback: (args: WindowOpenEventData) => void, thisArg?: any): void; + on(event: 'windowClose', callback: (args: WindowCloseEventData) => void, thisArg?: any): void; + + /** + * Get the primary NativeWindow. + */ + get primaryWindow(): NativeWindow | undefined; + + /** + * Get all active NativeWindows. + */ + getWindows(): NativeWindow[]; + /** * Flag to be set when the launch event should be delayed until the application has become active. * This is useful when you want to process notifications or data in the background without creating the UI. diff --git a/packages/core/application/application.ios.ts b/packages/core/application/application.ios.ts index df76b1f827..b4ab15be53 100644 --- a/packages/core/application/application.ios.ts +++ b/packages/core/application/application.ios.ts @@ -6,11 +6,14 @@ import type { NavigationEntry } from '../ui/frame/frame-interfaces'; import { getWindow } from '../utils/native-helper'; import { SDK_VERSION } from '../utils/constants'; import { ios as iosUtils, dataSerialize } from '../utils/native-helper'; -import { ApplicationCommon, initializeSdkVersionClass, SceneEvents } from './application-common'; +import { ApplicationCommon, initializeSdkVersionClass } from './application-common'; import { ApplicationEventData, SceneEventData } from './application-interfaces'; import { Observable } from '../data/observable'; import type { iOSApplication as IiOSApplication } from './application'; import { Trace } from '../trace'; +import { IOSNativeWindow } from '../native-window/native-window.ios'; +import { NativeWindow } from '../native-window/native-window-common'; +import { NativeWindowEvents, WindowEvents } from '../native-window/native-window-interfaces'; import { AccessibilityServiceEnabledPropName, CommonA11YServiceEnabledObservable, @@ -147,8 +150,30 @@ if (supportsScenes()) { * Detected by the Info.plist existence 'UIApplicationSceneManifest'. * If this method is implemented when there is no manifest defined, * the app will boot to a white screen. + * + * Since we configure the delegate dynamically here, UISceneConfigurations + * does NOT need to be present in Info.plist — only UIApplicationSceneManifest is required. + * + * NativeScript only handles UIWindowSceneSessionRoleApplication by default. + * Other scene types (CarPlay, external displays, etc.) are ignored unless + * the user provides an `onSceneConfiguration` callback. */ (Responder.prototype as UIApplicationDelegate).applicationConfigurationForConnectingSceneSessionOptions = function (application: UIApplication, connectingSceneSession: UISceneSession, options: UISceneConnectionOptions): UISceneConfiguration { + // Let the user intercept scene configuration for any/all scenes + const userHandler = Application.ios._onSceneConfiguration; + if (userHandler) { + const userConfig = userHandler(application, connectingSceneSession, options); + if (userConfig) { + return userConfig; + } + } + + // Only handle the standard window scene role — skip CarPlay, external displays, etc. + if (connectingSceneSession.role !== UIWindowSceneSessionRoleApplication) { + // Return a bare configuration so iOS doesn't crash, but NativeScript won't manage it + return UISceneConfiguration.configurationWithNameSessionRole('Unmanaged', connectingSceneSession.role); + } + const config = UISceneConfiguration.configurationWithNameSessionRole('Default Configuration', connectingSceneSession.role); config.sceneClass = UIWindowScene as any; config.delegateClass = SceneDelegate; @@ -187,71 +212,192 @@ class SceneDelegate extends UIResponder implements UIWindowSceneDelegate { return; } - const isFirstScene = !Application.ios.getPrimaryScene() && !Application.hasLaunched(); + const windowScene = scene as UIWindowScene; + const isFirstScene = Application.ios._getWindows().length === 0 && !Application.hasLaunched(); - this._scene = scene; + this._scene = windowScene; // Create window for this scene - this._window = UIWindow.alloc().initWithWindowScene(scene); + this._window = UIWindow.alloc().initWithWindowScene(windowScene); - // Store the window scene for this window - Application.ios._setWindowForScene(this._window, scene); + // Set up window background + if (!__VISIONOS__) { + this._window.backgroundColor = SDK_VERSION <= 12 || !UIColor.systemBackgroundColor ? UIColor.whiteColor : UIColor.systemBackgroundColor; + } - // Set up the window content - Application.ios._setupWindowForScene(this._window, scene); + const isPrimary = isFirstScene || !Application.ios.primaryWindow; + const nativeWindowId = IOSNativeWindow.getSceneId(windowScene); + + // Create NativeWindow and register it + const nativeWindow = new IOSNativeWindow(windowScene, this._window, nativeWindowId, isPrimary); + Application.ios._registerWindow(nativeWindow); + + if (isPrimary) { + // For primary, also set the legacy global window reference + setiOSWindow(this._window); + } + + // Notify on NativeWindow first + nativeWindow.notify({ + eventName: NativeWindowEvents.sceneWillConnect, + object: nativeWindow, + scene: windowScene, + window: this._window, + connectionOptions: connectionOptions, + } as SceneEventData); - // Notify that scene will connect + // @deprecated - Bridge to Application.ios for backward compat Application.ios.notify({ - eventName: SceneEvents.sceneWillConnect, + eventName: NativeWindowEvents.sceneWillConnect, object: Application.ios, - scene: scene, + scene: windowScene, window: this._window, connectionOptions: connectionOptions, } as SceneEventData); - if (scene === Application.ios.getPrimaryScene()) { + if (isPrimary) { // primary scene, activate right away this._window.makeKeyAndVisible(); - } else { - // For secondary scenes, emit an event to allow developers to set up custom content for the window - Application.ios.notify({ - eventName: SceneEvents.sceneContentSetup, - object: Application.ios, - scene: scene, - window: this._window, - connectionOptions: connectionOptions, - } as SceneEventData); } // If this is the first scene, trigger app startup if (isFirstScene) { Application.ios._notifySceneAppStarted(); + } else if (isPrimary && Application.ios.hasLaunched()) { + // Primary scene reconnecting after disconnect — restore content + (Application.ios as any).setWindowContent(); } } + sceneDidBecomeActive(scene: UIScene): void { - // This will be handled by the notification observer in iOSApplication - // The notification system will automatically trigger sceneDidActivate + const nativeWindow = Application.ios._getWindowForScene(scene as UIWindowScene); + if (nativeWindow) { + nativeWindow._notifyEvent(NativeWindowEvents.activate); + // Emit sceneDidActivate on NativeWindow + nativeWindow.notify({ + eventName: NativeWindowEvents.sceneDidActivate, + object: nativeWindow, + scene: scene, + } as SceneEventData); + } + + // @deprecated - Bridge to Application.ios for backward compat + Application.ios.notify({ + eventName: NativeWindowEvents.sceneDidActivate, + object: Application.ios, + scene: scene, + } as SceneEventData); + + // If this is the primary scene, trigger traditional app lifecycle + if (nativeWindow?.isPrimary) { + const additionalData = { + ios: UIApplication.sharedApplication, + scene: scene, + }; + Application.ios.setInBackground(false, additionalData); + Application.ios.setSuspended(false, additionalData); + + const rootView = nativeWindow.rootView; + if (rootView && !rootView.isLoaded) { + rootView.callLoaded(); + } + } } sceneWillResignActive(scene: UIScene): void { - // Notify that scene will resign active + const nativeWindow = Application.ios._getWindowForScene(scene as UIWindowScene); + if (nativeWindow) { + nativeWindow._notifyEvent(NativeWindowEvents.deactivate); + // Emit sceneWillResignActive on NativeWindow + nativeWindow.notify({ + eventName: NativeWindowEvents.sceneWillResignActive, + object: nativeWindow, + scene: scene, + } as SceneEventData); + } + + // @deprecated - Bridge to Application.ios for backward compat Application.ios.notify({ - eventName: SceneEvents.sceneWillResignActive, + eventName: NativeWindowEvents.sceneWillResignActive, object: Application.ios, scene: scene, } as SceneEventData); } sceneWillEnterForeground(scene: UIScene): void { - // This will be handled by the notification observer in iOSApplication + const nativeWindow = Application.ios._getWindowForScene(scene as UIWindowScene); + if (nativeWindow) { + nativeWindow._notifyEvent(NativeWindowEvents.foreground); + // Emit sceneWillEnterForeground on NativeWindow + nativeWindow.notify({ + eventName: NativeWindowEvents.sceneWillEnterForeground, + object: nativeWindow, + scene: scene, + } as SceneEventData); + } + + // @deprecated - Bridge to Application.ios for backward compat + Application.ios.notify({ + eventName: NativeWindowEvents.sceneWillEnterForeground, + object: Application.ios, + scene: scene, + } as SceneEventData); } sceneDidEnterBackground(scene: UIScene): void { - // This will be handled by the notification observer in iOSApplication + const nativeWindow = Application.ios._getWindowForScene(scene as UIWindowScene); + if (nativeWindow) { + nativeWindow._notifyEvent(NativeWindowEvents.background); + // Emit sceneDidEnterBackground on NativeWindow + nativeWindow.notify({ + eventName: NativeWindowEvents.sceneDidEnterBackground, + object: nativeWindow, + scene: scene, + } as SceneEventData); + } + + // @deprecated - Bridge to Application.ios for backward compat + Application.ios.notify({ + eventName: NativeWindowEvents.sceneDidEnterBackground, + object: Application.ios, + scene: scene, + } as SceneEventData); + + // If this is the primary scene, trigger traditional app lifecycle + if (nativeWindow?.isPrimary) { + const additionalData = { + ios: UIApplication.sharedApplication, + scene: scene, + }; + Application.ios.setInBackground(true, additionalData); + Application.ios.setSuspended(true, additionalData); + + const rootView = nativeWindow.rootView; + if (rootView && rootView.isLoaded) { + rootView.callUnloaded(); + } + } } sceneDidDisconnect(scene: UIScene): void { - // This will be handled by the notification observer in iOSApplication + const nativeWindow = Application.ios._getWindowForScene(scene as UIWindowScene); + if (nativeWindow) { + nativeWindow._notifyEvent(NativeWindowEvents.close); + // Emit sceneDidDisconnect on NativeWindow + nativeWindow.notify({ + eventName: NativeWindowEvents.sceneDidDisconnect, + object: nativeWindow, + scene: scene, + } as SceneEventData); + Application.ios._unregisterWindow(nativeWindow); + } + + // @deprecated - Bridge to Application.ios for backward compat + Application.ios.notify({ + eventName: NativeWindowEvents.sceneDidDisconnect, + object: Application.ios, + scene: scene, + } as SceneEventData); } } // ensure available globally @@ -263,9 +409,17 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication private _rootView: View; private launchEventCalled = false; private _sceneDelegate: UIWindowSceneDelegate; - private _windowSceneMap = new Map(); - private _primaryScene: UIWindowScene | null = null; - private _openedScenesById = new Map(); + /** + * User-provided callback to intercept scene configuration. + * Called for every new scene session. Return a UISceneConfiguration to handle + * the scene yourself, or return null/undefined to let NativeScript handle it + * (only for UIWindowSceneSessionRoleApplication scenes). + * @internal + */ + _onSceneConfiguration: ((application: UIApplication, connectingSceneSession: UISceneSession, options: UISceneConnectionOptions) => UISceneConfiguration | null | undefined) | null; + + // NativeWindow registry + private _windows: IOSNativeWindow[] = []; private _notificationObservers: NotificationObserver[] = []; @@ -287,15 +441,6 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication this.addNotificationObserver(UIApplicationWillTerminateNotification, this.willTerminate.bind(this)); this.addNotificationObserver(UIApplicationDidReceiveMemoryWarningNotification, this.didReceiveMemoryWarning.bind(this)); this.addNotificationObserver(UIApplicationDidChangeStatusBarOrientationNotification, this.didChangeStatusBarOrientation.bind(this)); - - // Add scene lifecycle notification observers only if scenes are supported - if (this.supportsScenes()) { - this.addNotificationObserver('UISceneWillConnectNotification', this.sceneWillConnect.bind(this)); - this.addNotificationObserver('UISceneDidActivateNotification', this.sceneDidActivate.bind(this)); - this.addNotificationObserver('UISceneWillEnterForegroundNotification', this.sceneWillEnterForeground.bind(this)); - this.addNotificationObserver('UISceneDidEnterBackgroundNotification', this.sceneDidEnterBackground.bind(this)); - this.addNotificationObserver('UISceneDidDisconnectNotification', this.sceneDidDisconnect.bind(this)); - } } getRootView(): View { @@ -712,28 +857,36 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication if (!this.launchEventCalled) { this.notifyAppStarted(notification); } - const additionalData = { - ios: UIApplication.sharedApplication, - }; - this.setInBackground(false, additionalData); - this.setSuspended(false, additionalData); - const rootView = this._rootView; - if (rootView && !rootView.isLoaded) { - rootView.callLoaded(); + // Only handle lifecycle here when NOT using scenes + // (scene lifecycle is handled by SceneDelegate methods) + if (!this.supportsScenes()) { + const additionalData = { + ios: UIApplication.sharedApplication, + }; + this.setInBackground(false, additionalData); + this.setSuspended(false, additionalData); + + const rootView = this._rootView; + if (rootView && !rootView.isLoaded) { + rootView.callLoaded(); + } } } private didEnterBackground(notification: NSNotification) { - const additionalData = { - ios: UIApplication.sharedApplication, - }; - this.setInBackground(true, additionalData); - this.setSuspended(true, additionalData); + // Only handle lifecycle here when NOT using scenes + if (!this.supportsScenes()) { + const additionalData = { + ios: UIApplication.sharedApplication, + }; + this.setInBackground(true, additionalData); + this.setSuspended(true, additionalData); - const rootView = this._rootView; - if (rootView && rootView.isLoaded) { - rootView.callUnloaded(); + const rootView = this._rootView; + if (rootView && rootView.isLoaded) { + rootView.callUnloaded(); + } } } @@ -764,167 +917,114 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication this.setOrientation(newOrientation); } - // Scene lifecycle notification handlers - private sceneWillConnect(notification: NSNotification) { - const scene = notification.object as UIWindowScene; - if (!scene || !(scene instanceof UIWindowScene)) { - return; - } - - // Store as primary scene if it's the first one - if (!this._primaryScene) { - this._primaryScene = scene; - } + // --- NativeWindow registry --- + /** + * @internal - Register a NativeWindow created by the SceneDelegate. + */ + _registerWindow(nativeWindow: IOSNativeWindow): void { + this._windows.push(nativeWindow); this.notify({ - eventName: SceneEvents.sceneWillConnect, + eventName: WindowEvents.windowOpen, object: this, - scene: scene, - userInfo: notification.userInfo, - } as SceneEventData); + window: nativeWindow, + }); } - private sceneDidActivate(notification: NSNotification) { - const scene = notification.object as UIScene; + /** + * @internal - Unregister a NativeWindow when its scene disconnects. + */ + _unregisterWindow(nativeWindow: IOSNativeWindow): void { + const idx = this._windows.indexOf(nativeWindow); + if (idx >= 0) { + this._windows.splice(idx, 1); + } this.notify({ - eventName: SceneEvents.sceneDidActivate, + eventName: WindowEvents.windowClose, object: this, - scene: scene, - } as SceneEventData); - - // If this is the primary scene, trigger traditional app lifecycle - if (scene === this._primaryScene) { - const additionalData = { - ios: UIApplication.sharedApplication, - scene: scene, - }; - this.setInBackground(false, additionalData); - this.setSuspended(false, additionalData); - - if (this._rootView && !this._rootView.isLoaded) { - this._rootView.callLoaded(); + window: nativeWindow, + }); + nativeWindow._destroy(); + + // If primary was removed, promote next window + if (nativeWindow.isPrimary && this._windows.length > 0) { + (this._windows[0] as any)._isPrimary = true; + const promotedWindow = this._windows[0].iosWindow?.window; + if (promotedWindow) { + setiOSWindow(promotedWindow); } } } - private sceneWillEnterForeground(notification: NSNotification) { - const scene = notification.object as UIScene; - this.notify({ - eventName: SceneEvents.sceneWillEnterForeground, - object: this, - scene: scene, - } as SceneEventData); + /** + * @internal - Get all registered NativeWindows. + */ + _getWindows(): NativeWindow[] { + return this._windows; } - private sceneDidEnterBackground(notification: NSNotification) { - const scene = notification.object as UIScene; - this.notify({ - eventName: SceneEvents.sceneDidEnterBackground, - object: this, - scene: scene, - } as SceneEventData); - - // If this is the primary scene, trigger traditional app lifecycle - if (scene === this._primaryScene) { - const additionalData = { - ios: UIApplication.sharedApplication, - scene: scene, - }; - this.setInBackground(true, additionalData); - this.setSuspended(true, additionalData); - - if (this._rootView && this._rootView.isLoaded) { - this._rootView.callUnloaded(); - } - } + /** + * @internal - Get a NativeWindow by its scene. + */ + _getWindowForScene(scene: UIWindowScene): IOSNativeWindow | undefined { + return this._windows.find((nw) => nw.iosWindow?.scene === scene); } - private sceneDidDisconnect(notification: NSNotification) { - const scene = notification.object as UIScene; - this._removeWindowForScene(scene); - - // If primary scene disconnected, clear it - if (scene === this._primaryScene) { - this._primaryScene = null; - } - - if (this._primaryScene) { - if (SDK_VERSION >= 17) { - const request = UISceneSessionActivationRequest.requestWithSession(this._primaryScene.session); + /** + * @internal - Get a NativeWindow by its id. + */ + _getWindowById(id: string): NativeWindow | undefined { + return this._windows.find((nw) => nw.id === id); + } - UIApplication.sharedApplication.activateSceneSessionForRequestErrorHandler(request, (err: NSError) => { - if (err) { - console.log('Failed to activate primary scene:', err.localizedDescription); - } - }); - } else { - UIApplication.sharedApplication.requestSceneSessionActivationUserActivityOptionsErrorHandler(this._primaryScene.session, null, null, (err: NSError) => { - if (err) { - console.log('Failed to activate primary scene (legacy):', err.localizedDescription); - } - }); - } - } + // --- Public NativeWindow API --- - this.notify({ - eventName: SceneEvents.sceneDidDisconnect, - object: this, - scene: scene, - } as SceneEventData); + /** + * Get the primary NativeWindow. + */ + get primaryWindow(): NativeWindow | undefined { + return this._windows.find((nw) => nw.isPrimary); } - // Scene management helper methods - _setWindowForScene(window: UIWindow, scene: UIScene): void { - this._windowSceneMap.set(scene, window); + /** + * Get all active NativeWindows. + */ + getWindows(): NativeWindow[] { + return [...this._windows]; } - _removeWindowForScene(scene: UIScene): void { - this._windowSceneMap.delete(scene); - // also untrack opened scene id - try { - const s: any = scene as any; - if (s && s.session) { - const id = this._getSceneId(s as UIWindowScene); - this._openedScenesById.delete(id); - } - } catch {} + /** + * Register a callback to intercept scene configuration. + * + * Called for every new scene session. Return a `UISceneConfiguration` to handle + * the scene yourself (e.g. CarPlay, external display), or return `null`/`undefined` + * to let NativeScript handle it with the default SceneDelegate. + * + * NativeScript only auto-manages `UIWindowSceneSessionRoleApplication` scenes. + * All other scene roles are ignored unless you provide a configuration here. + * + * @example + * ```ts + * Application.ios.onSceneConfiguration = (app, session, options) => { + * if (session.role === CPTemplateApplicationSceneSessionRoleApplication) { + * const config = UISceneConfiguration.configurationWithNameSessionRole('CarPlay', session.role); + * config.delegateClass = MyCarPlaySceneDelegate; + * return config; + * } + * // Return null to let NativeScript handle the default window scene + * return null; + * }; + * ``` + */ + set onSceneConfiguration(handler: ((application: UIApplication, connectingSceneSession: UISceneSession, options: UISceneConnectionOptions) => UISceneConfiguration | null | undefined) | null) { + this._onSceneConfiguration = handler; } - _getWindowForScene(scene: UIScene): UIWindow | undefined { - return this._windowSceneMap.get(scene); + get onSceneConfiguration(): ((application: UIApplication, connectingSceneSession: UISceneSession, options: UISceneConnectionOptions) => UISceneConfiguration | null | undefined) | null { + return this._onSceneConfiguration; } - _setupWindowForScene(window: UIWindow, scene: UIWindowScene): void { - if (!window) { - return; - } - - // track opened scene - try { - const id = this._getSceneId(scene); - this._openedScenesById.set(id, scene); - } catch {} - - // Set up window background - if (!__VISIONOS__) { - window.backgroundColor = SDK_VERSION <= 12 || !UIColor.systemBackgroundColor ? UIColor.whiteColor : UIColor.systemBackgroundColor; - } - - // If this is the primary scene, set up the main application content - if (scene === this._primaryScene || !this._primaryScene) { - this._primaryScene = scene; - - if (!getiOSWindow()) { - setiOSWindow(window); - } - - // During initial scene startup we must wait for launch to be notified first. - // Some frameworks provide root content from launch handlers. - if (this.hasLaunched()) { - this.setWindowContent(); - } - } - } + // Scene management helper methods (kept for backward compat) get sceneDelegate(): UIWindowSceneDelegate { if (!this._sceneDelegate) { @@ -956,39 +1056,20 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication // iOS 17+ if (SDK_VERSION >= 17) { - // Create a new scene activation request with proper role let request: UISceneSessionActivationRequest; try { - // Use the correct factory method to create request with role - // Based on the type definitions, this is the proper way request = UISceneSessionActivationRequest.requestWithRole(UIWindowSceneSessionRoleApplication); - // Note: may be useful to allow user defined activity type through optional string typed data in future const activity = NSUserActivity.alloc().initWithActivityType(`${NSBundle.mainBundle.bundleIdentifier}.scene`); activity.userInfo = dataSerialize(data); request.userActivity = activity; - // Set proper options with requesting scene const options = UISceneActivationRequestOptions.new(); - - // Note: explore secondary windows spawning other windows - // and if this context needs to change in those cases - const mainWindow = Application.ios.getPrimaryWindow(); - options.requestingScene = mainWindow?.windowScene; - - /** - * Note: This does not work in testing but worth exploring further sometime - * regarding the size/dimensions of opened secondary windows. - * The initial size is ultimately determined by the system - * based on available space and user context. - */ - // Get the size restrictions from the window scene - // const sizeRestrictions = (options.requestingScene as UIWindowScene).sizeRestrictions; - - // // Set your minimum and maximum dimensions - // sizeRestrictions.minimumSize = CGSizeMake(320, 400); - // sizeRestrictions.maximumSize = CGSizeMake(600, 800); + const primary = this.primaryWindow; + if (primary?.iosWindow?.scene) { + options.requestingScene = primary.iosWindow.scene; + } request.options = options; } catch (roleError) { @@ -1000,12 +1081,10 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication if (error) { console.log('Error creating new scene (iOS 17+):', error); - // Log additional debugging info if (error.userInfo) { console.error(`Error userInfo: ${error.userInfo.description}`); } - // Handle specific error types if (error.localizedDescription.includes('role') && error.localizedDescription.includes('nil')) { this.createSceneWithLegacyAPI(data); } else if (error.domain === 'FBSWorkspaceErrorDomain' && error.code === 2) { @@ -1013,22 +1092,13 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication } } }); - } - // iOS 13-16 - Use the legacy requestSceneSessionActivationUserActivityOptionsErrorHandler method - else if (SDK_VERSION >= 13 && SDK_VERSION < 17) { - app.requestSceneSessionActivationUserActivityOptionsErrorHandler( - null, // session - null, // userActivity - null, // options - (error) => { - if (error) { - console.log('Error creating new scene (legacy):', error); - } - }, - ); - } - // Fallback for older iOS versions or unsupported configurations - else { + } else if (SDK_VERSION >= 13 && SDK_VERSION < 17) { + app.requestSceneSessionActivationUserActivityOptionsErrorHandler(null, null, null, (error) => { + if (error) { + console.log('Error creating new scene (legacy):', error); + } + }); + } else { console.log('Neither new nor legacy scene activation methods are available'); } } catch (error) { @@ -1038,76 +1108,72 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication /** * Closes a secondary window/scene. - * Usage examples: - * - Application.ios.closeWindow() // best-effort close of a non-primary scene - * - Application.ios.closeWindow(button) // from a tap handler within the scene - * - Application.ios.closeWindow(window) - * - Application.ios.closeWindow(scene) - * - Application.ios.closeWindow('scene-id') + * Accepts a NativeWindow, View, UIWindow, UIWindowScene, or string id. */ - public closeWindow(target?: View | UIWindow | UIWindowScene | string): void { + public closeWindow(target?: NativeWindow | View | UIWindow | UIWindowScene | string): void { if (!__APPLE__) { return; } try { - const scene = this._resolveScene(target); - if (!scene) { - console.log('closeWindow: No scene resolved for target'); - return; - } + let nativeWindow: NativeWindow | undefined; - // Don't allow closing the primary scene - if (scene === this._primaryScene) { - console.log('closeWindow: Refusing to close the primary scene'); - return; + if (target instanceof NativeWindow) { + nativeWindow = target; + } else { + const scene = this._resolveScene(target); + if (scene) { + nativeWindow = this._getWindowForScene(scene); + } } - const session = scene.session; - if (!session) { - console.log('closeWindow: Scene has no session to destroy'); + if (!nativeWindow) { + console.log('closeWindow: No window resolved for target'); return; } - const app = UIApplication.sharedApplication; - if (app.requestSceneSessionDestructionOptionsErrorHandler) { - app.requestSceneSessionDestructionOptionsErrorHandler(session, null, (error: NSError) => { - if (error) { - console.log('closeWindow: destruction error', error); - } else { - // clean up tracked id - const id = this._getSceneId(scene); - this._openedScenesById.delete(id); - } - }); - } else { - console.info('closeWindow: Scene destruction API not available on this iOS version'); - } + nativeWindow.close(); } catch (err) { console.log('closeWindow: Unexpected error', err); } } + /** + * @deprecated Use `getWindows()` instead. + */ getAllWindows(): UIWindow[] { - return Array.from(this._windowSceneMap.values()); + return this._windows.map((nw) => nw.iosWindow?.window).filter(Boolean) as UIWindow[]; } + /** + * @deprecated Use `getWindows()` instead. + */ getAllScenes(): UIScene[] { - return Array.from(this._windowSceneMap.keys()); + return this._windows.map((nw) => nw.iosWindow?.scene).filter(Boolean) as UIScene[]; } + /** + * @deprecated Use `getWindows()` instead. + */ getWindowScenes(): UIWindowScene[] { return this.getAllScenes().filter((scene) => scene instanceof UIWindowScene) as UIWindowScene[]; } + /** + * @deprecated Use `primaryWindow?.iosWindow?.window` instead. + */ getPrimaryWindow(): UIWindow { - if (this._primaryScene) { - return this._getWindowForScene(this._primaryScene) || getiOSWindow(); + const primary = this.primaryWindow; + if (primary?.iosWindow?.window) { + return primary.iosWindow.window; } return getiOSWindow(); } + /** + * @deprecated Use `primaryWindow?.iosWindow?.scene` instead. + */ getPrimaryScene(): UIWindowScene | null { - return this._primaryScene; + return this.primaryWindow?.iosWindow?.scene || null; } // Scene lifecycle management @@ -1120,7 +1186,7 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication } isUsingSceneLifecycle(): boolean { - return this.supportsScenes() && this._windowSceneMap.size > 0; + return this.supportsScenes() && this._windows.length > 0; } // Call this to set up scene-based configuration @@ -1129,35 +1195,6 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication console.warn('Scene-based lifecycle is only supported on iOS 13+ iPad or visionOS with multi-scene enabled apps.'); return; } - - // Additional scene configuration can be added here - // For now, the notification observers are already set up in the constructor - } - - // Stable scene id for lookups - private _getSceneId(scene: UIWindowScene): string { - try { - if (!scene) { - return 'Unknown'; - } - // Prefer session persistentIdentifier when available (stable across lifetime) - const session = scene.session; - const persistentId = session && session.persistentIdentifier; - if (persistentId) { - return `${persistentId}`; - } - // Fallbacks - if (scene.hash != null) { - return `${scene.hash}`; - } - const desc = scene.description; - if (desc) { - return `${desc}`; - } - } catch (err) { - // ignore - } - return 'Unknown'; } // Resolve a UIWindowScene from various input types @@ -1166,12 +1203,10 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication return null; } if (!target) { - // Try to pick a non-primary foreground active scene, else last known scene - const scenes = this.getWindowScenes?.() || []; - const nonPrimary = scenes.filter((s) => s !== this._primaryScene); - return nonPrimary[0] || scenes[0] || null; + // Try to pick a non-primary window's scene + const nonPrimary = this._windows.filter((nw) => !nw.isPrimary); + return nonPrimary[0]?.iosWindow?.scene || this.primaryWindow?.iosWindow?.scene || null; } - // If a View was passed, derive its window.scene if (target && typeof target === 'object') { // UIWindowScene if ((target as UIWindowScene).session && (target as UIWindowScene).activationState !== undefined) { @@ -1190,15 +1225,15 @@ export class iOSApplication extends ApplicationCommon implements IiOSApplication } // String id lookup if (typeof target === 'string') { - if (this._openedScenesById.has(target)) { - return this._openedScenesById.get(target); + const found = this._getWindowById(target); + if (found) { + return found.iosWindow?.scene || null; } - // Try matching by persistentIdentifier or hash among known scenes - const scenes = this.getWindowScenes?.() || []; - for (const s of scenes) { - const sid = this._getSceneId(s); - if (sid === target) { - return s; + // Try matching among known scenes + for (const nw of this._windows) { + const scene = nw.iosWindow?.scene; + if (scene && IOSNativeWindow.getSceneId(scene) === target) { + return scene; } } } diff --git a/packages/core/index.d.ts b/packages/core/index.d.ts index adc95121cb..912419e1fb 100644 --- a/packages/core/index.d.ts +++ b/packages/core/index.d.ts @@ -10,6 +10,7 @@ export type { NativeScriptConfig } from './config'; export * from './application'; export { androidRegisterBroadcastReceiver, androidUnregisterBroadcastReceiver, androidRegisteredReceivers, iosAddNotificationObserver, iosRemoveNotificationObserver, iosNotificationObservers } from './application/helpers'; export { getNativeApp, setNativeApp } from './application/helpers-common'; +export * from './native-window'; export * as ApplicationSettings from './application-settings'; export namespace AccessibilityEvents { export const accessibilityBlurEvent: 'accessibilityBlur'; diff --git a/packages/core/index.ts b/packages/core/index.ts index 4e12c9fe93..f7fe4efefc 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -4,6 +4,7 @@ import './globals'; export * from './application'; export { getNativeApp, setNativeApp } from './application/helpers-common'; +export * from './native-window'; export * as ApplicationSettings from './application-settings'; import * as Accessibility from './accessibility'; export namespace AccessibilityEvents { diff --git a/packages/core/native-window/index.ts b/packages/core/native-window/index.ts new file mode 100644 index 0000000000..96264fa3ca --- /dev/null +++ b/packages/core/native-window/index.ts @@ -0,0 +1,2 @@ +export * from './native-window-interfaces'; +export * from './native-window-common'; diff --git a/packages/core/native-window/native-window-common.ts b/packages/core/native-window/native-window-common.ts new file mode 100644 index 0000000000..f915366c36 --- /dev/null +++ b/packages/core/native-window/native-window-common.ts @@ -0,0 +1,339 @@ +import { Observable } from '../data/observable'; +import { CoreTypes } from '../core-types'; +import { CSSUtils } from '../css/system-classes'; +import { Device } from '../platform'; +import { Trace } from '../trace'; +import { Builder } from '../ui/builder'; +import type { View } from '../ui/core/view'; +import type { Frame } from '../ui/frame'; +import type { NavigationEntry } from '../ui/frame/frame-interfaces'; +import type { StyleScope } from '../ui/styling/style-scope'; +import { readyInitAccessibilityCssHelper, readyInitFontScale } from '../accessibility/accessibility-common'; +import { SDK_VERSION } from '../utils/constants'; +import { initializeSdkVersionClass } from '../application/application-common'; +import type { NativeWindowEventData, NativeWindowEventName } from './native-window-interfaces'; +import { NativeWindowEvents } from './native-window-interfaces'; +import type { AndroidActivityEventData, AndroidActivityBundleEventData, AndroidActivityResultEventData, AndroidActivityBackPressedEventData, AndroidActivityNewIntentEventData, AndroidActivityRequestPermissionsEventData, SceneEventData } from '../application/application-interfaces'; + +// prettier-ignore +const ORIENTATION_CSS_CLASSES = [ + `${CSSUtils.CLASS_PREFIX}${CoreTypes.DeviceOrientation.portrait}`, + `${CSSUtils.CLASS_PREFIX}${CoreTypes.DeviceOrientation.landscape}`, + `${CSSUtils.CLASS_PREFIX}${CoreTypes.DeviceOrientation.unknown}`, +]; + +// prettier-ignore +const SYSTEM_APPEARANCE_CSS_CLASSES = [ + `${CSSUtils.CLASS_PREFIX}${CoreTypes.SystemAppearance.light}`, + `${CSSUtils.CLASS_PREFIX}${CoreTypes.SystemAppearance.dark}`, +]; + +// prettier-ignore +const LAYOUT_DIRECTION_CSS_CLASSES = [ + `${CSSUtils.CLASS_PREFIX}${CoreTypes.LayoutDirection.ltr}`, + `${CSSUtils.CLASS_PREFIX}${CoreTypes.LayoutDirection.rtl}`, +]; + +let _windowIdCounter = 0; + +/** + * Cross-platform NativeWindow base class. + * + * Wraps a platform window surface (iOS UIWindowScene+UIWindow, Android Activity) + * and manages per-window root view lifecycle, CSS classes, and events. + * + * Platform-specific subclasses implement the abstract methods. + */ +export abstract class NativeWindow extends Observable { + private _id: string; + private _isPrimary: boolean; + protected _rootView: View; + protected _orientation: 'portrait' | 'landscape' | 'unknown'; + protected _systemAppearance: 'dark' | 'light' | null; + protected _layoutDirection: CoreTypes.LayoutDirectionType | null; + + constructor(id?: string, isPrimary = false) { + super(); + this._id = id || `window-${++_windowIdCounter}`; + this._isPrimary = isPrimary; + } + + get id(): string { + return this._id; + } + + get isPrimary(): boolean { + return this._isPrimary; + } + + /** + * @internal - used by the Application to promote a window to primary. + */ + _setIsPrimary(value: boolean): void { + this._isPrimary = value; + } + + get rootView(): View { + return this._rootView; + } + + /** + * Set the content of this window. + * Accepts a View, a NavigationEntry, or a module name string. + */ + setContent(content: View | NavigationEntry | string): void { + let view: View; + + if (typeof content === 'string') { + view = Builder.createViewFromEntry({ moduleName: content }); + } else if (content && typeof content === 'object') { + if ((content as NavigationEntry).moduleName || (content as NavigationEntry).create) { + view = Builder.createViewFromEntry(content as NavigationEntry); + } else { + view = content as View; + } + } + + if (!view) { + throw new Error('NativeWindow.setContent: Invalid content provided.'); + } + + const previousRootView = this._rootView; + if (previousRootView) { + previousRootView._onRootViewReset(); + } + + this._rootView = view; + this._applyRootViewSettings(view); + this._setNativeContent(view); + + this._notifyEvent(NativeWindowEvents.contentLoaded); + } + + /** + * Platform-specific: apply the view to the native window surface. + */ + protected abstract _setNativeContent(view: View): void; + + /** + * Close this window. + */ + abstract close(): void; + + /** + * Get the current orientation of this window. + */ + orientation(): 'portrait' | 'landscape' | 'unknown' { + return (this._orientation ??= this._getOrientation()); + } + + /** + * Get the current system appearance of this window. + */ + systemAppearance(): 'light' | 'dark' | null { + return (this._systemAppearance ??= this._getSystemAppearance()); + } + + /** + * Get the current layout direction of this window. + */ + layoutDirection(): CoreTypes.LayoutDirectionType | null { + return (this._layoutDirection ??= this._getLayoutDirection()); + } + + get iosWindow(): { readonly scene: UIWindowScene; readonly window: UIWindow } | undefined { + return undefined; + } + + get androidWindow(): { readonly activity: androidx.appcompat.app.AppCompatActivity } | undefined { + return undefined; + } + + // --- Typed event overloads --- + + on(event: 'activate', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'deactivate', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'background', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'foreground', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'close', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'displayed', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'contentLoaded', callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(event: 'activityCreated', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; + on(event: 'activityDestroyed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'activityStarted', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'activityPaused', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'activityResumed', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'activityStopped', callback: (args: AndroidActivityEventData) => void, thisArg?: any): void; + on(event: 'saveActivityState', callback: (args: AndroidActivityBundleEventData) => void, thisArg?: any): void; + on(event: 'activityResult', callback: (args: AndroidActivityResultEventData) => void, thisArg?: any): void; + on(event: 'activityBackPressed', callback: (args: AndroidActivityBackPressedEventData) => void, thisArg?: any): void; + on(event: 'activityNewIntent', callback: (args: AndroidActivityNewIntentEventData) => void, thisArg?: any): void; + on(event: 'activityRequestPermissions', callback: (args: AndroidActivityRequestPermissionsEventData) => void, thisArg?: any): void; + on(event: 'sceneWillConnect', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneDidActivate', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneWillResignActive', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneWillEnterForeground', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneDidEnterBackground', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(event: 'sceneDidDisconnect', callback: (args: SceneEventData) => void, thisArg?: any): void; + on(eventName: string, callback: (data: NativeWindowEventData) => void, thisArg?: any): void; + on(eventName: string, callback: (data: any) => void, thisArg?: any): void { + super.on(eventName, callback, thisArg); + } + + // Platform-specific abstract getters + protected abstract _getOrientation(): 'portrait' | 'landscape' | 'unknown'; + protected abstract _getSystemAppearance(): 'light' | 'dark' | null; + protected abstract _getLayoutDirection(): CoreTypes.LayoutDirectionType | null; + + // --- Root view CSS class management --- + + /** + * Applies platform, orientation, appearance, and layout direction CSS classes + * to the root view. + */ + protected _applyRootViewSettings(rootView: View): void { + rootView._setupAsRootView({}); + this._setRootViewCSSClasses(rootView); + readyInitAccessibilityCssHelper(); + readyInitFontScale(); + initializeSdkVersionClass(rootView); + } + + private _setRootViewCSSClasses(rootView: View): void { + const platform = Device.os.toLowerCase(); + const deviceType = Device.deviceType.toLowerCase(); + const orientationValue = this.orientation(); + const appearanceValue = this.systemAppearance(); + const directionValue = this.layoutDirection(); + + if (platform) { + CSSUtils.pushToSystemCssClasses(`${CSSUtils.CLASS_PREFIX}${platform}`); + } + + if (deviceType) { + CSSUtils.pushToSystemCssClasses(`${CSSUtils.CLASS_PREFIX}${deviceType}`); + } + + if (orientationValue) { + CSSUtils.pushToSystemCssClasses(`${CSSUtils.CLASS_PREFIX}${orientationValue}`); + } + + if (appearanceValue) { + CSSUtils.pushToSystemCssClasses(`${CSSUtils.CLASS_PREFIX}${appearanceValue}`); + } + + if (directionValue) { + CSSUtils.pushToSystemCssClasses(`${CSSUtils.CLASS_PREFIX}${directionValue}`); + } + + rootView.cssClasses.add(CSSUtils.ROOT_VIEW_CSS_CLASS); + const rootViewCssClasses = CSSUtils.getSystemCssClasses(); + rootViewCssClasses.forEach((c) => rootView.cssClasses.add(c)); + + this._increaseStyleScopeVersion(rootView); + rootView._onCssStateChange(); + + if (Trace.isEnabled()) { + const rootCssClasses = Array.from(rootView.cssClasses); + Trace.write(`NativeWindow [${this._id}] Setting root css classes: ${rootCssClasses.join(' ')}`, Trace.categories.Style); + } + } + + // --- Orientation / Appearance / Direction change handling --- + + /** + * @internal – called by platform when orientation changes for this window. + */ + _setOrientation(value: 'portrait' | 'landscape' | 'unknown'): void { + if (this._orientation === value) { + return; + } + this._orientation = value; + if (this._rootView) { + const cssClass = `${CSSUtils.CLASS_PREFIX}${value}`; + this._applyCssClass(this._rootView, ORIENTATION_CSS_CLASSES, cssClass); + } + } + + /** + * @internal – called by platform when system appearance changes for this window. + */ + _setSystemAppearance(value: 'dark' | 'light'): void { + if (this._systemAppearance === value) { + return; + } + this._systemAppearance = value; + if (this._rootView) { + const cssClass = `${CSSUtils.CLASS_PREFIX}${value}`; + this._applyCssClass(this._rootView, SYSTEM_APPEARANCE_CSS_CLASSES, cssClass); + } + } + + /** + * @internal – called by platform when layout direction changes for this window. + */ + _setLayoutDirection(value: CoreTypes.LayoutDirectionType): void { + if (this._layoutDirection === value) { + return; + } + this._layoutDirection = value; + if (this._rootView) { + const cssClass = `${CSSUtils.CLASS_PREFIX}${value}`; + this._applyCssClass(this._rootView, LAYOUT_DIRECTION_CSS_CLASSES, cssClass); + } + } + + // --- Internal helpers --- + + private _applyCssClass(rootView: View, cssClasses: string[], newCssClass: string): void { + if (!rootView.cssClasses.has(newCssClass)) { + cssClasses.forEach((cssClass) => { + CSSUtils.removeSystemCssClass(cssClass); + rootView.cssClasses.delete(cssClass); + }); + CSSUtils.pushToSystemCssClasses(newCssClass); + rootView.cssClasses.add(newCssClass); + this._increaseStyleScopeVersion(rootView); + rootView._onCssStateChange(); + } + + // Apply to modal views + const rootModalViews = >rootView._getRootModalViews(); + rootModalViews.forEach((modalView) => { + if (!modalView.cssClasses.has(newCssClass)) { + cssClasses.forEach((cssClass) => modalView.cssClasses.delete(cssClass)); + modalView.cssClasses.add(newCssClass); + modalView._onCssStateChange(); + } + }); + } + + private _increaseStyleScopeVersion(rootView: View): void { + const styleScope: StyleScope = rootView._styleScope ?? (rootView as unknown as Frame)?.currentPage?._styleScope; + if (styleScope) { + styleScope._increaseApplicationCssSelectorVersion(); + } + } + + /** + * @internal – emit a NativeWindow lifecycle event. + */ + _notifyEvent(eventName: NativeWindowEventName): void { + this.notify({ + eventName, + window: this as any, + object: this, + }); + } + + /** + * @internal – called when the window is being torn down. + */ + _destroy(): void { + this._notifyEvent(NativeWindowEvents.close); + if (this._rootView) { + this._rootView._onRootViewReset(); + this._rootView = null; + } + } +} diff --git a/packages/core/native-window/native-window-interfaces.ts b/packages/core/native-window/native-window-interfaces.ts new file mode 100644 index 0000000000..fce4a35a22 --- /dev/null +++ b/packages/core/native-window/native-window-interfaces.ts @@ -0,0 +1,108 @@ +import type { EventData } from '../data/observable'; +import type { NativeWindow } from './native-window-common'; + +/** + * Events emitted by a NativeWindow instance. + */ +export const NativeWindowEvents = { + /** Fired when the window becomes the active/focused window. */ + activate: 'activate', + /** Fired when the window loses focus. */ + deactivate: 'deactivate', + /** Fired when the window enters the background. */ + background: 'background', + /** Fired when the window enters the foreground. */ + foreground: 'foreground', + /** Fired when the window is being closed/destroyed. */ + close: 'close', + /** Fired after the window content has been displayed for the first time. */ + displayed: 'displayed', + /** Fired when the root view content is set or changed. */ + contentLoaded: 'contentLoaded', + + // iOS scene lifecycle events + /** Fired when the scene is about to connect (iOS only). */ + sceneWillConnect: 'sceneWillConnect', + /** Fired when the scene becomes active (iOS only). */ + sceneDidActivate: 'sceneDidActivate', + /** Fired when the scene is about to resign active state (iOS only). */ + sceneWillResignActive: 'sceneWillResignActive', + /** Fired when the scene is about to enter the foreground (iOS only). */ + sceneWillEnterForeground: 'sceneWillEnterForeground', + /** Fired when the scene has entered the background (iOS only). */ + sceneDidEnterBackground: 'sceneDidEnterBackground', + /** Fired when the scene has disconnected (iOS only). */ + sceneDidDisconnect: 'sceneDidDisconnect', + + // Android activity lifecycle events + /** Fired when the activity is created (Android only). */ + activityCreated: 'activityCreated', + /** Fired when the activity is destroyed (Android only). */ + activityDestroyed: 'activityDestroyed', + /** Fired when the activity is started (Android only). */ + activityStarted: 'activityStarted', + /** Fired when the activity is paused (Android only). */ + activityPaused: 'activityPaused', + /** Fired when the activity is resumed (Android only). */ + activityResumed: 'activityResumed', + /** Fired when the activity is stopped (Android only). */ + activityStopped: 'activityStopped', + /** Fired when the activity state is being saved (Android only). */ + saveActivityState: 'saveActivityState', + /** Fired when the activity receives a result (Android only). */ + activityResult: 'activityResult', + /** Fired when the back button is pressed (Android only). */ + activityBackPressed: 'activityBackPressed', + /** Fired when the activity receives a new intent (Android only). */ + activityNewIntent: 'activityNewIntent', + /** Fired when permission results are received (Android only). */ + activityRequestPermissions: 'activityRequestPermissions', +} as const; + +export type NativeWindowEventName = (typeof NativeWindowEvents)[keyof typeof NativeWindowEvents]; + +/** + * Application-level events related to window management. + */ +export const WindowEvents = { + /** Fired on Application when a new NativeWindow is created. */ + windowOpen: 'windowOpen', + /** Fired on Application when a NativeWindow is closed/destroyed. */ + windowClose: 'windowClose', +} as const; + +/** + * Base event data for NativeWindow events. + */ +export interface NativeWindowEventData extends EventData { + /** The NativeWindow that emitted the event. */ + window: NativeWindow; +} + +/** + * Event data fired on Application when a window opens. + */ +export interface WindowOpenEventData extends EventData { + /** The NativeWindow that was opened. */ + window: NativeWindow; +} + +/** + * Event data fired on Application when a window closes. + */ +export interface WindowCloseEventData extends EventData { + /** The NativeWindow that was closed. */ + window?: NativeWindow; +} + +/** + * Options for opening a new window. + */ +export interface WindowOpenOptions { + /** + * Data to pass to the new window. + * On iOS: serialized into NSUserActivity.userInfo. + * On Android: added as intent extras. + */ + data?: Record; +} diff --git a/packages/core/native-window/native-window.android.ts b/packages/core/native-window/native-window.android.ts new file mode 100644 index 0000000000..727bf9c8cd --- /dev/null +++ b/packages/core/native-window/native-window.android.ts @@ -0,0 +1,143 @@ +import type { View } from '../ui/core/view'; +import { CoreTypes } from '../core-types'; +import { SDK_VERSION } from '../utils/constants'; +import { AndroidActivityCallbacks, NavigationEntry } from '../ui/frame/frame-common'; +import { NativeWindow } from './native-window-common'; + +/** + * Android implementation of NativeWindow. + * Wraps an AppCompatActivity. + */ +export class AndroidNativeWindow extends NativeWindow { + private _activity: WeakRef; + + constructor(activity: androidx.appcompat.app.AppCompatActivity, id: string, isPrimary = false) { + super(id, isPrimary); + this._activity = new WeakRef(activity); + } + + /** + * The wrapped Android Activity (may be GC'd). + */ + get activity(): androidx.appcompat.app.AppCompatActivity | undefined { + return this._activity?.deref(); + } + + get androidWindow() { + const activity = this.activity; + if (!activity) { + return undefined; + } + return { activity }; + } + + /** + * Platform-specific: apply the view as root content of this Activity. + */ + protected _setNativeContent(view: View): void { + const activity = this.activity; + if (!activity) { + throw new Error('NativeWindow: Activity is no longer available.'); + } + + const callbacks: AndroidActivityCallbacks = (activity as any)['_callbacks']; + if (!callbacks) { + throw new Error('NativeWindow: Cannot find activity callbacks.'); + } + callbacks.resetActivityContent(activity); + } + + /** + * Close this window by finishing the activity. + */ + close(): void { + if (this.isPrimary) { + console.log('NativeWindow: Cannot close the primary window.'); + return; + } + + const activity = this.activity; + if (activity) { + activity.finish(); + } + } + + // --- Platform getters --- + + protected _getOrientation(): 'portrait' | 'landscape' | 'unknown' { + const activity = this.activity; + if (!activity) { + return 'unknown'; + } + const configuration = activity.getResources().getConfiguration(); + return this._getOrientationValue(configuration); + } + + protected _getSystemAppearance(): 'light' | 'dark' | null { + const activity = this.activity; + if (!activity) { + return null; + } + const configuration = activity.getResources().getConfiguration(); + return this._getSystemAppearanceValue(configuration); + } + + protected _getLayoutDirection(): CoreTypes.LayoutDirectionType | null { + const activity = this.activity; + if (!activity) { + return null; + } + const configuration = activity.getResources().getConfiguration(); + return this._getLayoutDirectionValue(configuration); + } + + // --- Value converters --- + + _getOrientationValue(configuration: android.content.res.Configuration): 'portrait' | 'landscape' | 'unknown' { + switch (configuration.orientation) { + case android.content.res.Configuration.ORIENTATION_LANDSCAPE: + return 'landscape'; + case android.content.res.Configuration.ORIENTATION_PORTRAIT: + return 'portrait'; + default: + return 'unknown'; + } + } + + _getSystemAppearanceValue(configuration: android.content.res.Configuration): 'dark' | 'light' { + const mode = configuration.uiMode & android.content.res.Configuration.UI_MODE_NIGHT_MASK; + switch (mode) { + case android.content.res.Configuration.UI_MODE_NIGHT_YES: + return 'dark'; + case android.content.res.Configuration.UI_MODE_NIGHT_NO: + case android.content.res.Configuration.UI_MODE_NIGHT_UNDEFINED: + default: + return 'light'; + } + } + + _getLayoutDirectionValue(configuration: android.content.res.Configuration): CoreTypes.LayoutDirectionType { + switch (configuration.getLayoutDirection()) { + case android.view.View.LAYOUT_DIRECTION_RTL: + return CoreTypes.LayoutDirection.rtl; + case android.view.View.LAYOUT_DIRECTION_LTR: + default: + return CoreTypes.LayoutDirection.ltr; + } + } + + /** + * @internal + */ + _destroy(): void { + super._destroy(); + this._activity = null; + } + + /** + * Gets a stable identifier from an Activity. + */ + static getActivityId(activity: androidx.appcompat.app.AppCompatActivity): string { + return `activity-${activity.hashCode()}`; + } +} diff --git a/packages/core/native-window/native-window.ios.ts b/packages/core/native-window/native-window.ios.ts new file mode 100644 index 0000000000..90f55e8a2d --- /dev/null +++ b/packages/core/native-window/native-window.ios.ts @@ -0,0 +1,215 @@ +import type { View } from '../ui/core/view'; +import { IOSHelper } from '../ui/core/view/view-helper'; +import { SDK_VERSION } from '../utils/constants'; +import { CoreTypes } from '../core-types'; +import { NativeWindow } from './native-window-common'; +import { NativeWindowEvents } from './native-window-interfaces'; + +/** + * iOS implementation of NativeWindow. + * Wraps a UIWindowScene + UIWindow pair. + */ +export class IOSNativeWindow extends NativeWindow { + private _scene: UIWindowScene; + private _window: UIWindow; + + constructor(scene: UIWindowScene, window: UIWindow, id: string, isPrimary = false) { + super(id, isPrimary); + this._scene = scene; + this._window = window; + } + + get iosWindow() { + return { + scene: this._scene, + window: this._window, + }; + } + + /** + * Platform-specific: set the view as root content of this UIWindow. + */ + protected _setNativeContent(view: View): void { + const controller = this._getViewController(view); + this._setViewControllerView(view); + + const haveController = this._window.rootViewController !== null; + this._window.rootViewController = controller; + + if (!haveController) { + this._window.makeKeyAndVisible(); + } + + // Listen for trait collection changes per-window + view.on(IOSHelper.traitCollectionColorAppearanceChangedEvent, () => { + const userInterfaceStyle = controller.traitCollection.userInterfaceStyle; + this._setSystemAppearance(this._getSystemAppearanceValue(userInterfaceStyle)); + }); + + view.on(IOSHelper.traitCollectionLayoutDirectionChangedEvent, () => { + const layoutDirection = controller.traitCollection.layoutDirection; + this._setLayoutDirection(this._getLayoutDirectionValue(layoutDirection)); + }); + } + + /** + * Close this window/scene. + */ + close(): void { + if (this.isPrimary) { + console.log('NativeWindow: Cannot close the primary window.'); + return; + } + + const session = this._scene?.session; + if (!session) { + console.log('NativeWindow: Scene has no session to destroy.'); + return; + } + + const app = UIApplication.sharedApplication; + if (app.requestSceneSessionDestructionOptionsErrorHandler) { + app.requestSceneSessionDestructionOptionsErrorHandler(session, null, (error: NSError) => { + if (error) { + console.log('NativeWindow: Error destroying scene session:', error.localizedDescription); + } + }); + } else { + console.log('NativeWindow: Scene destruction API not available on this iOS version.'); + } + } + + // --- Platform getters --- + + protected _getOrientation(): 'portrait' | 'landscape' | 'unknown' { + if (__VISIONOS__) { + return this._getOrientationValue(NativeScriptEmbedder.sharedInstance().windowScene?.interfaceOrientation); + } + if (this._scene) { + return this._getOrientationValue(this._scene.interfaceOrientation); + } + return this._getOrientationValue(UIApplication.sharedApplication.statusBarOrientation); + } + + protected _getSystemAppearance(): 'light' | 'dark' | null { + if (!__VISIONOS__ && SDK_VERSION <= 11) { + return null; + } + const rootVC = this._window?.rootViewController; + if (!rootVC) { + return null; + } + return this._getSystemAppearanceValue(rootVC.traitCollection.userInterfaceStyle); + } + + protected _getLayoutDirection(): CoreTypes.LayoutDirectionType | null { + const rootVC = this._window?.rootViewController; + if (!rootVC) { + return null; + } + return this._getLayoutDirectionValue(rootVC.traitCollection.layoutDirection); + } + + // --- Value converters --- + + private _getOrientationValue(orientation: number): 'portrait' | 'landscape' | 'unknown' { + switch (orientation) { + case UIInterfaceOrientation.LandscapeRight: + case UIInterfaceOrientation.LandscapeLeft: + return 'landscape'; + case UIInterfaceOrientation.PortraitUpsideDown: + case UIInterfaceOrientation.Portrait: + return 'portrait'; + case UIInterfaceOrientation.Unknown: + default: + return 'unknown'; + } + } + + _getSystemAppearanceValue(userInterfaceStyle: number): 'dark' | 'light' { + switch (userInterfaceStyle) { + case UIUserInterfaceStyle.Dark: + return 'dark'; + case UIUserInterfaceStyle.Light: + case UIUserInterfaceStyle.Unspecified: + default: + return 'light'; + } + } + + _getLayoutDirectionValue(layoutDirection: number): CoreTypes.LayoutDirectionType { + switch (layoutDirection) { + case UITraitEnvironmentLayoutDirection.RightToLeft: + return CoreTypes.LayoutDirection.rtl; + case UITraitEnvironmentLayoutDirection.LeftToRight: + default: + return CoreTypes.LayoutDirection.ltr; + } + } + + // --- ViewController helpers --- + + private _getViewController(rootView: View): UIViewController { + let viewController: UIViewController = rootView.viewController || rootView.ios; + + if (!(viewController instanceof UIViewController)) { + viewController = IOSHelper.UILayoutViewController.initWithOwner(new WeakRef(rootView)) as UIViewController; + rootView.viewController = viewController; + } + + return viewController; + } + + private _setViewControllerView(view: View): void { + const viewController: UIViewController = view.viewController || view.ios; + const nativeView = view.ios || view.nativeViewProtected; + + if (!nativeView || !viewController) { + throw new Error('Root should be either UIViewController or UIView'); + } + + if (viewController instanceof IOSHelper.UILayoutViewController) { + viewController.view.addSubview(nativeView); + } + } + + /** + * @internal + */ + _destroy(): void { + // Remove trait collection listeners from root view before destroying + if (this._rootView) { + this._rootView.off(IOSHelper.traitCollectionColorAppearanceChangedEvent); + this._rootView.off(IOSHelper.traitCollectionLayoutDirectionChangedEvent); + } + super._destroy(); + this._scene = null; + this._window = null; + } + + /** + * Gets the stable scene identifier. + */ + static getSceneId(scene: UIWindowScene): string { + try { + if (!scene) { + return 'unknown'; + } + const session = scene.session; + const persistentId = session?.persistentIdentifier; + if (persistentId) { + return `${persistentId}`; + } + if (scene.hash != null) { + return `${scene.hash}`; + } + const desc = scene.description; + if (desc) { + return `${desc}`; + } + } catch { + // ignore + } + return 'unknown'; + } +} diff --git a/packages/core/ui/frame/index.android.ts b/packages/core/ui/frame/index.android.ts index b76a6fd80b..629f78d29c 100644 --- a/packages/core/ui/frame/index.android.ts +++ b/packages/core/ui/frame/index.android.ts @@ -15,6 +15,7 @@ import { getAppMainEntry } from '../../application/helpers-common'; import { AndroidActivityBackPressedEventData, AndroidActivityNewIntentEventData, AndroidActivityRequestPermissionsEventData, AndroidActivityResultEventData } from '../../application/application-interfaces'; import { Application } from '../../application/application'; +import { NativeWindowEvents } from '../../native-window/native-window-interfaces'; import { isEmbedded, setEmbeddedView } from '../embedding'; import { CALLBACKS, FRAMEID, framesCache, setFragmentCallbacks } from './frame-helper-for-android'; import { SDK_VERSION } from '../../utils'; @@ -783,13 +784,23 @@ if (SDK_VERSION >= 33) { } const args = { - eventName: 'activityBackPressed', + eventName: NativeWindowEvents.activityBackPressed, object: Application, android: Application.android, activity: activity, cancel: false, }; + // Emit on NativeWindow first + const nativeWindow = Application.android._getWindowForActivity(activity); + if (nativeWindow) { + nativeWindow.notify({ + ...args, + object: nativeWindow, + } as AndroidActivityBackPressedEventData); + } + + // @deprecated - Bridge to Application.android for backward compat Application.android.notify(args); if (args.cancel) { @@ -804,7 +815,7 @@ if (SDK_VERSION >= 33) { if (view) { const viewArgs = { - eventName: 'activityBackPressed', + eventName: NativeWindowEvents.activityBackPressed, object: view, activity: activity, cancel: false, @@ -871,12 +882,24 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks } if (intent && intent.getAction()) { - Application.android.notify({ - eventName: Application.AndroidApplication.activityNewIntentEvent, + const newIntentArgs = { + eventName: NativeWindowEvents.activityNewIntent, object: Application.android, activity, intent, - }); + }; + + // Emit on NativeWindow first + const nativeWindow = Application.android._getWindowForActivity(activity); + if (nativeWindow) { + nativeWindow.notify({ + ...newIntentArgs, + object: nativeWindow, + } as AndroidActivityNewIntentEventData); + } + + // @deprecated - Bridge to Application.android for backward compat + Application.android.notify(newIntentArgs); } this.setActivityContent(activity, savedInstanceState, true); @@ -902,12 +925,24 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks superFunc.call(activity, intent); superSetIntentFunc.call(activity, intent); - Application.android.notify({ - eventName: Application.AndroidApplication.activityNewIntentEvent, + const newIntentArgs = { + eventName: NativeWindowEvents.activityNewIntent, object: Application.android, activity, intent, - }); + }; + + // Emit on NativeWindow first + const nativeWindow = Application.android._getWindowForActivity(activity); + if (nativeWindow) { + nativeWindow.notify({ + ...newIntentArgs, + object: nativeWindow, + } as AndroidActivityNewIntentEventData); + } + + // @deprecated - Bridge to Application.android for backward compat + Application.android.notify(newIntentArgs); } @profile @@ -996,12 +1031,23 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks } const args = { - eventName: 'activityBackPressed', + eventName: NativeWindowEvents.activityBackPressed, object: Application, android: Application.android, activity: activity, cancel: false, }; + + // Emit on NativeWindow first + const nativeWindow = Application.android._getWindowForActivity(activity); + if (nativeWindow) { + nativeWindow.notify({ + ...args, + object: nativeWindow, + } as AndroidActivityBackPressedEventData); + } + + // @deprecated - Bridge to Application.android for backward compat Application.android.notify(args); if (args.cancel) { return; @@ -1011,7 +1057,7 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks let callSuper = false; const viewArgs = { - eventName: 'activityBackPressed', + eventName: NativeWindowEvents.activityBackPressed, object: view, activity: activity, cancel: false, @@ -1034,15 +1080,27 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks Trace.write('NativeScriptActivity.onRequestPermissionsResult;', Trace.categories.NativeLifecycle); } - Application.android.notify({ - eventName: 'activityRequestPermissions', + const permArgs = { + eventName: NativeWindowEvents.activityRequestPermissions, object: Application, android: Application.android, activity: activity, requestCode: requestCode, permissions: permissions, grantResults: grantResults, - }); + }; + + // Emit on NativeWindow first + const nativeWindow = Application.android._getWindowForActivity(activity); + if (nativeWindow) { + nativeWindow.notify({ + ...permArgs, + object: nativeWindow, + } as AndroidActivityRequestPermissionsEventData); + } + + // @deprecated - Bridge to Application.android for backward compat + Application.android.notify(permArgs); } @profile @@ -1052,15 +1110,27 @@ export class ActivityCallbacksImplementation implements AndroidActivityCallbacks Trace.write(`NativeScriptActivity.onActivityResult(${requestCode}, ${resultCode}, ${data})`, Trace.categories.NativeLifecycle); } - Application.android.notify({ - eventName: 'activityResult', + const resultArgs = { + eventName: NativeWindowEvents.activityResult, object: Application, android: Application.android, activity: activity, requestCode: requestCode, resultCode: resultCode, intent: data, - }); + }; + + // Emit on NativeWindow first + const nativeWindow = Application.android._getWindowForActivity(activity); + if (nativeWindow) { + nativeWindow.notify({ + ...resultArgs, + object: nativeWindow, + } as AndroidActivityResultEventData); + } + + // @deprecated - Bridge to Application.android for backward compat + Application.android.notify(resultArgs); } public resetActivityContent(activity: androidx.appcompat.app.AppCompatActivity): void { diff --git a/tools/assets/App_Resources/iOS/Info.plist b/tools/assets/App_Resources/iOS/Info.plist index 78642afa97..53e75e7496 100644 --- a/tools/assets/App_Resources/iOS/Info.plist +++ b/tools/assets/App_Resources/iOS/Info.plist @@ -60,18 +60,5 @@ UIWindowSceneSessionRoleApplication UIApplicationSupportsMultipleScenes - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - SceneDelegate - - - -