Skip to content

Commit 555306b

Browse files
committed
feat: add multi-window support
1 parent a3be3d7 commit 555306b

File tree

17 files changed

+389
-34
lines changed

17 files changed

+389
-34
lines changed

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ NS_ASSUME_NONNULL_BEGIN
5757

5858
/// The window object, used to render the UViewControllers
5959
@property (nonatomic, strong, nonnull) UIWindow *window;
60+
/// Store last focused window to properly handle multi-window scenarios
61+
@property (nonatomic, weak, nullable) UIWindow *lastFocusedWindow;
6062
@property (nonatomic, strong, nullable) RCTBridge *bridge;
6163
@property (nonatomic, strong, nullable) NSString *moduleName;
6264
@property (nonatomic, strong, nullable) NSDictionary *initialProps;

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+
#if os(visionOS)
33+
.modifier(WindowHandlingModifier())
34+
#endif
3235
}
3336
}
3437
}
38+
39+
#if os(visionOS)
40+
/**
41+
Handles data sharing between React Native and SwiftUI views.
42+
*/
43+
struct WindowHandlingModifier: ViewModifier {
44+
typealias UserInfoType = Dictionary<String, AnyHashable>
45+
46+
@Environment(\.reactContext) private var reactContext
47+
@Environment(\.openWindow) private var openWindow
48+
@Environment(\.dismissWindow) private var dismissWindow
49+
50+
func body(content: Content) -> some View {
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 let id = data.userInfo?["id"] as? String else { return }
59+
if let userInfo = data.userInfo?["userInfo"] as? UserInfoType {
60+
reactContext.scenes[id]?.props = userInfo
61+
}
62+
}
63+
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("RCTDismissWindow"))) { data in
64+
if let id = data.userInfo?["id"] as? String {
65+
dismissWindow(id: id)
66+
reactContext.scenes.removeValue(forKey: id)
67+
}
68+
}
69+
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("RCTOpenImmersiveSpace"))) { data in
70+
guard let id = data.userInfo?["id"] as? String else { return }
71+
reactContext.scenes.updateValue(
72+
RCTSceneData(id: id, props: data.userInfo?["userInfo"] as? UserInfoType),
73+
forKey: id
74+
)
75+
}
76+
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("RCTDismissImmersiveSpace"))) { data in
77+
if let spaceId = data.userInfo?["id"] as? String {
78+
reactContext.scenes.removeValue(forKey: spaceId)
79+
}
80+
}
81+
}
82+
}
83+
#endif
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/RCTReactViewController.m

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
#import "RCTReactViewController.h"
22
#import <React/RCTConstants.h>
3+
#import <React/RCTUtils.h>
4+
#import <React/RCTRootView.h>
35

46
@protocol RCTRootViewFactoryProtocol <NSObject>
57

68
- (UIView *)viewWithModuleName:(NSString *)moduleName initialProperties:(NSDictionary*)initialProperties launchOptions:(NSDictionary*)launchOptions;
79

810
@end
911

12+
@protocol RCTFocusedWindowProtocol <NSObject>
13+
14+
@property (nonatomic, nullable) UIWindow *lastFocusedWindow;
15+
16+
@end
17+
1018
@implementation RCTReactViewController
1119

1220
- (instancetype)initWithModuleName:(NSString *)moduleName initProps:(NSDictionary *)initProps {
@@ -33,4 +41,26 @@ - (void)loadView {
3341
}
3442
}
3543

