Skip to content

Commit b86f25f

Browse files
committed
feat: add multi-window support (#117)
* feat: add multi-window support * feat: introduce WindowManager fix: RCTReactViewController properly check props to update fix: use clearColor instead of systemBackgroundColor for visionOS (#125)
1 parent 2fe9cec commit b86f25f

32 files changed

+734
-101
lines changed

packages/react-native/Libraries/AppDelegate/RCTAppDelegate.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ NS_ASSUME_NONNULL_BEGIN
5959

6060
/// The window object, used to render the UViewControllers
6161
@property (nonatomic, strong, nonnull) UIWindow *window;
62-
@property (nonatomic, nullable) RCTBridge *bridge;
62+
/// Store last focused window to properly handle multi-window scenarios
63+
@property (nonatomic, weak, nullable) UIWindow *lastFocusedWindow;
64+
@property (nonatomic, strong, nullable) RCTBridge *bridge;
6365
@property (nonatomic, strong, nullable) NSString *moduleName;
6466
@property (nonatomic, strong, nullable) NSDictionary *initialProps;
6567
@property (nonatomic, strong, nonnull) RCTRootViewFactory *rootViewFactory;

packages/react-native/Libraries/AppDelegate/RCTAppDelegate.mm

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,11 @@ - (UIView *)createRootViewWithBridge:(RCTBridge *)bridge
8787
BOOL enableFabric = self.fabricEnabled;
8888
UIView *rootView = RCTAppSetupDefaultRootView(bridge, moduleName, initProps, enableFabric);
8989

90+
#if TARGET_OS_VISION
91+
rootView.backgroundColor = [UIColor clearColor];
92+
#else
9093
rootView.backgroundColor = [UIColor systemBackgroundColor];
94+
#endif
9195

9296
return rootView;
9397
}

packages/react-native/Libraries/AppDelegate/RCTRootViewFactory.mm

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,11 @@ - (UIView *)viewWithModuleName:(NSString *)moduleName
149149
initWithSurface:surface
150150
sizeMeasureMode:RCTSurfaceSizeMeasureModeWidthExact | RCTSurfaceSizeMeasureModeHeightExact];
151151

152+
#if TARGET_OS_VISION
153+
surfaceHostingProxyRootView.backgroundColor = [UIColor clearColor];
154+
#else
152155
surfaceHostingProxyRootView.backgroundColor = [UIColor systemBackgroundColor];
156+
#endif
153157
if (self->_configuration.customizeRootView != nil) {
154158
self->_configuration.customizeRootView(surfaceHostingProxyRootView);
155159
}
@@ -183,7 +187,11 @@ - (UIView *)createRootViewWithBridge:(RCTBridge *)bridge
183187
BOOL enableFabric = self->_configuration.fabricEnabled;
184188
UIView *rootView = RCTAppSetupDefaultRootView(bridge, moduleName, initProps, enableFabric);
185189

190+
#if TARGET_OS_VISION
191+
rootView.backgroundColor = [UIColor clearColor];
192+
#else
186193
rootView.backgroundColor = [UIColor systemBackgroundColor];
194+
#endif
187195

188196
return rootView;
189197
}

