Skip to content

Commit 7fef120

Browse files
committed
feat: implement XR API (#81)
* feat: implement Spatial API * feat: make RCTSpatial decoupled from RCTMainWindow() * feat: implement XR module docs: add image to README, annotate nightly APIs fix: export XR library from typescript
1 parent 0554153 commit 7fef120

File tree

23 files changed

+356
-66
lines changed

23 files changed

+356
-66
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ vendor/
155155

156156
# CircleCI
157157
.circleci/generated_config.yml
158-
159158
# Jest Integration
160159
/jest/integration/build/
160+
.circleci/storage
161+
.yarn

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,5 @@ Pod::Spec.new do |s|
2424
s.frameworks = ["UIKit", "SwiftUI"]
2525

2626
s.dependency "React-Core"
27+
s.dependency "React-RCTXR"
2728
end
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import Foundation
2+
import SwiftUI
3+
4+
@objc public enum ImmersiveSpaceResult: Int {
5+
case opened
6+
case userCancelled
7+
case error
8+
}
9+
10+
public typealias CompletionHandlerType = (_ result: ImmersiveSpaceResult) -> Void
11+
12+
/**
13+
* Utility view used to bridge the gap between SwiftUI environment and UIKit.
14+
*
15+
* Calls `openImmersiveSpace` when view appears in the UIKit hierarchy and `dismissImmersiveSpace` when removed.
16+
*/
17+
struct ImmersiveBridgeView: View {
18+
@Environment(\.openImmersiveSpace) private var openImmersiveSpace
19+
@Environment(\.dismissImmersiveSpace) private var dismissImmersiveSpace
20+
21+
var spaceId: String
22+
var completionHandler: CompletionHandlerType
23+
24+
var body: some View {
25+
EmptyView()
26+
.onAppear {
27+
Task {
28+
let result = await openImmersiveSpace(id: spaceId)
29+
30+
switch result {
31+
case .opened:
32+
completionHandler(.opened)
33+
case .error:
34+
completionHandler(.error)
35+
case .userCancelled:
36+
completionHandler(.userCancelled)
37+
default:
38+
break
39+
}
40+
}
41+
}
42+
.onDisappear {
43+
Task { await dismissImmersiveSpace() }
44+
}
45+
}
46+
}
47+
48+
@objc public class ImmersiveBridgeFactory: NSObject {
49+
@objc public static func makeImmersiveBridgeView(
50+
spaceId: String,
51+
completionHandler: @escaping CompletionHandlerType
52+
) -> UIViewController {
53+
return UIHostingController(rootView: ImmersiveBridgeView(spaceId: spaceId, completionHandler: completionHandler))
54+
}
55+
}
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/NativeXRModule';
7+
import NativeXRModule from '../../src/private/specs/visionos_modules/NativeXRModule';
8+
export default NativeXRModule;
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 RCTXRModule : NSObject <RCTBridgeModule>
5+
6+
@end
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
#import <React/RCTXRModule.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+
#import "RCTXR-Swift.h"
9+
10+
@interface RCTXRModule () <NativeXRModuleSpec>
11+
@end
12+
13+
@implementation RCTXRModule {
14+
UIViewController *_immersiveBridgeView;
15+
}
16+
17+
RCT_EXPORT_MODULE()
18+
19+
RCT_EXPORT_METHOD(endSession
20+
: (RCTPromiseResolveBlock)resolve reject
21+
: (RCTPromiseRejectBlock)reject)
22+
{
23+
[self removeImmersiveBridge];
24+
resolve(nil);
25+
}
26+
27+
28+
RCT_EXPORT_METHOD(requestSession
29+
: (NSString *)sessionId resolve
30+
: (RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
31+
{
32+
RCTExecuteOnMainQueue(^{
33+
UIWindow *keyWindow = RCTKeyWindow();
34+
UIViewController *rootViewController = keyWindow.rootViewController;
35+
36+
if (self->_immersiveBridgeView == nil) {
37+
self->_immersiveBridgeView = [ImmersiveBridgeFactory makeImmersiveBridgeViewWithSpaceId:sessionId
38+
completionHandler:^(enum ImmersiveSpaceResult result){
39+
if (result == ImmersiveSpaceResultError) {
40+
reject(@"ERROR", @"Immersive Space failed to open, the system cannot fulfill the request.", nil);
41+
[self removeImmersiveBridge];
42+
} else if (result == ImmersiveSpaceResultUserCancelled) {
43+
reject(@"ERROR", @"Immersive Space canceled by user", nil);
44+
[self removeImmersiveBridge];
45+
} else if (result == ImmersiveSpaceResultOpened) {
46+
resolve(nil);
47+
}
48+
}];
49+
50+
[rootViewController.view addSubview:self->_immersiveBridgeView.view];
51+
[rootViewController addChildViewController:self->_immersiveBridgeView];
52+
[self->_immersiveBridgeView didMoveToParentViewController:rootViewController];
53+
} else {
54+
reject(@"ERROR", @"Immersive Space already opened", nil);
55+
}
56+
});
57+
}
58+
59+
- (facebook::react::ModuleConstants<JS::NativeXRModule::Constants::Builder>)constantsToExport {
60+
return [self getConstants];
61+
}
62+
63+
- (facebook::react::ModuleConstants<JS::NativeXRModule::Constants>)getConstants {
64+
__block facebook::react::ModuleConstants<JS::NativeXRModule::Constants> constants;
65+
RCTUnsafeExecuteOnMainQueueSync(^{
66+
constants = facebook::react::typedConstants<JS::NativeXRModule::Constants>({
67+
.supportsMultipleScenes = RCTSharedApplication().supportsMultipleScenes
68+
});
69+
});
70+
71+
return constants;
72+
}
73+
74+
- (void) removeImmersiveBridge
75+
{
76+
RCTExecuteOnMainQueue(^{
77+
[self->_immersiveBridgeView willMoveToParentViewController:nil];
78+
[self->_immersiveBridgeView.view removeFromSuperview];
79+
[self->_immersiveBridgeView removeFromParentViewController];
80+
self->_immersiveBridgeView = nil;
81+
});
82+
}
83+
84+
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params {
85+
return std::make_shared<facebook::react::NativeXRModuleSpecJSI>(params);
86+
}
87+
88+
@end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
2+
export interface XRStatic {
3+
requestSession(sessionId: string): Promise<void>;
4+
endSession(): Promise<void>;
5+
supportsMultipleScenes: boolean;
6+
}
7+
8+
export const XR: XRStatic;
9+
export type XR = XRStatic;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* @format
3+
* @flow strict
4+
* @jsdoc
5+
*/
6+
7+
import NativeXRModule from './NativeXRModule';
8+
9+
const XR = {
10+
requestSession: (sessionId?: string): Promise<void> => {
11+
if (NativeXRModule != null && NativeXRModule.requestSession != null) {
12+
return NativeXRModule.requestSession(sessionId);
13+
}
14+
return Promise.reject(new Error('NativeXRModule is not available'));
15+
},
16+
endSession: (): Promise<void> => {
17+
if (NativeXRModule != null && NativeXRModule.endSession != null) {
18+
return NativeXRModule.endSession();
19+
}
20+
return Promise.reject(new Error('NativeXRModule is not available'));
21+
},
22+
// $FlowIgnore[unsafe-getters-setters]
23+
get supportsMultipleScenes(): boolean {
24+
if (NativeXRModule == null) {
25+
return false;
26+
}
27+
28+
const nativeConstants = NativeXRModule.getConstants();
29+
return nativeConstants.supportsMultipleScenes || false;
30+
},
31+
};
32+
33+
module.exports = XR;

packages/react-native/Libraries/__tests__/__snapshots__/public-api-test.js.snap

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9507,6 +9507,22 @@ declare module.exports: WebSocketInterceptor;
95079507
"
95089508
`;
95099509

9510+
exports[`public API should not change unintentionally Libraries/XR/NativeXRModule.js 1`] = `
9511+
"export * from \\"../../src/private/specs/visionos_modules/NativeXRModule\\";
9512+
declare export default typeof NativeXRModule;
9513+
"
9514+
`;
9515+
9516+
exports[`public API should not change unintentionally Libraries/XR/XR.js 1`] = `
9517+
"declare const XR: {
9518+
requestSession: (sessionId?: string) => Promise<void>,
9519+
endSession: () => Promise<void>,
9520+
get supportsMultipleScenes(): boolean,
9521+
};
9522+
declare module.exports: XR;
9523+
"
9524+
`;
9525+
95109526
exports[`public API should not change unintentionally Libraries/YellowBox/YellowBoxDeprecated.js 1`] = `
95119527
"declare const React: $FlowFixMe;
95129528
type Props = $ReadOnly<{||}>;
@@ -9626,6 +9642,7 @@ declare module.exports: {
96269642
get PushNotificationIOS(): PushNotificationIOS,
96279643
get Settings(): Settings,
96289644
get Share(): Share,
9645+
get XR(): XR,
96299646
get StyleSheet(): StyleSheet,
96309647
get Systrace(): Systrace,
96319648
get ToastAndroid(): ToastAndroid,

packages/react-native/React-Core.podspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ header_subspecs = {
4141
'RCTSettingsHeaders' => 'Libraries/Settings/*.h',
4242
'RCTTextHeaders' => 'Libraries/Text/**/*.h',
4343
'RCTVibrationHeaders' => 'Libraries/Vibration/*.h',
44+
'RCTXRHeaders' => 'Libraries/XR/*.h',
4445
}
4546

4647
frameworks_search_paths = []

packages/react-native/React.podspec

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,4 +53,5 @@ Pod::Spec.new do |s|
5353
s.dependency "React-RCTSettings", version
5454
s.dependency "React-RCTText", version
5555
s.dependency "React-RCTVibration", version
56+
s.dependency "React-RCTXR", version
5657
end

packages/react-native/React/Base/RCTUtils.m

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,13 @@ BOOL RCTRunningInAppExtension(void)
580580
continue;
581581
}
582582

583+
#if TARGET_OS_VISION
584+
/// Presenting scenes over Immersive Spaces leads to crash: "Presentations are not permitted within volumetric window scenes."
585+
if (scene.session.role == UISceneSessionRoleImmersiveSpaceApplication) {
586+
continue;
587+
}
588+
#endif
589+
583590
if (scene.activationState == UISceneActivationStateForegroundActive) {
584591
foregroundActiveScene = scene;
585592
break;

packages/react-native/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import typeof Platform from './Libraries/Utilities/Platform';
9595
import typeof useColorScheme from './Libraries/Utilities/useColorScheme';
9696
import typeof useWindowDimensions from './Libraries/Utilities/useWindowDimensions';
9797
import typeof Vibration from './Libraries/Vibration/Vibration';
98+
import typeof XR from './Libraries/XR/XR';
9899
import typeof YellowBox from './Libraries/YellowBox/YellowBoxDeprecated';
99100
import typeof DevMenu from './src/private/devmenu/DevMenu';
100101

@@ -309,6 +310,9 @@ module.exports = {
309310
get Share(): Share {
310311
return require('./Libraries/Share/Share');
311312
},
313+
get XR(): XR {
314+
return require('./Libraries/XR/XR');
315+
},
312316
get StyleSheet(): StyleSheet {
313317
return require('./Libraries/StyleSheet/StyleSheet');
314318
},

packages/react-native/package.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,13 @@
154154
"android": {},
155155
"jsSrcsDir": "src"
156156
},
157+
{
158+
"name": "FBReactNativeSpec_visionOS",
159+
"type": "modules",
160+
"ios": {},
161+
"android": {},
162+
"jsSrcsDir": "src/private/specs/visionos_modules"
163+
},
157164
{
158165
"name": "rncore",
159166
"type": "components",

packages/react-native/scripts/cocoapods/utils.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,7 @@ def self.react_native_pods
645645
"glog",
646646
"hermes-engine",
647647
"React-hermes",
648+
"React-RCTXR", # visionOS
648649
]
649650
end
650651

packages/react-native/scripts/react_native_pods.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ def use_react_native! (
136136
pod 'RCTDeprecation', :path => "#{prefix}/ReactApple/Libraries/RCTFoundation/RCTDeprecation"
137137
pod 'React-RCTFBReactNativeSpec', :path => "#{prefix}/React"
138138
pod 'React-RCTSwiftExtensions', :path => "#{prefix}/Libraries/SwiftExtensions"
139+
pod 'React-RCTXR', :path => "#{prefix}/Libraries/XR"
139140

140141
if hermes_enabled
141142
setup_hermes!(:react_native_path => prefix)
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* @flow strict
3+
* @format
4+
*/
5+
6+
import type {TurboModule} from '../../../../Libraries/TurboModule/RCTExport';
7+
8+
import * as TurboModuleRegistry from '../../../../Libraries/TurboModule/TurboModuleRegistry';
9+
10+
export type XRModuleConstants = {|
11+
+supportsMultipleScenes?: boolean,
12+
|};
13+
14+
export interface Spec extends TurboModule {
15+
+getConstants: () => XRModuleConstants;
16+
17+
+requestSession: (sessionId?: string) => Promise<void>;
18+
+endSession: () => Promise<void>;
19+
}
20+
21+
export default (TurboModuleRegistry.get<Spec>('XRModule'): ?Spec);

packages/react-native/types/index.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ export * from '../Libraries/Utilities/Dimensions';
147147
export * from '../Libraries/Utilities/PixelRatio';
148148
export * from '../Libraries/Utilities/Platform';
149149
export * from '../Libraries/Vibration/Vibration';
150+
export * from '../Libraries/XR/XR';
150151
export * from '../Libraries/YellowBox/YellowBoxDeprecated';
151152
export * from '../Libraries/vendor/core/ErrorUtils';
152153
export {

0 commit comments

Comments
 (0)