44+
- (void)viewDidLoad {
45+
UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapGesture:)];
46+
[self.view addGestureRecognizer:tapGesture];
47+
}
48+
49+
- (void)tapGesture:(UITapGestureRecognizer*)recognizer {
50+
id<RCTFocusedWindowProtocol> appDelegate = (id<RCTFocusedWindowProtocol>)RCTSharedApplication().delegate;
51+
52+
if (![appDelegate respondsToSelector:@selector(lastFocusedWindow)]) {
53+
return;
54+
}
55+
56+
UIWindow *targetWindow = recognizer.view.window;
57+
if (targetWindow != appDelegate.lastFocusedWindow) {
58+
appDelegate.lastFocusedWindow = targetWindow;
59+
}
60+
}
61+
62+
- (void)updateProps:(NSDictionary *)newProps {
63+
[(RCTRootView *)self.view setAppProperties:newProps];
64+
}
65+
3666
@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: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import SwiftUI
2+
3+
/**
4+
`RCTWindow` is a SwiftUI struct that returns additional scenes.
5+
6+
Example usage:
7+
```
8+
RCTWindow(id: "SecondWindow", sceneData: reactContext.getSceneData(id: "SecondWindow"))
9+
```
10+
*/
11+
public struct RCTWindow : Scene {
12+
var id: String
13+
var sceneData: RCTSceneData?
14+
var moduleName: String
15+
16+
public init(id: String, moduleName: String, sceneData: RCTSceneData?) {
17+
self.id = id
18+
self.moduleName = moduleName
19+
self.sceneData = sceneData
20+
}
21+
22+
public var body: some Scene {
23+
WindowGroup(id: id) {
24+
if let sceneData {
25+
RCTRootViewRepresentable(moduleName: moduleName, initialProps: sceneData.props)
26+
}
27+
}
28+
}
29+
}
30+
31+
extension RCTWindow {
32+
public init(id: String, sceneData: RCTSceneData?) {
33+
self.id = id
34+
self.moduleName = id
35+
self.sceneData = sceneData
36+
}
37+
}

packages/react-native/Libraries/XR/ImmersiveBridge.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ struct ImmersiveBridgeView: View {
4545
}
4646
}
4747

