Skip to content

feat: add multi-window support #117

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Feb 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/react-native/Libraries/AppDelegate/RCTAppDelegate.h
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ NS_ASSUME_NONNULL_BEGIN

/// The window object, used to render the UViewControllers
@property (nonatomic, strong, nonnull) UIWindow *window;
/// Store last focused window to properly handle multi-window scenarios
@property (nonatomic, weak, nullable) UIWindow *lastFocusedWindow;
@property (nonatomic, strong, nullable) RCTBridge *bridge;
@property (nonatomic, strong, nullable) NSString *moduleName;
@property (nonatomic, strong, nullable) NSDictionary *initialProps;
Expand Down
15 changes: 0 additions & 15 deletions packages/react-native/Libraries/AppDelegate/RCTAppDelegate.mm
Original file line number Diff line number Diff line change
Expand Up @@ -81,22 +81,7 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(

RCTAppSetupPrepareApp(application, enableTM, *_reactNativeConfig);

#if TARGET_OS_VISION
/// Bail out of UIWindow initializaiton to support multi-window scenarios in SwiftUI lifecycle.
return YES;
#else
UIView* rootView = [self viewWithModuleName:self.moduleName initialProperties:[self prepareInitialProps] launchOptions:launchOptions];

self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];

UIViewController *rootViewController = [self createRootViewController];
[self setRootView:rootView toRootViewController:rootViewController];
self.window.rootViewController = rootViewController;
self.window.windowScene.delegate = self;
[self.window makeKeyAndVisible];

return YES;
#endif
}

