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

@@ -266,4 +266,76 @@ Button {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
/* Multiple Scenes Demo Styles */
.card {
background-color: #f8f9fa;
border-radius: 12;
border-width: 1;
border-color: #e9ecef;
margin-bottom: 15;
}
.code-block {
background-color: #2d3748;
color: #e2e8f0;
padding: 15;
border-radius: 8;
font-family: "Courier New", monospace;
font-size: 12;
}
.event-item {
background-color: #f1f3f4;
border-radius: 6;
margin-bottom: 5;
}
.event-item:nth-child(even) {
background-color: #e8eaed;
}
.border {
border-width: 1;
border-color: #dee2e6;
border-radius: 8;
background-color: white;
}
.btn {
border-radius: 8;
font-weight: bold;
}
.btn-outline {
background-color: transparent;
color: #007bff;
border-width: 2;
border-color: #007bff;
}
.h3 {
font-size: 18;
font-weight: bold;
color: #2c3e50;
}
.body {
font-size: 14;
color: #495057;
}
.caption {
font-size: 12;
color: #6c757d;
}
.font-weight-bold {
font-weight: bold;
}
.text-center {
text-align: center;
}

View File

@@ -1,12 +1,71 @@
import { EventData, Page, Utils } from '@nativescript/core';
import { Application, EventData, Page, SceneEventData, SceneEvents, Utils } from '@nativescript/core';
import { HelloWorldModel } from './main-view-model';
let initSceneEvents = false;
export function navigatingTo(args: EventData) {
const page = <Page>args.object;
page.bindingContext = new HelloWorldModel();
// Testing setting window background color
// if (global.isIOS) {
// if (__APPLE__) {
// Utils.ios.setWindowBackgroundColor('blue');
// }
// Note: can test global scene handling by uncommenting following
// Can also view the 'multiple-scenes' demo page in isolation
// setupSceneEvents();
}
function setupSceneEvents() {
if (initSceneEvents) {
return;
}
initSceneEvents = true;
if (__APPLE__) {
if (Application.ios.supportsScenes()) {
console.log('Supports multiple scenes:', Application.ios.supportsMultipleScenes());
// Get all windows and scenes
const windows = Application.ios.getAllWindows();
const scenes = Application.ios.getWindowScenes();
const primaryWindow = Application.ios.getPrimaryWindow();
console.log(`App has ${windows.length} windows`);
console.log(`App has ${scenes.length} scenes`);
console.log('Primary window:', primaryWindow);
// Check if using scene lifecycle
if (Application.ios.isUsingSceneLifecycle()) {
console.log('App is using scene-based lifecycle');
}
// Listen to scene events
Application.on(SceneEvents.sceneWillConnect, (args: SceneEventData) => {
console.log('New scene connecting:', args.scene);
console.log('Window:', args.window);
console.log('Connection options:', args.connectionOptions);
});
Application.on(SceneEvents.sceneDidActivate, (args: SceneEventData) => {
console.log('Scene became active:', args.scene);
});
Application.on(SceneEvents.sceneWillResignActive, (args: SceneEventData) => {
console.log('Scene will resign active:', args.scene);
});
Application.on(SceneEvents.sceneDidEnterBackground, (args: SceneEventData) => {
console.log('Scene entered background:', args.scene);
});
Application.on(SceneEvents.sceneWillEnterForeground, (args: SceneEventData) => {
console.log('Scene will enter foreground:', args.scene);
});
Application.on(SceneEvents.sceneDidDisconnect, (args: SceneEventData) => {
console.log('Scene disconnected:', args.scene);
});
} else {
console.log('Traditional single-window app');
}
}
}

View File

@@ -18,6 +18,7 @@
<Button text="image-handling" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
<Button text="labels" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
<Button text="list-page" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
<Button text="multiple-scenes" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
<Button text="root-layout" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
<Button text="scroll-view" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />
<Button text="status-bar" tap="{{ viewDemo }}" class="btn btn-primary btn-view-demo" />

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}`);
// }
// }
}

View File

@@ -0,0 +1,118 @@
<Page xmlns="http://schemas.nativescript.org/tns.xsd" navigatingTo="navigatingTo" class="page">
<Page.actionBar>
<ActionBar title="Multiple Scenes Demo" class="action-bar">
</ActionBar>
</Page.actionBar>
<ScrollView>
<StackLayout class="p-20">
<!-- Scene Support Status -->
<StackLayout class="card p-20 m-b-20">
<Label text="Scene Support Status" class="h3 text-center m-b-15"/>
<Label text="{{ statusText }}" class="body text-center m-b-15" textWrap="true"/>
<GridLayout columns="*, *" class="m-t-10">
<Label col="0" text="Scene Count:" class="body"/>
<Label col="1" text="{{ sceneCount }}" class="body font-weight-bold"/>
</GridLayout>
<Button text="Refresh Info" tap="{{ onRefreshSceneInfo }}" class="btn btn-outline m-t-15"/>
</StackLayout>
<!-- Scene Controls -->
<StackLayout class="card p-20 m-b-20" visibility="{{ canCreateNewScene ? 'visible' : 'collapsed' }}">
<Label text="Scene Controls (Test on Physical iPad Only)" class="h3 text-center m-b-15"/>
<Label text="Create additional windows on iPad (requires scene support enabled in Info.plist)"
class="caption text-center m-b-15" textWrap="true"/>
<GridLayout columns="*,*,*" class="m-t-10">
<Button col="0" text="New Scene" tap="{{ onCreateNewScene }}" class="btn btn-primary m-r-10"/>
<Button col="1" text="New Alternate Scene" tap="{{ onCreateNewSceneAlt }}" class="btn btn-primary m-r-10"/>
<Button col="2" text="Scene Details" tap="{{ onShowSceneDetails }}" class="btn btn-outline m-l-10"/>
</GridLayout>
<Button text="Test API Info" tap="{{ onTestSceneAPI }}" class="btn btn-outline m-t-10"/>
</StackLayout>
<!-- Configuration Help -->
<StackLayout class="card p-20 m-b-20" visibility="{{ isMultiSceneSupported ? 'collapsed' : 'visible' }}">
<Label text="Setup Instructions" class="h3 text-center m-b-15"/>
<Label text="To enable multi-scene support, add this to your Info.plist:"
class="body m-b-10" textWrap="true"/>
<Label class="code-block m-b-15" textWrap="true">
<Span text="&lt;key&gt;UIApplicationSceneManifest&lt;/key&gt;&#10;"/>
<Span text="&lt;dict&gt;&#10;"/>
<Span text=" &lt;key&gt;UIApplicationSupportsMultipleScenes&lt;/key&gt;&#10;"/>
<Span text=" &lt;true/&gt;&#10;"/>
<Span text=" &lt;key&gt;UISceneConfigurations&lt;/key&gt;&#10;"/>
<Span text=" &lt;dict&gt;&#10;"/>
<Span text=" &lt;key&gt;UIWindowSceneSessionRoleApplication&lt;/key&gt;&#10;"/>
<Span text=" &lt;array&gt;&#10;"/>
<Span text=" &lt;dict&gt;&#10;"/>
<Span text=" &lt;key&gt;UISceneDelegateClassName&lt;/key&gt;&#10;"/>
<Span text=" &lt;string&gt;SceneDelegate&lt;/string&gt;&#10;"/>
<Span text=" &lt;/dict&gt;&#10;"/>
<Span text=" &lt;/array&gt;&#10;"/>
<Span text=" &lt;/dict&gt;&#10;"/>
<Span text="&lt;/dict&gt;"/>
</Label>
<Label text="After adding this configuration, rebuild and test on iPad for best multi-window experience."
class="caption" textWrap="true"/>
</StackLayout>
<!-- Scene Events Log -->
<StackLayout class="card p-20 m-b-20">
<GridLayout columns="*, auto" class="m-b-15">
<Label col="0" text="Scene Events Log" class="h3"/>
<Button col="1" text="Clear" tap="{{ onClearEvents }}" class="btn btn-outline"/>
</GridLayout>
<ScrollView height="250" class="border">
<StackLayout class="p-10">
<Label visibility="{{ sceneEvents.length === 0 ? 'visible' : 'collapsed' }}"
text="No scene events yet. Scene events will appear here when scenes are created, activated, or destroyed."
class="caption text-center" textWrap="true"/>
<Repeater items="{{ sceneEvents }}">
<Repeater.itemTemplate>
<StackLayout class="event-item p-5">
<Label text="{{ $value }}" class="caption" textWrap="true"/>
</StackLayout>
</Repeater.itemTemplate>
</Repeater>
</StackLayout>
</ScrollView>
</StackLayout>
<!-- Usage Instructions -->
<StackLayout class="card p-20 m-b-20">
<Label text="How to Test" class="h3 text-center m-b-15"/>
<StackLayout class="m-b-10">
<Label text="1. iPad Testing:" class="body font-weight-bold"/>
<Label text="• Use iPad with iOS 13+ for multi-window experience" class="caption m-l-15" textWrap="true"/>
<Label text="• iOS 17+: Uses modern activateSceneSessionForRequestErrorHandler API" class="caption m-l-15" textWrap="true"/>
<Label text="• iOS 13-16: Uses legacy requestSceneSessionActivation API" class="caption m-l-15" textWrap="true"/>
<Label text="• Tap 'New Scene' to request additional windows" class="caption m-l-15" textWrap="true"/>
<Label text="• Use split view or slide over gestures" class="caption m-l-15" textWrap="true"/>
</StackLayout>
<StackLayout class="m-b-10">
<Label text="2. iPhone Testing:" class="body font-weight-bold"/>
<Label text="• Scene events still work but limited to single window" class="caption m-l-15" textWrap="true"/>
<Label text="• Watch background/foreground transitions" class="caption m-l-15" textWrap="true"/>
</StackLayout>
<StackLayout>
<Label text="3. Scene Events:" class="body font-weight-bold"/>
<Label text="• Monitor the events log for scene lifecycle changes" class="caption m-l-15" textWrap="true"/>
<Label text="• Each scene activation/deactivation is logged" class="caption m-l-15" textWrap="true"/>
</StackLayout>
</StackLayout>
</StackLayout>
</ScrollView>
</Page>

View File

@@ -80,6 +80,19 @@ function applySdkVersionClass(rootView: View): void {
const globalEvents = getNativeScriptGlobals().events;
// Scene lifecycle event names
export const SceneEvents = {
sceneWillConnect: 'sceneWillConnect',
sceneDidActivate: 'sceneDidActivate',
sceneWillResignActive: 'sceneWillResignActive',
sceneWillEnterForeground: 'sceneWillEnterForeground',
sceneDidEnterBackground: 'sceneDidEnterBackground',
sceneDidDisconnect: 'sceneDidDisconnect',
sceneContentSetup: 'sceneContentSetup',
};
export type SceneEventName = (typeof SceneEvents)[keyof typeof SceneEvents];
// helper interface to correctly type Application event handlers
interface ApplicationEvents {
off(eventNames: string, callback?: any, thisArg?: any): void;

View File

@@ -216,3 +216,28 @@ export interface AndroidActivityBackPressedEventData extends AndroidActivityEven
export interface LoadAppCSSEventData extends ApplicationEventData {
cssFile: string;
}
/**
* iOS Event data containing information for scene lifecycle events (iOS 13+).
*/
export interface SceneEventData extends ApplicationEventData {
/**
* The UIWindowScene instance associated with this event.
*/
scene?: UIWindowScene;
/**
* The UIWindow associated with this scene (if applicable).
*/
window?: UIWindow;
/**
* Scene connection options (for sceneWillConnect event).
*/
connectionOptions?: UISceneConnectionOptions;
/**
* Additional user info from the notification.
*/
userInfo?: NSDictionary<any, any>;
}

View File

@@ -192,6 +192,72 @@ export class iOSApplication extends ApplicationCommon {
* @param onReceiveCallback A callback function that will be called each time the observer receives a notification.
*/
removeNotificationObserver(observer: any, notificationName: string);
/**
* Checks if the application supports scenes.
*/
supportsScenes(): boolean;
/**
* Checks if the application supports multiple scenes.
*/
supportsMultipleScenes(): boolean;
/**
* Checks if the application is using the scene lifecycle.
*/
isUsingSceneLifecycle(): boolean;
/**
* Opens a new window with the specified data.
* @param data The data to pass to the new window.
*/
openWindow(data: Record<any, any>): void;
/**
* Closes a secondary window/scene.
* If no target is provided, attempts to close a non-primary active scene.
* @param target Optional target to resolve the scene to close. Can be a View, UIWindow, UIWindowScene, or a string scene identifier.
*/
closeWindow(target?: View | UIWindow | UIWindowScene | string): void;
/**
* Gets all windows for the application.
*/
getAllWindows(): UIWindow[];
/**
* Gets all scenes for the application.
*/
getAllScenes(): UIScene[];
/**
* Gets all window scenes for the application.
*/
getWindowScenes(): UIWindowScene[];
/**
* Gets the primary window for the application.
*/
getPrimaryWindow(): UIWindow;
/**
* Gets the primary scene for the application.
*/
getPrimaryScene(): UIWindowScene;
/**
* Sets the root view for a specific window.
* @param window The window to set the root view for.
* @param view The view to set as the root view.
*/
setWindowRootView(window: UIWindow, view: View): void;
/**
* The scene delegate for the application.
* Get the current one or set a custom one.
*/
sceneDelegate: UIWindowSceneDelegate;
}
export const VALID_FONT_SCALES: number[];

View File

@@ -5,9 +5,9 @@ import { IOSHelper } from '../ui/core/view/view-helper';
import type { NavigationEntry } from '../ui/frame/frame-interfaces';
import { getWindow } from '../utils/native-helper';
import { SDK_VERSION } from '../utils/constants';
import { ios as iosUtils } from '../utils/native-helper';
import { ApplicationCommon, initializeSdkVersionClass } from './application-common';
import { ApplicationEventData } from './application-interfaces';
import { ios as iosUtils, dataSerialize } from '../utils/native-helper';
import { ApplicationCommon, initializeSdkVersionClass, SceneEvents } from './application-common';
import { ApplicationEventData, SceneEventData } from './application-interfaces';
import { Observable } from '../data/observable';
import { Trace } from '../trace';
import {
@@ -81,6 +81,31 @@ class CADisplayLinkTarget extends NSObject {
};
}
/**
* Detect if the app supports scenes.
* When an app configures UIApplicationSceneManifest in Info.plist
* it will use scene lifecycle management.
*/
let sceneManifest: NSDictionary<any, any>;
function supportsScenes(): boolean {
if (SDK_VERSION < 13) {
return false;
}
if (typeof sceneManifest === 'undefined') {
// Check if scene manifest exists in Info.plist
sceneManifest = NSBundle.mainBundle.objectForInfoDictionaryKey('UIApplicationSceneManifest');
}
return !!sceneManifest;
}
function supportsMultipleScenes(): boolean {
if (SDK_VERSION < 13) {
return false;
}
return UIApplication.sharedApplication?.supportsMultipleScenes;
}
@NativeClass
class Responder extends UIResponder implements UIApplicationDelegate {
get window(): UIWindow {
@@ -94,10 +119,129 @@ class Responder extends UIResponder implements UIApplicationDelegate {
static ObjCProtocols = [UIApplicationDelegate];
}
if (supportsScenes()) {
/**
* This method is called when a new scene session is being created.
* Important: When this method is implemented, the app assumes scene-based lifecycle management.
* 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.
*/
(Responder.prototype as UIApplicationDelegate).applicationConfigurationForConnectingSceneSessionOptions = function (application: UIApplication, connectingSceneSession: UISceneSession, options: UISceneConnectionOptions): UISceneConfiguration {
const config = UISceneConfiguration.configurationWithNameSessionRole('Default Configuration', connectingSceneSession.role);
config.sceneClass = UIWindowScene as any;
config.delegateClass = SceneDelegate;
return config;
};
// scene session destruction handling
(Responder.prototype as UIApplicationDelegate).applicationDidDiscardSceneSessions = function (application: UIApplication, sceneSessions: NSSet<UISceneSession>): void {
// Note: we could emit an event here if needed
// console.log('Scene sessions discarded:', sceneSessions.count);
};
}
@NativeClass
class SceneDelegate extends UIResponder implements UIWindowSceneDelegate {
static ObjCProtocols = [UIWindowSceneDelegate];
private _window: UIWindow;
private _scene: UIWindowScene;
get window(): UIWindow {
return this._window;
}
set window(value: UIWindow) {
this._window = value;
}
sceneWillConnectToSessionOptions(scene: UIScene, session: UISceneSession, connectionOptions: UISceneConnectionOptions): void {
if (Trace.isEnabled()) {
Trace.write(`SceneDelegate.sceneWillConnectToSessionOptions called with role: ${session.role}`, Trace.categories.NativeLifecycle);
}
if (!(scene instanceof UIWindowScene)) {
// Scene is not a UIWindowScene, ignoring
return;
}
this._scene = scene;
// Create window for this scene
this._window = UIWindow.alloc().initWithWindowScene(scene);
// Store the window scene for this window
Application.ios._setWindowForScene(this._window, scene);
// Set up the window content
Application.ios._setupWindowForScene(this._window, scene);
// Notify that scene will connect
Application.ios.notify({
eventName: SceneEvents.sceneWillConnect,
object: Application.ios,
scene: scene,
window: this._window,
connectionOptions: connectionOptions,
} as SceneEventData);
if (scene === Application.ios.getPrimaryScene()) {
// 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 (!Application.ios.getPrimaryScene()) {
Application.ios._notifySceneAppStarted();
}
}
sceneDidBecomeActive(scene: UIScene): void {
// This will be handled by the notification observer in iOSApplication
// The notification system will automatically trigger sceneDidActivate
}
sceneWillResignActive(scene: UIScene): void {
// Notify that scene will resign active
Application.ios.notify({
eventName: SceneEvents.sceneWillResignActive,
object: Application.ios,
scene: scene,
} as SceneEventData);
}
sceneWillEnterForeground(scene: UIScene): void {
// This will be handled by the notification observer in iOSApplication
}
sceneDidEnterBackground(scene: UIScene): void {
// This will be handled by the notification observer in iOSApplication
}
sceneDidDisconnect(scene: UIScene): void {
// This will be handled by the notification observer in iOSApplication
}
}
// ensure available globally
global.SceneDelegate = SceneDelegate;
export class iOSApplication extends ApplicationCommon {
private _delegate: UIApplicationDelegate;
private _delegateHandlers = new Map<string, Array<Function>>();
private _rootView: View;
private _sceneDelegate: UIWindowSceneDelegate;
private _windowSceneMap = new Map<UIScene, UIWindow>();
private _primaryScene: UIWindowScene | null = null;
private _openedScenesById = new Map<string, UIWindowScene>();
displayedOnce = false;
displayedLinkTarget: CADisplayLinkTarget;
@@ -115,6 +259,15 @@ export class iOSApplication extends ApplicationCommon {
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 {
@@ -388,6 +541,11 @@ export class iOSApplication extends ApplicationCommon {
}
}
// Public method for scene-based app startup
_notifySceneAppStarted() {
this.notifyAppStarted();
}
public _onLivesync(context?: ModuleContext): void {
// Handle application root module
const isAppRootModuleChanged = context && context.path && context.path.includes(this.getMainEntry().moduleName) && context.type !== 'style';
@@ -456,19 +614,26 @@ export class iOSApplication extends ApplicationCommon {
}
}
this.setMaxRefreshRate();
// ensures window is assigned to proper window scene
setiOSWindow(this.window);
if (!getiOSWindow()) {
// if still no window, create one
setiOSWindow(UIWindow.alloc().initWithFrame(UIScreen.mainScreen.bounds));
// Only set up window if NOT using scene-based lifecycle
if (!this.supportsScenes()) {
// Traditional single-window app setup
// ensures window is assigned to proper window scene
setiOSWindow(this.window);
if (!getiOSWindow()) {
// if still no window, create one
setiOSWindow(UIWindow.alloc().initWithFrame(UIScreen.mainScreen.bounds));
}
if (!__VISIONOS__) {
this.window.backgroundColor = SDK_VERSION <= 12 || !UIColor.systemBackgroundColor ? UIColor.whiteColor : UIColor.systemBackgroundColor;
}
this.notifyAppStarted(notification);
} else {
// Scene-based app - window creation will happen in scene delegate
}
if (!__VISIONOS__) {
this.window.backgroundColor = SDK_VERSION <= 12 || !UIColor.systemBackgroundColor ? UIColor.whiteColor : UIColor.systemBackgroundColor;
}
this.notifyAppStarted(notification);
}
@profile
@@ -525,6 +690,489 @@ export class iOSApplication extends ApplicationCommon {
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;
}
this.notify({
eventName: SceneEvents.sceneWillConnect,
object: this,
scene: scene,
userInfo: notification.userInfo,
} as SceneEventData);
}
private sceneDidActivate(notification: NSNotification) {
const scene = notification.object as UIScene;
this.notify({
eventName: SceneEvents.sceneDidActivate,
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();
}
}
}
private sceneWillEnterForeground(notification: NSNotification) {
const scene = notification.object as UIScene;
this.notify({
eventName: SceneEvents.sceneWillEnterForeground,
object: this,
scene: scene,
} as SceneEventData);
}
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();
}
}
}
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);
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);
}
});
}
}
this.notify({
eventName: SceneEvents.sceneDidDisconnect,
object: this,
scene: scene,
} as SceneEventData);
}
// Scene management helper methods
_setWindowForScene(window: UIWindow, scene: UIScene): void {
this._windowSceneMap.set(scene, window);
}
_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 {}
}
_getWindowForScene(scene: UIScene): UIWindow | undefined {
return this._windowSceneMap.get(scene);
}
_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);
}
// Set up the window content for the primary scene
this.setWindowContent();
}
}
get sceneDelegate(): UIWindowSceneDelegate {
if (!this._sceneDelegate) {
this._sceneDelegate = SceneDelegate.new() as UIWindowSceneDelegate;
}
return this._sceneDelegate;
}
set sceneDelegate(value: UIWindowSceneDelegate) {
this._sceneDelegate = value;
}
/**
* Multi-window support
*/
/**
* Opens a new window with the specified data.
* @param data The data to pass to the new window.
*/
openWindow(data: Record<any, any>) {
if (!supportsMultipleScenes()) {
console.log('Cannot create a new scene - not supported on this device.');
return;
}
try {
const app = UIApplication.sharedApplication;
// 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);
request.options = options;
} catch (roleError) {
console.log('Error creating request:', roleError);
return;
}
app.activateSceneSessionForRequestErrorHandler(request, (error) => {
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) {
this.createSceneWithLegacyAPI(data);
}
}
});
}
// 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 {
console.log('Neither new nor legacy scene activation methods are available');
}
} catch (error) {
console.error('Error requesting new scene:', error);
}
}
/**
* 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')
*/
public closeWindow(target?: 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;
}
// Don't allow closing the primary scene
if (scene === this._primaryScene) {
console.log('closeWindow: Refusing to close the primary scene');
return;
}
const session = scene.session;
if (!session) {
console.log('closeWindow: Scene has no session to destroy');
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');
}
} catch (err) {
console.log('closeWindow: Unexpected error', err);
}
}
getAllWindows(): UIWindow[] {
return Array.from(this._windowSceneMap.values());
}
getAllScenes(): UIScene[] {
return Array.from(this._windowSceneMap.keys());
}
getWindowScenes(): UIWindowScene[] {
return this.getAllScenes().filter((scene) => scene instanceof UIWindowScene) as UIWindowScene[];
}
getPrimaryWindow(): UIWindow {
if (this._primaryScene) {
return this._getWindowForScene(this._primaryScene) || getiOSWindow();
}
return getiOSWindow();
}
getPrimaryScene(): UIWindowScene | null {
return this._primaryScene;
}
// Scene lifecycle management
supportsScenes(): boolean {
return supportsScenes();
}
supportsMultipleScenes(): boolean {
return supportsMultipleScenes();
}
isUsingSceneLifecycle(): boolean {
return this.supportsScenes() && this._windowSceneMap.size > 0;
}
// Call this to set up scene-based configuration
configureForScenes(): void {
if (!this.supportsScenes()) {
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
private _resolveScene(target?: any): UIWindowScene | null {
if (!__APPLE__) {
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;
}
// 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) {
return target as UIWindowScene;
}
// UIWindow
if ((target as UIWindow).windowScene) {
return (target as UIWindow).windowScene;
}
// NativeScript View
if ((target as View)?.nativeViewProtected) {
const uiView = (target as View).nativeViewProtected as UIView;
const win = uiView?.window as UIWindow;
return win?.windowScene || null;
}
}
// String id lookup
if (typeof target === 'string') {
if (this._openedScenesById.has(target)) {
return this._openedScenesById.get(target);
}
// 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;
}
}
}
return null;
}
private createSceneWithLegacyAPI(data: Record<any, any>) {
const windowScene = this.window?.windowScene;
if (!windowScene) {
return;
}
// Create user activity for the new scene
const userActivity = NSUserActivity.alloc().initWithActivityType(`${NSBundle.mainBundle.bundleIdentifier}.scene`);
userActivity.userInfo = dataSerialize(data);
// Use the legacy API
const options = UISceneActivationRequestOptions.new();
options.requestingScene = windowScene;
UIApplication.sharedApplication.requestSceneSessionActivationUserActivityOptionsErrorHandler(
null, // session - null for new scene
userActivity,
options,
(error: NSError) => {
if (error) {
console.error(`Legacy scene API failed: ${error.localizedDescription}`);
}
},
);
}
/**
* Creates a simple view controller with a NativeScript view for a scene window.
* @param window The UIWindow to set content for
* @param view The NativeScript View to set as root content
*/
setWindowRootView(window: UIWindow, view: View): void {
if (!window || !view) {
return;
}
if (view.ios) {
window.rootViewController = view.viewController;
window.makeKeyAndVisible();
} else {
console.warn('View does not have a native iOS implementation');
}
}
get ios() {
// ensures Application.ios is defined when running on iOS
return this;

View File

@@ -917,7 +917,6 @@ export class View extends ViewCommon {
if (!navigationBar) return;
if (typeof value === 'string') {
console.log('here:', value);
navigationBar.barStyle = value === 'dark' ? UIBarStyle.Black : UIBarStyle.Default;
} else {
navigationBar.barStyle = value;

View File

@@ -16,6 +16,14 @@ global.__APPLE__ = true;
global.__COMMONJS__ = false;
global.WeakRef.prototype.get = global.WeakRef.prototype.deref;
global.NativeClass = function () {};
global.NSBundle = {
mainBundle: {
bundleIdentifier: 'test',
objectForInfoDictionaryKey(key: string) {
return true;
},
},
};
global.NSTimer = class NSTimer {};
global.NSObject = class NSObject {
static new() {
@@ -88,6 +96,7 @@ global.UIInterfaceOrientation = {
LandscapeLeft: 4,
LandscapeRight: 3,
};
global.UIWindowSceneDelegate = function () {};
const cgColors = { CGColor: 1 };
global.UIColor = {
alloc() {

View File

@@ -181,7 +181,7 @@ export default function (config: Config, env: IWebpackEnv = _env): Config {
class FixSourceMapUrlPlugin {
apply(compiler) {
compiler.hooks.emit.tap('FixSourceMapUrlPlugin', (compilation) => {
const leadingCharacter = process.platform === "win32" ? "/":"";
const leadingCharacter = process.platform === 'win32' ? '/' : '';
Object.keys(compilation.assets).forEach((filename) => {
if (filename.endsWith('.mjs') || filename.endsWith('.js')) {
const asset = compilation.assets[filename];

View File

@@ -1,58 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>${PRODUCT_NAME}</string>
<key>CFBundleExecutable</key>
<string>${EXECUTABLE_NAME}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>${PRODUCT_NAME}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIRequiresFullScreen</key>
<true/>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>${PRODUCT_NAME}</string>
<key>CFBundleExecutable</key>
<string>${EXECUTABLE_NAME}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>${PRODUCT_NAME}</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAllowsArbitraryLoadsForMedia</key>
<key>UIRequiresFullScreen</key>
<true/>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
</dict>
</dict>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsArbitraryLoadsForMedia</key>
<true/>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
</dict>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationPreferredDefaultSceneSessionRole</key>
<string>UIWindowSceneSessionRoleApplication</string>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>SceneDelegate</string>
</dict>
</array>
</dict>
</dict>
</dict>
</plist>