48-
@objc public class ImmersiveBridgeFactory: NSObject {
48+
@objc public class SwiftUIBridgeFactory: NSObject {
4949
@objc public static func makeImmersiveBridgeView(
5050
spaceId: String,
5151
completionHandler: @escaping CompletionHandlerType

packages/react-native/Libraries/XR/RCTXRModule.mm

Lines changed: 86 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ @interface RCTXRModule () <NativeXRModuleSpec>
1212

1313
@implementation RCTXRModule {
1414
UIViewController *_immersiveBridgeView;
15+
NSString *_currentSessionId;
1516
}
1617

1718
RCT_EXPORT_MODULE()
@@ -20,30 +21,52 @@ @implementation RCTXRModule {
2021
: (RCTPromiseResolveBlock)resolve reject
2122
: (RCTPromiseRejectBlock)reject)
2223
{
23-
[self removeImmersiveBridge];
24+
[self removeViewController:self->_immersiveBridgeView];
25+
self->_immersiveBridgeView = nil;
26+
RCTExecuteOnMainQueue(^{
27+
if (self->_currentSessionId != nil) {
28+
NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter];
29+
[notificationCenter postNotificationName:@"RCTDismissImmersiveSpace" object:self userInfo:@{@"id": self->_currentSessionId }];
30+
}
31+
});
32+
_currentSessionId = nil;
2433
resolve(nil);
2534
}
2635

2736

2837
RCT_EXPORT_METHOD(requestSession
29-
: (NSString *)sessionId resolve
30-
: (RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
38+
: (NSString *)sessionId userInfo
39+
: (NSDictionary *)userInfo resolve
40+
: (RCTPromiseResolveBlock)resolve reject
41+
: (RCTPromiseRejectBlock)reject)
3142
{
3243
RCTExecuteOnMainQueue(^{
3344
UIWindow *keyWindow = RCTKeyWindow();
3445
UIViewController *rootViewController = keyWindow.rootViewController;
3546

3647
if (self->_immersiveBridgeView == nil) {
37-
self->_immersiveBridgeView = [ImmersiveBridgeFactory makeImmersiveBridgeViewWithSpaceId:sessionId
48+
NSMutableDictionary *userInfoDict = [[NSMutableDictionary alloc] init];
49+
[userInfoDict setValue:sessionId forKey:@"id"];
50+
if (userInfo != nil) {
51+
[userInfoDict setValue:userInfo forKey:@"userInfo"];
52+
}
53+
NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter];
54+
[notificationCenter postNotificationName:@"RCTOpenImmersiveSpace" object:self userInfo:userInfoDict];
55+
self->_currentSessionId = sessionId;
56+
57+
self->_immersiveBridgeView = [SwiftUIBridgeFactory makeImmersiveBridgeViewWithSpaceId:sessionId
3858
completionHandler:^(enum ImmersiveSpaceResult result){
3959
if (result == ImmersiveSpaceResultError) {
4060
reject(@"ERROR", @"Immersive Space failed to open, the system cannot fulfill the request.", nil);
41-
[self removeImmersiveBridge];
61+
[self removeViewController:self->_immersiveBridgeView];
62+
self->_immersiveBridgeView = nil;
4263
} else if (result == ImmersiveSpaceResultUserCancelled) {
4364
reject(@"ERROR", @"Immersive Space canceled by user", nil);
44-
[self removeImmersiveBridge];
65+
[self removeViewController:self->_immersiveBridgeView];
66+
self->_immersiveBridgeView = nil;
4567
} else if (result == ImmersiveSpaceResultOpened) {
46-
resolve(nil);
68+
// resolve(nil);
69+
4770
}
4871
}];
4972

@@ -55,6 +78,37 @@ @implementation RCTXRModule {
5578
}
5679
});
5780
}
81+
RCT_EXPORT_METHOD(openWindow
82+
: (NSString *)windowId userInfo
83+
: (NSDictionary *)userInfo resolve
84+
: (RCTPromiseResolveBlock)resolve reject
85+
: (RCTPromiseRejectBlock)reject)
86+
{
87+
RCTExecuteOnMainQueue(^{
88+
if (!RCTSharedApplication().supportsMultipleScenes) {
89+
reject(@"ERROR", @"Multiple scenes not supported", nil);
90+
}
91+
NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter];
92+
NSMutableDictionary *userInfoDict = [[NSMutableDictionary alloc] init];
93+
[userInfoDict setValue:windowId forKey:@"id"];
94+
if (userInfo != nil) {
95+
[userInfoDict setValue:userInfo forKey:@"userInfo"];
96+
}
97+
[notificationCenter postNotificationName:@"RCTOpenWindow" object:self userInfo:userInfoDict];
98+
resolve(nil);
99+
});
100+
}
101+
102+
RCT_EXPORT_METHOD(closeWindow
103+
: (NSString *)windowId resolve
104+
: (RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
105+
{
106+
RCTExecuteOnMainQueue(^{
107+
NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter];
108+
[notificationCenter postNotificationName:@"RCTDismissWindow" object:self userInfo:@{@"id": windowId}];
109+
resolve(nil);
110+
});
111+
}
58112

59113
- (facebook::react::ModuleConstants<JS::NativeXRModule::Constants::Builder>)constantsToExport {
60114
return [self getConstants];
@@ -71,13 +125,33 @@ @implementation RCTXRModule {
71125
return constants;
72126
}
73127

74-
- (void) removeImmersiveBridge
128+
RCT_EXPORT_METHOD(updateWindow
129+
: (NSString *)windowId userInfo
130+
: (NSDictionary *)userInfo resolve
131+
: (RCTPromiseResolveBlock)resolve reject
132+
: (RCTPromiseRejectBlock)reject)
133+
{
134+
RCTExecuteOnMainQueue(^{
135+
if (!RCTSharedApplication().supportsMultipleScenes) {
136+
reject(@"ERROR", @"Multiple scenes not supported", nil);
137+
}
138+
NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter];
139+
NSMutableDictionary *userInfoDict = [[NSMutableDictionary alloc] init];
140+
[userInfoDict setValue:windowId forKey:@"id"];
141+
if (userInfo != nil) {
142+
[userInfoDict setValue:userInfo forKey:@"userInfo"];
143+
}
144+
[notificationCenter postNotificationName:@"RCTUpdateWindow" object:self userInfo:userInfoDict];
145+
resolve(nil);
146+
});
147+
}
148+
149+
- (void)removeViewController:(UIViewController*)viewController
75150
{
76151
RCTExecuteOnMainQueue(^{
77-
[self->_immersiveBridgeView willMoveToParentViewController:nil];
78-
[self->_immersiveBridgeView.view removeFromSuperview];
79-
[self->_immersiveBridgeView removeFromParentViewController];
80-
self->_immersiveBridgeView = nil;
152+
[viewController willMoveToParentViewController:nil];
153+
[viewController.view removeFromSuperview];
154+
[viewController removeFromParentViewController];
81155
});
82156
}
83157

packages/react-native/Libraries/XR/XR.d.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11

22
export interface XRStatic {
3-
requestSession(sessionId: string): Promise<void>;
3+
requestSession(sessionId: string, userInfo: Object): Promise<void>;
44
endSession(): Promise<void>;
5+
6+
openWindow(windowId: string, userInfo: Object): Promise<void>;
7+
updateWindow(windowId: string, userInfo: Object): Promise<void>;
8+
closeWindow(windowId: string): Promise<void>;
9+
510
supportsMultipleScenes: boolean;
611
}
712

0 commit comments

Comments
 (0)