From 914e3279200d9c4c15ae0ec2bdfbe4cb76fc7b37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwa=C5=9Bniewski?= Date: Wed, 17 Apr 2024 13:11:50 +0200 Subject: [PATCH] feat: implement window manager events (#142) --- .../SwiftExtensions/RCTMainWindow.swift | 18 +++++++ .../Libraries/SwiftExtensions/RCTWindow.swift | 5 ++ .../WindowManager/RCTWindowManager.h | 3 +- .../WindowManager/RCTWindowManager.mm | 49 ++++++++++++++++++- .../WindowManager/WindowManager.d.ts | 10 ++++ .../Libraries/WindowManager/WindowManager.js | 38 +++++++++++--- .../__snapshots__/public-api-test.js.snap | 21 ++++---- .../visionos_modules/NativeWindowManager.js | 4 ++ .../rn-tester/js/examples/XR/XRExample.js | 11 +++++ 9 files changed, 140 insertions(+), 19 deletions(-) diff --git a/packages/react-native/Libraries/SwiftExtensions/RCTMainWindow.swift b/packages/react-native/Libraries/SwiftExtensions/RCTMainWindow.swift index 3dff68146cb66e..415d843c693953 100644 --- a/packages/react-native/Libraries/SwiftExtensions/RCTMainWindow.swift +++ b/packages/react-native/Libraries/SwiftExtensions/RCTMainWindow.swift @@ -21,6 +21,10 @@ public struct RCTMainWindow: Scene { var moduleName: String var initialProps: RCTRootViewRepresentable.InitialPropsType var onOpenURLCallback: ((URL) -> ())? + let windowId: String = "0" + + @Environment(\.scenePhase) private var scenePhase + public init(moduleName: String, initialProps: RCTRootViewRepresentable.InitialPropsType = nil) { self.moduleName = moduleName @@ -31,6 +35,9 @@ public struct RCTMainWindow: Scene { WindowGroup { RCTRootViewRepresentable(moduleName: moduleName, initialProps: initialProps) .modifier(WindowHandlingModifier()) + .onChange(of: scenePhase, { _, newValue in + postWindowStateNotification(windowId: windowId, state: newValue) + }) .onOpenURL(perform: { url in onOpenURLCallback?(url) }) @@ -38,6 +45,17 @@ public struct RCTMainWindow: Scene { } } +public func postWindowStateNotification(windowId: String, state: SwiftUI.ScenePhase) { + NotificationCenter.default.post( + name: NSNotification.Name(rawValue: "RCTWindowStateDidChange"), + object: nil, + userInfo: [ + "windowId": windowId, + "state": "\(state)" + ] + ) +} + extension RCTMainWindow { public func onOpenURL(perform action: @escaping (URL) -> ()) -> some Scene { var scene = self diff --git a/packages/react-native/Libraries/SwiftExtensions/RCTWindow.swift b/packages/react-native/Libraries/SwiftExtensions/RCTWindow.swift index 12bb37085cd1dd..bed6245877a6fe 100644 --- a/packages/react-native/Libraries/SwiftExtensions/RCTWindow.swift +++ b/packages/react-native/Libraries/SwiftExtensions/RCTWindow.swift @@ -13,6 +13,8 @@ public struct RCTWindow : Scene { var id: String var sceneData: RCTSceneData? var moduleName: String + @Environment(\.scenePhase) private var scenePhase + public init(id: String, moduleName: String, sceneData: RCTSceneData?) { self.id = id @@ -25,6 +27,9 @@ public struct RCTWindow : Scene { Group { if let sceneData { RCTRootViewRepresentable(moduleName: moduleName, initialProps: sceneData.props) + .onChange(of: scenePhase) { _, newValue in + postWindowStateNotification(windowId: id, state: newValue) + } } } .onAppear { diff --git a/packages/react-native/Libraries/WindowManager/RCTWindowManager.h b/packages/react-native/Libraries/WindowManager/RCTWindowManager.h index 8d782b1bfe77e7..1e1bf4dd95e7c8 100644 --- a/packages/react-native/Libraries/WindowManager/RCTWindowManager.h +++ b/packages/react-native/Libraries/WindowManager/RCTWindowManager.h @@ -1,6 +1,7 @@ #import #import +#import -@interface RCTWindowManager : NSObject +@interface RCTWindowManager : RCTEventEmitter @end diff --git a/packages/react-native/Libraries/WindowManager/RCTWindowManager.mm b/packages/react-native/Libraries/WindowManager/RCTWindowManager.mm index 3700bceba20b95..3f101782044bf5 100644 --- a/packages/react-native/Libraries/WindowManager/RCTWindowManager.mm +++ b/packages/react-native/Libraries/WindowManager/RCTWindowManager.mm @@ -10,14 +10,41 @@ static NSString *const RCTOpenWindow = @"RCTOpenWindow"; static NSString *const RCTDismissWindow = @"RCTDismissWindow"; static NSString *const RCTUpdateWindow = @"RCTUpdateWindow"; +static NSString *const RCTWindowStateDidChangeEvent = @"windowStateDidChange"; -@interface RCTWindowManager () +static NSString *const RCTWindowStateDidChange = @"RCTWindowStateDidChange"; + +@interface RCTWindowManager () { + BOOL _hasAnyListeners; +} @end @implementation RCTWindowManager RCT_EXPORT_MODULE(WindowManager) +- (void)initialize { + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleWindowStateChanges:) + name:RCTWindowStateDidChange + object:nil]; +} + +- (void)invalidate { + [super invalidate]; + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +-(void)startObserving +{ + _hasAnyListeners = YES; +} + +- (void)stopObserving +{ + _hasAnyListeners = NO; +} + RCT_EXPORT_METHOD(openWindow : (NSString *)windowId userInfo : (NSDictionary *)userInfo resolve @@ -68,6 +95,17 @@ @implementation RCTWindowManager }); } +- (void) handleWindowStateChanges:(NSNotification *)notification { + + if (_hasAnyListeners) { + [self sendEventWithName:RCTWindowStateDidChangeEvent body:notification.userInfo]; + } +} + +- (NSArray *)supportedEvents { + return @[RCTWindowStateDidChangeEvent]; +} + - (facebook::react::ModuleConstants)constantsToExport { return [self getConstants]; } @@ -87,4 +125,13 @@ @implementation RCTWindowManager return std::make_shared(params); } ++ (BOOL)requiresMainQueueSetup { + return YES; +} + +- (dispatch_queue_t)methodQueue +{ + return dispatch_get_main_queue(); +} + @end diff --git a/packages/react-native/Libraries/WindowManager/WindowManager.d.ts b/packages/react-native/Libraries/WindowManager/WindowManager.d.ts index bcdf96c272c21c..b77d7ff695d1b7 100644 --- a/packages/react-native/Libraries/WindowManager/WindowManager.d.ts +++ b/packages/react-native/Libraries/WindowManager/WindowManager.d.ts @@ -1,8 +1,18 @@ +import {NativeEventSubscription} from '../EventEmitter/RCTNativeAppEventEmitter'; + +type WindowManagerEvents = 'windowStateDidChange'; + +type WindowState = { + windowId: string; + state: 'active' | 'inactive' | 'background'; +}; + export interface WindowStatic { id: String; open (props?: Object): Promise; update (props: Object): Promise; close (): Promise; + addEventListener (type: WindowManagerEvents, handler: (info: WindowState) => void): NativeEventSubscription; } export interface WindowManagerStatic { diff --git a/packages/react-native/Libraries/WindowManager/WindowManager.js b/packages/react-native/Libraries/WindowManager/WindowManager.js index bb39535a65bb1a..8eb113cce66a79 100644 --- a/packages/react-native/Libraries/WindowManager/WindowManager.js +++ b/packages/react-native/Libraries/WindowManager/WindowManager.js @@ -1,26 +1,50 @@ /** * @format - * @flow strict + * @flow strict-local * @jsdoc */ +import NativeEventEmitter from '../EventEmitter/NativeEventEmitter'; +import Platform from '../Utilities/Platform'; +import {type EventSubscription} from '../vendor/emitter/EventEmitter'; import NativeWindowManager from './NativeWindowManager'; -const WindowManager = { - getWindow: function (id: string): Window { +export type WindowStateValues = 'inactive' | 'background' | 'active'; + +type WindowManagerEventDefinitions = { + windowStateDidChange: [{state: WindowStateValues, windowId: string}], +}; + +let emitter: ?NativeEventEmitter; + +if (NativeWindowManager != null) { + emitter = new NativeEventEmitter( + Platform.OS !== 'ios' ? null : NativeWindowManager, + ); +} + +class WindowManager { + static getWindow = function (id: string): Window { return new Window(id); - }, + }; + + static addEventListener>( + type: K, + handler: (...$ElementType) => void, + ): ?EventSubscription { + return emitter?.addListener(type, handler); + } // $FlowIgnore[unsafe-getters-setters] - get supportsMultipleScenes(): boolean { + static get supportsMultipleScenes(): boolean { if (NativeWindowManager == null) { return false; } const nativeConstants = NativeWindowManager.getConstants(); return nativeConstants.supportsMultipleScenes || false; - }, -}; + } +} class Window { id: string; diff --git a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap index 1d386a914344f2..18db3ccc9a2993 100644 --- a/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap +++ b/packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap @@ -9086,16 +9086,17 @@ declare export default typeof NativeWindowManager; `; exports[`public API should not change unintentionally Libraries/WindowManager/WindowManager.js 1`] = ` -"declare const WindowManager: { - getWindow: (id: string) => Window, - get supportsMultipleScenes(): boolean, -}; -declare class Window { - id: string; - constructor(id: string): void; - open(props: ?Object): Promise; - close(): Promise; - update(props: ?Object): Promise; +"export type WindowStateValues = \\"inactive\\" | \\"background\\" | \\"active\\"; +type WindowManagerEventDefinitions = { + windowStateDidChange: [{ state: WindowStateValues, windowId: string }], +}; +declare class WindowManager { + static getWindow: $FlowFixMe; + static addEventListener>( + type: K, + handler: (...$ElementType) => void + ): ?EventSubscription; + static get supportsMultipleScenes(): boolean; } declare module.exports: WindowManager; " diff --git a/packages/react-native/src/private/specs/visionos_modules/NativeWindowManager.js b/packages/react-native/src/private/specs/visionos_modules/NativeWindowManager.js index db0332702236c4..d726a0c344279f 100644 --- a/packages/react-native/src/private/specs/visionos_modules/NativeWindowManager.js +++ b/packages/react-native/src/private/specs/visionos_modules/NativeWindowManager.js @@ -19,6 +19,10 @@ export interface Spec extends TurboModule { // $FlowIgnore[unclear-type] +updateWindow: (windowId: string, userInfo: Object) => Promise; +closeWindow: (windowId: string) => Promise; + + // RCTEventEmitter + +addListener: (eventName: string) => void; + +removeListeners: (count: number) => void; } export default (TurboModuleRegistry.get('WindowManager'): ?Spec); diff --git a/packages/rn-tester/js/examples/XR/XRExample.js b/packages/rn-tester/js/examples/XR/XRExample.js index b042657dc0472b..3b324b99b95dd3 100644 --- a/packages/rn-tester/js/examples/XR/XRExample.js +++ b/packages/rn-tester/js/examples/XR/XRExample.js @@ -19,6 +19,17 @@ const secondWindow = WindowManager.getWindow('SecondWindow'); const OpenXRSession = () => { const [isOpen, setIsOpen] = React.useState(false); + React.useEffect(() => { + const listener = WindowManager.addEventListener( + 'windowStateDidChange', + data => { + console.log('Window state changed to:', data); + }, + ); + return () => { + listener?.remove(); + }; + }, []); const openXRSession = async () => { try { if (!WindowManager.supportsMultipleScenes) {