Skip to content

Commit

Permalink
feat: add multi-window support (#117)
Browse files Browse the repository at this point in the history
* feat: add multi-window support

* feat: introduce WindowManager

fix: RCTReactViewController properly check props to update

fix: use clearColor instead of systemBackgroundColor for visionOS (#125)

# Conflicts:
#	packages/react-native/Libraries/AppDelegate/RCTRootViewFactory.mm
  • Loading branch information
okwasniewski committed Mar 19, 2024
1 parent aac5971 commit d7fa3d4
Show file tree
Hide file tree
Showing 32 changed files with 850 additions and 163 deletions.
4 changes: 3 additions & 1 deletion packages/react-native/Libraries/AppDelegate/RCTAppDelegate.h
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ NS_ASSUME_NONNULL_BEGIN

/// The window object, used to render the UViewControllers
@property (nonatomic, strong, nonnull) UIWindow *window;
@property (nonatomic, nullable) RCTBridge *bridge;
/// 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;
@property (nonatomic, strong, nonnull) RCTRootViewFactory *rootViewFactory;
Expand Down
4 changes: 4 additions & 0 deletions packages/react-native/Libraries/AppDelegate/RCTAppDelegate.mm
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,11 @@ - (UIView *)createRootViewWithBridge:(RCTBridge *)bridge
BOOL enableFabric = self.fabricEnabled;
UIView *rootView = RCTAppSetupDefaultRootView(bridge, moduleName, initProps, enableFabric);

#if TARGET_OS_VISION
rootView.backgroundColor = [UIColor clearColor];
#else
rootView.backgroundColor = [UIColor systemBackgroundColor];
#endif

return rootView;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,11 @@ - (UIView *)viewWithModuleName:(NSString *)moduleName
initWithSurface:surface
sizeMeasureMode:RCTSurfaceSizeMeasureModeWidthExact | RCTSurfaceSizeMeasureModeHeightExact];

#if TARGET_OS_VISION
surfaceHostingProxyRootView.backgroundColor = [UIColor clearColor];
#else
surfaceHostingProxyRootView.backgroundColor = [UIColor systemBackgroundColor];
#endif
return surfaceHostingProxyRootView;
}

Expand All @@ -156,7 +161,11 @@ - (UIView *)createRootViewWithBridge:(RCTBridge *)bridge
BOOL enableFabric = self->_configuration.fabricEnabled;
UIView *rootView = RCTAppSetupDefaultRootView(bridge, moduleName, initProps, enableFabric);

#if TARGET_OS_VISION
rootView.backgroundColor = [UIColor clearColor];
#else
rootView.backgroundColor = [UIColor systemBackgroundColor];
#endif

return rootView;
}
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
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
16 changes: 16 additions & 0 deletions packages/react-native/Libraries/WindowManager/WindowManager.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export interface WindowStatic {
id: String;
open (props?: Object): Promise<void>;
update (props: Object): Promise<void>;
close (): Promise<void>;
}

export interface WindowManagerStatic {
getWindow(id: String): Window;
supportsMultipleScenes: boolean;
}

export const WindowManager: WindowManagerStatic;
export type WindowManager = WindowManagerStatic;
export const Window: WindowStatic;
export type Window = WindowStatic;
Loading

0 comments on commit d7fa3d4

Please sign in to comment.