packages/react-native/Libraries/SwiftExtensions/RCTMainWindow.swift

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ import SwiftUI
1515
}
1616
```
1717

18-
Note: If you want to create additional windows in your app, create a new `WindowGroup {}` and pass it a `RCTRootViewRepresentable`.
19-
*/
18+
Note: If you want to create additional windows in your app, use `RCTWindow()`.
19+
*/
2020
public struct RCTMainWindow: Scene {
2121
var moduleName: String
2222
var initialProps: RCTRootViewRepresentable.InitialPropsType
@@ -29,6 +29,55 @@ public struct RCTMainWindow: Scene {
2929
public var body: some Scene {
3030
WindowGroup {
3131
RCTRootViewRepresentable(moduleName: moduleName, initialProps: initialProps)
32+
.modifier(WindowHandlingModifier())
33+
}
34+
}
35+
}
36+
37+
/**
38+
Handles data sharing between React Native and SwiftUI views.
39+
*/
40+
struct WindowHandlingModifier: ViewModifier {
41+
typealias UserInfoType = Dictionary<String, AnyHashable>
42+
43+
@Environment(\.reactContext) private var reactContext
44+
@Environment(\.openWindow) private var openWindow
45+
@Environment(\.dismissWindow) private var dismissWindow
46+
@Environment(\.supportsMultipleWindows) private var supportsMultipleWindows
47+
48+
func body(content: Content) -> some View {
49+
// Attach listeners only if app supports multiple windows
50+
if supportsMultipleWindows {
51+
content
52+
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("RCTOpenWindow"))) { data in
53+
guard let id = data.userInfo?["id"] as? String else { return }
54+
reactContext.scenes.updateValue(RCTSceneData(id: id, props: data.userInfo?["userInfo"] as? UserInfoType), forKey: id)
55+
openWindow(id: id)
56+
}
57+
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("RCTUpdateWindow"))) { data in
58+
guard
59+
let id = data.userInfo?["id"] as? String,
60+
let userInfo = data.userInfo?["userInfo"] as? UserInfoType else { return }
61+
reactContext.scenes[id]?.props = userInfo
62+
}
63+
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("RCTDismissWindow"))) { data in
64+
guard let id = data.userInfo?["id"] as? String else { return }
65+
dismissWindow(id: id)
66+
reactContext.scenes.removeValue(forKey: id)
67+
}
68+
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("RCTOpenImmersiveSpace"))) { data in
69+
guard let id = data.userInfo?["id"] as? String else { return }
70+
reactContext.scenes.updateValue(
71+
RCTSceneData(id: id, props: data.userInfo?["userInfo"] as? UserInfoType),
72+
forKey: id
73+
)
74+
}
75+
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("RCTDismissImmersiveSpace"))) { data in
76+
guard let id = data.userInfo?["id"] as? String else { return }
77+
reactContext.scenes.removeValue(forKey: id)
78+
}
79+
} else {
80+
content
3281
}
3382
}
3483
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import SwiftUI
2+
import Observation
3+
4+
@Observable
5+
public class RCTSceneData: Identifiable {
6+
public var id: String
7+
public var props: Dictionary<String, AnyHashable>?
8+
9+
init(id: String, props: Dictionary<String, AnyHashable>?) {
10+
self.id = id
11+
self.props = props
12+
}
13+
}
14+
15+
extension RCTSceneData: Equatable {
16+
public static func == (lhs: RCTSceneData, rhs: RCTSceneData) -> Bool {
17+
lhs.id == rhs.id && NSDictionary(dictionary: lhs.props ?? [:]).isEqual(to: rhs.props ?? [:])
18+
}
19+
}
20+
21+
@Observable
22+
public class RCTReactContext {
23+
public var scenes: Dictionary<String, RCTSceneData> = [:]
24+
25+
public func getSceneData(id: String) -> RCTSceneData? {
26+
return scenes[id]
27+
}
28+
}
29+
30+
extension RCTReactContext: Equatable {
31+
public static func == (lhs: RCTReactContext, rhs: RCTReactContext) -> Bool {
32+
NSDictionary(dictionary: lhs.scenes).isEqual(to: rhs.scenes)
33+
}
34+
}
35+
36+
public extension EnvironmentValues {
37+
var reactContext: RCTReactContext {
38+
get { self[RCTSceneContextKey.self] }
39+
set { self[RCTSceneContextKey.self] = newValue }
40+
}
41+
}
42+
43+
private struct RCTSceneContextKey: EnvironmentKey {
44+
static var defaultValue: RCTReactContext = RCTReactContext()
45+
}

packages/react-native/Libraries/SwiftExtensions/RCTReactViewController.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,6 @@
1313
- (instancetype _Nonnull)initWithModuleName:(NSString *_Nonnull)moduleName
1414
initProps:(NSDictionary *_Nullable)initProps;
1515

16+
-(void)updateProps:(NSDictionary *_Nullable)newProps;
17+
1618
@end

packages/react-native/Libraries/SwiftExtensions/RCTRootViewRepresentable.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,11 @@ public struct RCTRootViewRepresentable: UIViewControllerRepresentable {
2222
self.initialProps = initialProps
2323
}
2424

25-
public func makeUIViewController(context: Context) -> UIViewController {
25+
public func makeUIViewController(context: Context) -> RCTReactViewController {
2626
RCTReactViewController(moduleName: moduleName, initProps: initialProps)
2727
}
2828

29-
public func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
30-
// noop
29+
public func updateUIViewController(_ uiViewController: RCTReactViewController, context: Context) {
30+
uiViewController.updateProps(initialProps)
3131
}
3232
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import SwiftUI
2+
import React
3+
4+
/**
5+
`RCTWindow` is a SwiftUI struct that returns additional scenes.
6+
7+
Example usage:
8+
```
9+
RCTWindow(id: "SecondWindow", sceneData: reactContext.getSceneData(id: "SecondWindow"))
10+
```
11+
*/
12+
public struct RCTWindow : Scene {
13+
var id: String
14+
var sceneData: RCTSceneData?
15+
var moduleName: String
16+
17+
public init(id: String, moduleName: String, sceneData: RCTSceneData?) {
18+
self.id = id
19+
self.moduleName = moduleName
20+
self.sceneData = sceneData
21+
}
22+
23+
public var body: some Scene {
24+
WindowGroup(id: id) {
25+
Group {
26+
if let sceneData {
27+
RCTRootViewRepresentable(moduleName: moduleName, initialProps: sceneData.props)
28+
}
29+
}
30+
.onAppear {
31+
if sceneData == nil {
32+
RCTFatal(RCTErrorWithMessage("Passed scene data is nil, make sure to pass sceneContext to RCTWindow() in App.swift"))
33+
}
34+
}
35+
}
36+
}
37+
}
38+
39+
extension RCTWindow {
40+
public init(id: String, sceneData: RCTSceneData?) {
41+
self.id = id
42+
self.moduleName = id
43+
self.sceneData = sceneData
44+
}
45+
}

packages/react-native/Libraries/SwiftExtensions/React-RCTSwiftExtensions.podspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,5 @@ Pod::Spec.new do |s|
2525

2626
s.dependency "React-Core"
2727
s.dependency "React-RCTXR"
28+
s.dependency "React-RCTWindowManager"
2829
end
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* @flow strict
3+
* @format
4+
*/
5+
6+
export * from '../../src/private/specs/visionos_modules/NativeWindowManager';
7+
import NativeWindowManager from '../../src/private/specs/visionos_modules/NativeWindowManager';
8+
export default NativeWindowManager;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
#import <Foundation/Foundation.h>
2+
#import <React/RCTBridgeModule.h>
3+
4+
@interface RCTWindowManager : NSObject <RCTBridgeModule>
5+
6+
@end
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
#import <React/RCTWindowManager.h>
2+
3+
#import <FBReactNativeSpec_visionOS/FBReactNativeSpec_visionOS.h>
4+
5+
#import <React/RCTBridge.h>
6+
#import <React/RCTConvert.h>
7+
#import <React/RCTUtils.h>
8+
9+
// Events
10+
static NSString *const RCTOpenWindow = @"RCTOpenWindow";
11+
static NSString *const RCTDismissWindow = @"RCTDismissWindow";
12+
static NSString *const RCTUpdateWindow = @"RCTUpdateWindow";
13+
14+
@interface RCTWindowManager () <NativeWindowManagerSpec>
15+
@end
16+
17+
@implementation RCTWindowManager
18+
19+
RCT_EXPORT_MODULE(WindowManager)
20+
21+
RCT_EXPORT_METHOD(openWindow
22+
: (NSString *)windowId userInfo
23+
: (NSDictionary *)userInfo resolve
24+
: (RCTPromiseResolveBlock)resolve reject
25+
: (RCTPromiseRejectBlock)reject)
26+
{
27+
RCTExecuteOnMainQueue(^{
28+
if (!RCTSharedApplication().supportsMultipleScenes) {
29+
reject(@"ERROR", @"Multiple scenes not supported", nil);
30+
}
31+
NSMutableDictionary *userInfoDict = [[NSMutableDictionary alloc] init];
32+
[userInfoDict setValue:windowId forKey:@"id"];
33+
if (userInfo != nil) {
34+
[userInfoDict setValue:userInfo forKey:@"userInfo"];
35+
}
36+
[[NSNotificationCenter defaultCenter] postNotificationName:RCTOpenWindow object:self userInfo:userInfoDict];
37+
resolve(nil);
38+
});
39+
}
40+
41+
RCT_EXPORT_METHOD(closeWindow
42+
: (NSString *)windowId resolve
43+
: (RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
44+
{
45+
RCTExecuteOnMainQueue(^{
46+
[[NSNotificationCenter defaultCenter] postNotificationName:RCTDismissWindow object:self userInfo:@{@"id": windowId}];
47+
resolve(nil);
48+
});
49+
}
50+
51+
RCT_EXPORT_METHOD(updateWindow
52+
: (NSString *)windowId userInfo
53+
: (NSDictionary *)userInfo resolve
54+
: (RCTPromiseResolveBlock)resolve reject
55+
: (RCTPromiseRejectBlock)reject)
56+
{
57+
RCTExecuteOnMainQueue(^{
58+
if (!RCTSharedApplication().supportsMultipleScenes) {
59+
reject(@"ERROR", @"Multiple scenes not supported", nil);
60+
}
61+
NSMutableDictionary *userInfoDict = [[NSMutableDictionary alloc] init];
62+
[userInfoDict setValue:windowId forKey:@"id"];
63+
if (userInfo != nil) {
64+
[userInfoDict setValue:userInfo forKey:@"userInfo"];
65+
}
66+
[[NSNotificationCenter defaultCenter] postNotificationName:RCTUpdateWindow object:self userInfo:userInfoDict];
67+
resolve(nil);
68+
});
69+
}
70+
71+
- (facebook::react::ModuleConstants<JS::NativeWindowManager::Constants::Builder>)constantsToExport {
72+
return [self getConstants];
73+
}
74+
75+
- (facebook::react::ModuleConstants<JS::NativeWindowManager::Constants>)getConstants {
76+
__block facebook::react::ModuleConstants<JS::NativeWindowManager::Constants> constants;
77+
RCTUnsafeExecuteOnMainQueueSync(^{
78+
constants = facebook::react::typedConstants<JS::NativeWindowManager::Constants>({
79+
.supportsMultipleScenes = RCTSharedApplication().supportsMultipleScenes
80+
});
81+
});
82+
83+
return constants;
84+
}
85+
86+
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params {
87+
return std::make_shared<facebook::react::NativeWindowManagerSpecJSI>(params);
88+
}
89+
90+
@end
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export interface WindowStatic {
2+
id: String;
3+
open (props?: Object): Promise<void>;
4+
update (props: Object): Promise<void>;
5+
close (): Promise<void>;
6+
}
7+
8+
export interface WindowManagerStatic {
9+
getWindow(id: String): Window;
10+
supportsMultipleScenes: boolean;
11+
}
12+
13+
export const WindowManager: WindowManagerStatic;
14+
export type WindowManager = WindowManagerStatic;
15+
export const Window: WindowStatic;
16+
export type Window = WindowStatic;

0 commit comments

Comments
 (0)