- (void)applicationDidEnterBackground:(UIApplication *)application
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import SwiftUI
}
```

Note: If you want to create additional windows in your app, create a new `WindowGroup {}` and pass it a `RCTRootViewRepresentable`.
*/
Note: If you want to create additional windows in your app, use `RCTWindow()`.
*/
public struct RCTMainWindow: Scene {
var moduleName: String
var initialProps: RCTRootViewRepresentable.InitialPropsType
Expand All @@ -29,6 +29,55 @@ public struct RCTMainWindow: Scene {
public var body: some Scene {
WindowGroup {
RCTRootViewRepresentable(moduleName: moduleName, initialProps: initialProps)
.modifier(WindowHandlingModifier())
}
}
}

/**
Handles data sharing between React Native and SwiftUI views.
*/
struct WindowHandlingModifier: ViewModifier {
typealias UserInfoType = Dictionary<String, AnyHashable>

@Environment(\.reactContext) private var reactContext
@Environment(\.openWindow) private var openWindow
@Environment(\.dismissWindow) private var dismissWindow
@Environment(\.supportsMultipleWindows) private var supportsMultipleWindows

func body(content: Content) -> some View {
// Attach listeners only if app supports multiple windows
if supportsMultipleWindows {
content
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("RCTOpenWindow"))) { data in
guard let id = data.userInfo?["id"] as? String else { return }
reactContext.scenes.updateValue(RCTSceneData(id: id, props: data.userInfo?["userInfo"] as? UserInfoType), forKey: id)
openWindow(id: id)
}
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("RCTUpdateWindow"))) { data in
guard
let id = data.userInfo?["id"] as? String,
let userInfo = data.userInfo?["userInfo"] as? UserInfoType else { return }
reactContext.scenes[id]?.props = userInfo
}
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("RCTDismissWindow"))) { data in
guard let id = data.userInfo?["id"] as? String else { return }
dismissWindow(id: id)
reactContext.scenes.removeValue(forKey: id)
}
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("RCTOpenImmersiveSpace"))) { data in
guard let id = data.userInfo?["id"] as? String else { return }
reactContext.scenes.updateValue(
RCTSceneData(id: id, props: data.userInfo?["userInfo"] as? UserInfoType),
forKey: id
)
}
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("RCTDismissImmersiveSpace"))) { data in
guard let id = data.userInfo?["id"] as? String else { return }
reactContext.scenes.removeValue(forKey: id)
}
} else {
content
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import SwiftUI
import Observation

@Observable
public class RCTSceneData: Identifiable {
public var id: String
public var props: Dictionary<String, AnyHashable>?

init(id: String, props: Dictionary<String, AnyHashable>?) {
self.id = id
self.props = props
}
}

extension RCTSceneData: Equatable {
public static func == (lhs: RCTSceneData, rhs: RCTSceneData) -> Bool {
lhs.id == rhs.id && NSDictionary(dictionary: lhs.props ?? [:]).isEqual(to: rhs.props ?? [:])
}
}

@Observable
public class RCTReactContext {
public var scenes: Dictionary<String, RCTSceneData> = [:]

public func getSceneData(id: String) -> RCTSceneData? {
return scenes[id]
}
}

extension RCTReactContext: Equatable {
public static func == (lhs: RCTReactContext, rhs: RCTReactContext) -> Bool {
NSDictionary(dictionary: lhs.scenes).isEqual(to: rhs.scenes)
}
}

public extension EnvironmentValues {
var reactContext: RCTReactContext {
get { self[RCTSceneContextKey.self] }
set { self[RCTSceneContextKey.self] = newValue }
}
}

private struct RCTSceneContextKey: EnvironmentKey {
static var defaultValue: RCTReactContext = RCTReactContext()
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@
- (instancetype _Nonnull)initWithModuleName:(NSString *_Nonnull)moduleName
initProps:(NSDictionary *_Nullable)initProps;

-(void)updateProps:(NSDictionary *_Nullable)newProps;

@end
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
#import "RCTReactViewController.h"
#import <React/RCTConstants.h>
#import <React/RCTUtils.h>
#import <React/RCTRootView.h>

@protocol RCTRootViewFactoryProtocol <NSObject>

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

@end

@protocol RCTFocusedWindowProtocol <NSObject>

@property (nonatomic, nullable) UIWindow *lastFocusedWindow;

@end

@implementation RCTReactViewController

- (instancetype)initWithModuleName:(NSString *)moduleName initProps:(NSDictionary *)initProps {
Expand All @@ -33,4 +41,29 @@ - (void)loadView {
}
}

- (void)viewDidLoad {
UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapGesture:)];
[self.view addGestureRecognizer:tapGesture];
}

- (void)tapGesture:(UITapGestureRecognizer*)recognizer {
id<RCTFocusedWindowProtocol> appDelegate = (id<RCTFocusedWindowProtocol>)RCTSharedApplication().delegate;

if (![appDelegate respondsToSelector:@selector(lastFocusedWindow)]) {
return;
}

UIWindow *targetWindow = recognizer.view.window;
if (targetWindow != appDelegate.lastFocusedWindow) {
appDelegate.lastFocusedWindow = targetWindow;
}
}

- (void)updateProps:(NSDictionary *)newProps {
RCTRootView *rootView = (RCTRootView *)self.view;
if (![rootView.appProperties isEqualToDictionary:newProps]) {
[rootView setAppProperties:newProps];
}
}

@end
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ public struct RCTRootViewRepresentable: UIViewControllerRepresentable {
self.initialProps = initialProps
}

public func makeUIViewController(context: Context) -> UIViewController {
public func makeUIViewController(context: Context) -> RCTReactViewController {
RCTReactViewController(moduleName: moduleName, initProps: initialProps)
}

public func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
// noop
public func updateUIViewController(_ uiViewController: RCTReactViewController, context: Context) {
uiViewController.updateProps(initialProps)
}
}
45 changes: 45 additions & 0 deletions packages/react-native/Libraries/SwiftExtensions/RCTWindow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import SwiftUI
import React

/**
`RCTWindow` is a SwiftUI struct that returns additional scenes.

Example usage:
```
RCTWindow(id: "SecondWindow", sceneData: reactContext.getSceneData(id: "SecondWindow"))
```
*/
public struct RCTWindow : Scene {
var id: String
var sceneData: RCTSceneData?
var moduleName: String

public init(id: String, moduleName: String, sceneData: RCTSceneData?) {
self.id = id
self.moduleName = moduleName
self.sceneData = sceneData
}

public var body: some Scene {
WindowGroup(id: id) {
Group {
if let sceneData {
RCTRootViewRepresentable(moduleName: moduleName, initialProps: sceneData.props)
}
}
.onAppear {
if sceneData == nil {
RCTFatal(RCTErrorWithMessage("Passed scene data is nil, make sure to pass sceneContext to RCTWindow() in App.swift"))
}
}
}
}
}

extension RCTWindow {
public init(id: String, sceneData: RCTSceneData?) {
self.id = id
self.moduleName = id
self.sceneData = sceneData
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ Pod::Spec.new do |s|

s.dependency "React-Core"
s.dependency "React-RCTXR"
s.dependency "React-RCTWindowManager"
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* @flow strict
* @format
*/

export * from '../../src/private/specs/visionos_modules/NativeWindowManager';
import NativeWindowManager from '../../src/private/specs/visionos_modules/NativeWindowManager';
export default NativeWindowManager;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#import <Foundation/Foundation.h>
#import <React/RCTBridgeModule.h>

@interface RCTWindowManager : NSObject <RCTBridgeModule>

@end
90 changes: 90 additions & 0 deletions packages/react-native/Libraries/WindowManager/RCTWindowManager.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#import <React/RCTWindowManager.h>

#import <FBReactNativeSpec_visionOS/FBReactNativeSpec_visionOS.h>

#import <React/RCTBridge.h>
#import <React/RCTConvert.h>
#import <React/RCTUtils.h>

// Events
static NSString *const RCTOpenWindow = @"RCTOpenWindow";
static NSString *const RCTDismissWindow = @"RCTDismissWindow";
static NSString *const RCTUpdateWindow = @"RCTUpdateWindow";

@interface RCTWindowManager () <NativeWindowManagerSpec>
@end

@implementation RCTWindowManager

RCT_EXPORT_MODULE(WindowManager)

RCT_EXPORT_METHOD(openWindow
: (NSString *)windowId userInfo
: (NSDictionary *)userInfo resolve
: (RCTPromiseResolveBlock)resolve reject
: (RCTPromiseRejectBlock)reject)
{
RCTExecuteOnMainQueue(^{
if (!RCTSharedApplication().supportsMultipleScenes) {
reject(@"ERROR", @"Multiple scenes not supported", nil);
}
NSMutableDictionary *userInfoDict = [[NSMutableDictionary alloc] init];
[userInfoDict setValue:windowId forKey:@"id"];
if (userInfo != nil) {
[userInfoDict setValue:userInfo forKey:@"userInfo"];
}
[[NSNotificationCenter defaultCenter] postNotificationName:RCTOpenWindow object:self userInfo:userInfoDict];
resolve(nil);
});
}

RCT_EXPORT_METHOD(closeWindow
: (NSString *)windowId resolve
: (RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
{
RCTExecuteOnMainQueue(^{
[[NSNotificationCenter defaultCenter] postNotificationName:RCTDismissWindow object:self userInfo:@{@"id": windowId}];
resolve(nil);
});
}

RCT_EXPORT_METHOD(updateWindow
: (NSString *)windowId userInfo
: (NSDictionary *)userInfo resolve
: (RCTPromiseResolveBlock)resolve reject
: (RCTPromiseRejectBlock)reject)
{
RCTExecuteOnMainQueue(^{
if (!RCTSharedApplication().supportsMultipleScenes) {
reject(@"ERROR", @"Multiple scenes not supported", nil);
}
NSMutableDictionary *userInfoDict = [[NSMutableDictionary alloc] init];
[userInfoDict setValue:windowId forKey:@"id"];
if (userInfo != nil) {
[userInfoDict setValue:userInfo forKey:@"userInfo"];
}
[[NSNotificationCenter defaultCenter] postNotificationName:RCTUpdateWindow object:self userInfo:userInfoDict];
resolve(nil);
});
}

- (facebook::react::ModuleConstants<JS::NativeWindowManager::Constants::Builder>)constantsToExport {
return [self getConstants];
}

- (facebook::react::ModuleConstants<JS::NativeWindowManager::Constants>)getConstants {
__block facebook::react::ModuleConstants<JS::NativeWindowManager::Constants> constants;
RCTUnsafeExecuteOnMainQueueSync(^{
constants = facebook::react::typedConstants<JS::NativeWindowManager::Constants>({
.supportsMultipleScenes = RCTSharedApplication().supportsMultipleScenes
});
});

return constants;
}

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params {
return std::make_shared<facebook::react::NativeWindowManagerSpecJSI>(params);
}

@end
Loading