Skip to content

Commit 4c68104

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 4a84c7d commit 4c68104

File tree

23 files changed

+369
-72
lines changed

23 files changed

+369
-72
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,5 @@ fix_*.patch
165165

166166
# [Experimental] Generated TS type definitions
167167
/packages/**/types_generated/
168+
.circleci/storage
169+
.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/React-Core.podspec

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

4748
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: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,8 +244,14 @@ module.exports = {
244244
get Share() {
245245
return require('./Libraries/Share/Share').default;
246246
},
247+
get XR() {
248+
return require('./Libraries/XR/XR');
249+
},
250+
get WindowManager() {
251+
return require('./Libraries/WindowManager/WindowManager');
252+
},
247253
get StyleSheet() {
248-
return require('./Libraries/StyleSheet/StyleSheet').default;
254+
return require('./Libraries/StyleSheet/StyleSheet');
249255
},
250256
get Systrace() {
251257
return require('./Libraries/Performance/Systrace');

packages/react-native/index.js.flow

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,9 @@ export {default as PixelRatio} from './Libraries/Utilities/PixelRatio';
8787
export {default as PushNotificationIOS} from './Libraries/PushNotificationIOS/PushNotificationIOS';
8888
export {default as Settings} from './Libraries/Settings/Settings';
8989
export {default as Share} from './Libraries/Share/Share';
90-
export {default as StyleSheet} from './Libraries/StyleSheet/StyleSheet';
90+
export {default as StyleSheet} from './Libraries/WindowManager/WindowManager';
91+
export {default as XR} from './Libraries/XR/XR';
92+
export {default as WindowManager} from './Libraries/WindowManager/WindowManager';
9193
export * as Systrace from './Libraries/Performance/Systrace';
9294
export {default as ToastAndroid} from './Libraries/Components/ToastAndroid/ToastAndroid';
9395
export * as TurboModuleRegistry from './Libraries/TurboModule/TurboModuleRegistry';

packages/react-native/package.json

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

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,7 @@ def self.react_native_pods
652652
"glog",
653653
"hermes-engine",
654654
"React-hermes",
655+
"React-RCTXR", # visionOS
655656
]
656657
if ENV['USE_THIRD_PARTY_JSC'] != '1'
657658
pods << "React-jsc"

packages/react-native/scripts/react_native_pods.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ def use_react_native! (
141141
pod 'React-RCTFBReactNativeSpec', :path => "#{prefix}/React"
142142
pod 'React-jsi', :path => "#{prefix}/ReactCommon/jsi"
143143
pod 'React-RCTSwiftExtensions', :path => "#{prefix}/Libraries/SwiftExtensions"
144+
pod 'React-RCTXR', :path => "#{prefix}/Libraries/XR"
144145

145146
if hermes_enabled
146147
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
@@ -148,6 +148,7 @@ export * from '../Libraries/Utilities/Dimensions';
148148
export * from '../Libraries/Utilities/PixelRatio';
149149
export * from '../Libraries/Utilities/Platform';
150150
export * from '../Libraries/Vibration/Vibration';
151+
export * from '../Libraries/XR/XR';
151152
export * from '../Libraries/vendor/core/ErrorUtils';
152153
export {
153154
EmitterSubscription,

0 commit comments

Comments
 (0)