feat(ios): multi-window support (#10786)

This commit is contained in:
Nathan Walker
2025-11-03 17:01:43 -08:00
committed by GitHub
parent c8c741072f
commit 96733c2ecc
13 changed files with 1623 additions and 71 deletions

View File

@@ -0,0 +1,523 @@
import { Observable, EventData, Page, Application, Frame, StackLayout, Label, Button, Dialogs, View, Color, SceneEvents, SceneEventData, Utils } from '@nativescript/core';
let page: Page;
let viewModel: MultipleScenesModel;
export function navigatingTo(args: EventData) {
page = <Page>args.object;
viewModel = new MultipleScenesModel();
page.bindingContext = viewModel;
}
export class MultipleScenesModel extends Observable {
private _sceneCount = 0;
private _isMultiSceneSupported = false;
private _currentScenes: any[] = [];
private _currentWindows: any[] = [];
private _sceneEvents: string[] = [];
constructor() {
super();
this.checkSceneSupport();
this.setupSceneEventListeners();
this.updateSceneInfo();
this.checkSceneDelegateRegistration();
}
get sceneCount(): number {
return this._sceneCount;
}
get isMultiSceneSupported(): boolean {
return this._isMultiSceneSupported;
}
get currentScenes(): any[] {
return this._currentScenes;
}
get currentWindows(): any[] {
return this._currentWindows;
}
get sceneEvents(): string[] {
return this._sceneEvents;
}
get canCreateNewScene(): boolean {
return this._isMultiSceneSupported && __APPLE__;
}
get statusText(): string {
if (!__APPLE__) {
return 'Scene support is only available on iOS';
}
if (!this._isMultiSceneSupported) {
return 'Multi-scene support not enabled. Add scene configuration to Info.plist';
}
// Check which API is available
let apiInfo = '';
try {
if (typeof UIApplication !== 'undefined') {
const app = UIApplication.sharedApplication;
if (typeof app.activateSceneSessionForRequestErrorHandler === 'function') {
apiInfo = ' (iOS 17+ API available)';
} else if (typeof app.requestSceneSessionActivationUserActivityOptionsErrorHandler === 'function') {
apiInfo = ' (iOS 13-16 API available)';
}
}
} catch (e) {
// Ignore errors in API detection
}
return `Multi-scene support enabled. ${this._sceneCount} scene(s) active${apiInfo}`;
}
private checkSceneSupport() {
if (__APPLE__) {
try {
// Check if the supportsScenes method exists and call it
if (typeof Application.ios.supportsScenes === 'function') {
this._isMultiSceneSupported = Application.ios.supportsScenes();
} else {
// Fallback: check for scene manifest in bundle
this._isMultiSceneSupported = false;
try {
const bundle = NSBundle.mainBundle;
const sceneManifest = bundle.objectForInfoDictionaryKey('UIApplicationSceneManifest');
this._isMultiSceneSupported = !!sceneManifest;
} catch (e) {
console.log('Error checking scene manifest:', e);
}
}
console.log('Scene support check:', this._isMultiSceneSupported);
} catch (error) {
console.log('Error checking scene support:', error);
this._isMultiSceneSupported = false;
}
}
this.notifyPropertyChange('isMultiSceneSupported', this._isMultiSceneSupported);
this.notifyPropertyChange('canCreateNewScene', this.canCreateNewScene);
this.notifyPropertyChange('statusText', this.statusText);
}
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)}`);
this.updateSceneInfo();
});
Application.on(SceneEvents.sceneDidActivate, (args: SceneEventData) => {
this.addSceneEvent(`Scene Did Activate: ${this.getSceneDescription(args.scene)}`);
this.updateSceneInfo();
});
Application.on(SceneEvents.sceneWillResignActive, (args: SceneEventData) => {
this.addSceneEvent(`Scene Will Resign Active: ${this.getSceneDescription(args.scene)}`);
});
Application.on(SceneEvents.sceneWillEnterForeground, (args: SceneEventData) => {
this.addSceneEvent(`Scene Will Enter Foreground: ${this.getSceneDescription(args.scene)}`);
});
Application.on(SceneEvents.sceneDidEnterBackground, (args: SceneEventData) => {
this.addSceneEvent(`Scene Did Enter Background: ${this.getSceneDescription(args.scene)}`);
});
Application.on(SceneEvents.sceneDidDisconnect, (args: SceneEventData) => {
this.addSceneEvent(`Scene Did Disconnect: ${this.getSceneDescription(args.scene)}`);
this.updateSceneInfo();
});
// 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);
});
}
private getSceneDescription(scene: UIWindowScene): string {
if (!scene) return 'Unknown';
return `Scene ${this.getSceneId(scene)}`;
}
private getSceneId(scene: UIWindowScene): string {
return scene?.hash ? `${scene?.hash}` : scene?.description || 'Unknown';
}
private setupSceneContent(args: SceneEventData) {
if (!args.scene || !args.window || !__APPLE__) return;
try {
let nsViewId: string;
if (args.connectionOptions?.userActivities?.count > 0) {
const activity = args.connectionOptions.userActivities.allObjects.objectAtIndex(0) as NSUserActivity;
nsViewId = Utils.dataDeserialize(activity.userInfo).id;
}
console.log('--- Open scene for nsViewId:', nsViewId);
let page: Page;
switch (nsViewId) {
case 'newSceneBasic':
page = this._createPageForScene(args.scene, args.window);
break;
case 'newSceneAlt':
page = this._createAltPageForScene(args.scene, args.window);
break;
// 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)}`);
} catch (error) {
this.addSceneEvent(`Error setting up scene content: ${error.message}`);
}
}
/**
* Note: When creating UI's with plain core, buttons will be garbage collected if not referenced.
* Particularly when opening many new scenes.
* If the button is GC'd by the system, the taps will no longer function.
* This is more related to iOS delegates getting GC'd than anything.
* Most flavors circumvent things like that because their components are retained.
* We circumvent the core demo (xml ui) issue but just retaining a map of the created UI buttons.
*/
private _closeButtons = new Map<string, Button>();
private _createPageForScene(scene: UIWindowScene, window: UIWindow): Page {
const page = new Page();
page.backgroundColor = new Color('#cdffdb');
// Create a simple layout for the new scene
const layout = new StackLayout();
layout.padding = 32;
page.content = layout;
// Add title
const title = new Label();
title.text = 'New NativeScript Scene';
title.fontSize = 35;
title.fontWeight = 'bold';
title.textAlignment = 'center';
title.marginBottom = 30;
layout.addChild(title);
// Add scene info
const sceneInfo = new Label();
sceneInfo.text = `Scene ID: ${scene.hash || 'Unknown'}\nWindow: ${window.description || 'Unknown'}`;
sceneInfo.fontSize = 22;
sceneInfo.textAlignment = 'center';
sceneInfo.marginBottom = 25;
layout.addChild(sceneInfo);
// Add close button
const closeButton = new Button();
const sceneId = this.getSceneId(scene);
closeButton.id = sceneId;
console.log('scene assigning id to button:', closeButton.id);
closeButton.text = 'Close This Scene';
closeButton.fontSize = 22;
closeButton.fontWeight = 'bold';
closeButton.backgroundColor = '#ff4444';
closeButton.color = new Color('white');
closeButton.borderRadius = 8;
closeButton.padding = 16;
closeButton.width = 300;
closeButton.horizontalAlignment = 'center';
closeButton.on('tap', this._closeScene.bind(this));
// retain the close button so we don't lose the tap (iOS delegate binding)
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;
}
private _createAltPageForScene(scene: UIWindowScene, window: UIWindow): Page {
const page = new Page();
page.backgroundColor = new Color('#65ADF1');
// Create a simple layout for the new scene
const layout = new StackLayout();
layout.padding = 32;
page.content = layout;
// Add title
const title = new Label();
title.text = 'New Alternate NativeScript Scene';
title.fontSize = 35;
title.color = new Color('blue');
title.fontWeight = 'bold';
title.textAlignment = 'center';
title.marginBottom = 35;
layout.addChild(title);
// Add scene info
const sceneInfo = new Label();
sceneInfo.text = `Scene ID: ${scene.hash || 'Unknown'}\nWindow: ${window.description || 'Unknown'}`;
sceneInfo.fontSize = 24;
sceneInfo.textAlignment = 'center';
sceneInfo.marginBottom = 32;
layout.addChild(sceneInfo);
// Add close button
const closeButton = new Button();
const sceneId = this.getSceneId(scene);
closeButton.id = sceneId;
console.log('scene assigning id to button:', closeButton.id);
closeButton.text = 'Close This Scene';
closeButton.fontSize = 25;
closeButton.fontWeight = 'bold';
closeButton.backgroundColor = '#006ead';
closeButton.color = new Color('white');
closeButton.borderRadius = 8;
closeButton.padding = 16;
closeButton.width = 350;
closeButton.horizontalAlignment = 'center';
closeButton.on('tap', this._closeScene.bind(this));
// retain the close button so we don't lose the tap (iOS delegate binding)
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;
}
private _closeScene(args: EventData) {
const btn = args.object as Button;
console.log('closing scene from button tap');
// Let core resolve the scene from the view/window context
Application.ios.closeWindow(btn);
}
private getSceneAPIInfo(): string {
if (!__APPLE__) return 'Not iOS';
try {
if (typeof UIApplication !== 'undefined') {
const app = UIApplication.sharedApplication;
if (typeof app.activateSceneSessionForRequestErrorHandler === 'function') {
return `iOS ${Utils.SDK_VERSION} - Modern API (iOS 17+) available`;
} else if (typeof app.requestSceneSessionActivationUserActivityOptionsErrorHandler === 'function') {
return `iOS ${Utils.SDK_VERSION} - Legacy API (iOS 13-16) available`;
} else {
return `iOS ${Utils.SDK_VERSION} - No scene activation API available`;
}
}
} catch (e) {
return `Error detecting API: ${e.message}`;
}
return 'Unknown API status';
}
private addSceneEvent(event: string) {
const timestamp = new Date().toLocaleTimeString();
const evt = `${timestamp}: ${event}`;
this._sceneEvents.unshift(evt);
console.log(evt);
// Keep only last 20 events
if (this._sceneEvents.length > 20) {
this._sceneEvents = this._sceneEvents.slice(0, 20);
}
this.notifyPropertyChange('sceneEvents', this._sceneEvents);
}
private updateSceneInfo() {
if (__APPLE__ && this._isMultiSceneSupported) {
try {
this._currentScenes = Application.ios.getAllScenes() || [];
this._currentWindows = Application.ios.getAllWindows() || [];
this._sceneCount = this._currentScenes.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);
}
onCreateNewScene() {
Application.ios.openWindow({ id: 'newSceneBasic' });
}
onCreateNewSceneAlt() {
Application.ios.openWindow({ id: 'newSceneAlt' });
}
onRefreshSceneInfo() {
this.updateSceneInfo();
this.addSceneEvent('Scene info refreshed');
}
onClearEvents() {
this._sceneEvents = [];
this.notifyPropertyChange('sceneEvents', this._sceneEvents);
}
onShowSceneDetails() {
if (!__APPLE__) {
Dialogs.alert({
title: 'Scene Details',
message: 'Scene functionality is only available on iOS 13+',
okButtonText: 'OK',
});
return;
}
const apiInfo = this.getSceneAPIInfo();
const sceneInfo = this.getSceneAPIInfo(); // Using getSceneAPIInfo for now
// Add SceneDelegate registration info
let delegateInfo = '\n--- SceneDelegate Status ---\n';
if (typeof global.SceneDelegate !== 'undefined') {
delegateInfo += '✅ SceneDelegate: Registered globally\n';
} else {
delegateInfo += '❌ SceneDelegate: NOT registered\n';
}
if (typeof UIWindowSceneDelegate !== 'undefined') {
delegateInfo += '✅ UIWindowSceneDelegate: Available\n';
} else {
delegateInfo += '❌ UIWindowSceneDelegate: Not available\n';
}
const fullDetails = `${sceneInfo}\n\n${apiInfo}${delegateInfo}`;
// Show in alert dialog
const alertOptions = {
title: 'Scene & API Details',
message: fullDetails,
okButtonText: 'OK',
};
Dialogs.alert(alertOptions);
}
onTestSceneAPI() {
const apiInfo = this.getSceneAPIInfo();
this.addSceneEvent(`API Test: ${apiInfo}`);
// Also log current scene/window counts
this.addSceneEvent(`Current state: ${this._currentScenes.length} scenes, ${this._currentWindows.length} windows`);
// Add device and system info
try {
const device = UIDevice.currentDevice;
this.addSceneEvent(`Device: ${device.model} (${device.systemName} ${device.systemVersion})`);
// Check if this is iPad (more likely to support multiple scenes)
if (device.userInterfaceIdiom === UIUserInterfaceIdiom.Pad) {
this.addSceneEvent('📱 iPad detected - better scene support expected');
} else {
this.addSceneEvent('📱 iPhone detected - limited scene support');
}
} catch (e) {
this.addSceneEvent('Could not get device info');
}
}
private checkSceneDelegateRegistration() {
if (!__APPLE__) {
return;
}
this.addSceneEvent('Checking SceneDelegate registration...');
// Check if SceneDelegate is available globally
if (typeof global.SceneDelegate !== 'undefined') {
this.addSceneEvent('✅ SceneDelegate is registered globally');
} else {
this.addSceneEvent('❌ SceneDelegate NOT found in global scope!');
}
// Check if UIWindowSceneDelegate is available
if (typeof UIWindowSceneDelegate !== 'undefined') {
this.addSceneEvent('✅ UIWindowSceneDelegate protocol available');
} else {
this.addSceneEvent('❌ UIWindowSceneDelegate protocol not available');
}
}
// private closeScene(scene: UIWindowScene) {
// if (!scene || !__APPLE__) return;
// try {
// this.addSceneEvent(`Attempting to close scene: ${this.getSceneDescription(scene)}`);
// // Get the scene session
// const session = scene.session;
// if (session) {
// // Check if this is the primary scene (typically can't be closed)
// const isPrimaryScene = Application.ios.getPrimaryScene() === scene;
// const sceneId = this.getSceneId(scene);
// console.log('isPrimaryScene:', isPrimaryScene, 'sceneId:', sceneId);
// if (isPrimaryScene) {
// this.addSceneEvent(`⚠️ This appears to be the primary scene`);
// this.addSceneEvent(`💡 Primary scenes typically cannot be closed programmatically`);
// return;
// } else {
// this.addSceneEvent(`✅ This appears to be a secondary scene - closure should work`);
// }
// // Try the correct iOS API for scene destruction
// const app = UIApplication.sharedApplication;
// if (app.requestSceneSessionDestructionOptionsErrorHandler) {
// this.addSceneEvent(`📞 Calling scene destruction API...`);
// app.requestSceneSessionDestructionOptionsErrorHandler(session, null, (error: NSError) => {
// if (error) {
// console.log('scene destroy error:', error);
// this.addSceneEvent(`❌ Scene destruction failed: ${error.localizedDescription}`);
// this.addSceneEvent(`📋 Error details - Domain: ${error.domain}, Code: ${error.code}`);
// // Provide specific guidance based on error
// if (error.localizedDescription.includes('primary') || error.code === 1) {
// this.addSceneEvent(`💡 Cannot close primary scene - this is iOS system behavior`);
// this.addSceneEvent(` Only secondary scenes can be closed via API`);
// } else if (error.code === 22 || error.domain.includes('FBSWorkspace')) {
// this.addSceneEvent(`💡 System declined scene destruction request`);
// this.addSceneEvent(`🔄 This may be due to system resource management`);
// } else {
// this.addSceneEvent(`🔍 Unexpected error - scene destruction may not be fully supported`);
// }
// this.addSceneEvent(`🖱️ Alternative: Use system UI to close (app switcher or split-screen controls)`);
// } else {
// this._closeButtons.delete(sceneId);
// this.addSceneEvent(`✅ Scene destruction request accepted`);
// this.addSceneEvent(`⏳ Scene should close within a few seconds...`);
// }
// });
// } else {
// this.addSceneEvent(`❌ Scene destruction API not available`);
// this.addSceneEvent(`📱 This iOS version/configuration may not support programmatic scene closure`);
// }
// } else {
// this.addSceneEvent('❌ Error: Could not find scene session to close');
// }
// } catch (error) {
// this.addSceneEvent(`❌ Error closing scene: ${error.message}`);
// }
// }
}