Skip to content

Commit d44ac88

Browse files
committed
feat: implement XR module
1 parent 77f00b4 commit d44ac88

28 files changed

+412
-153
lines changed

README.md

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,110 @@ This is a prop on `<View />` component allowing to add hover effect. It's applie
6969

7070
If you want to customize it you can use the `visionos_hoverEffect` prop, like so:
7171

72-
```jsx
72+
```tsx
7373
<TouchableOpacity visionos_hoverEffect="lift">
7474
<Text>Click me</Text>
7575
</TouchableOpacity>
7676
```
7777

7878
The available options are: `lift` or `highlight`.
7979

80+
### `XR` API
81+
Manage Immersive Experiences.
82+
83+
#### Methods
84+
**`requestSession`**
85+
```js
86+
requestSession: (sessionId?: string) => Promise<void>
87+
```
88+
Opens a new [`ImmersiveSpace`](https://developer.apple.com/documentation/swiftui/immersive-spaces) given it's unique `Id`.
89+
90+
**`endSession`**
91+
```js
92+
endSession: () => Promise<void>
93+
```
94+
Closes currently open `ImmersiveSpace`.
95+
96+
#### Constants
97+
**`supportsMultipleScenes`**
98+
```js
99+
supportsMultipleScenes: boolean
100+
```
101+
A Boolean value that indicates whether the app may display multiple scenes simultaneously. Returns the value of `UIApplicationSupportsMultipleScenes` key from `Info.plist`.
102+
103+
### Example Usage
104+
105+
1. Set `UIApplicationSupportsMultipleScenes` to `true` in `Info.plist`:
106+
```diff
107+
<?xml version="1.0" encoding="UTF-8"?>
108+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
109+
<plist version="1.0">
110+
<dict>
111+
<key>UIApplicationSceneManifest</key>
112+
<dict>
113+
<key>UIApplicationPreferredDefaultSceneSessionRole</key>
114+
<string>UIWindowSceneSessionRoleApplication</string>
115+
<key>UIApplicationSupportsMultipleScenes</key>
116+
- <false/>
117+
+ <true/>
118+
<key>UISceneConfigurations</key>
119+
<dict/>
120+
</dict>
121+
</dict>
122+
</plist>
123+
124+
```
125+
126+
127+
1. Inside `App.swift` add new `ImmersiveSpace`:
128+
```diff
129+
@main
130+
struct HelloWorldApp: App {
131+
@UIApplicationDelegateAdaptor var delegate: AppDelegate
132+
+ @State private var immersionLevel: ImmersionStyle = .mixed
133+
134+
var body: some Scene {
135+
RCTMainWindow(moduleName: "HelloWorldApp")
136+
+ ImmersiveSpace(id: "TestImmersiveSpace") {
137+
+ // RealityKit content goes here
138+
+ }
139+
+ .immersionStyle(selection: $immersionLevel, in: .mixed, .progressive, .full)
140+
}
141+
}
142+
```
143+
For more information about `ImmersiveSpace` API refer to [Apple documentation](https://developer.apple.com/documentation/swiftui/immersive-spaces).
144+
145+
In the above example we set `ImmersiveSpace` id to `TestImmersiveSpace`.
146+
147+
Now in our JS code, we can call:
148+
149+
```js
150+
import {XR} from "@callstack/react-native-visionos"
151+
//...
152+
const openXRSession = async () => {
153+
try {
154+
if (!XR.supportsMultipleScenes) {
155+
Alert.alert('Error', 'Multiple scenes are not supported');
156+
return;
157+
}
158+
await XR.requestSession('TestImmersiveSpace'); // Pass the same identifier from `App.swift`
159+
} catch (e) {
160+
Alert.alert('Error', e.message);
161+
}
162+
};
163+
164+
const closeXRSession = async () => {
165+
await XR.endSession();
166+
};
167+
```
168+
> [!CAUTION]
169+
> Opening an `ImmersiveSpace` can fail in this secarios:
170+
> - `ImmersiveSpace` is not declared.
171+
> - `UIApplicationSupportsMultipleScenes` is set to `false`.
172+
> - User cancels the request.
173+
174+
For a full example usage, refer to [`XRExample.js`](https://github.com/callstack/react-native-visionos/blob/main/packages/rn-tester/js/examples/XR/XRExample.js).
175+
80176
## Contributing
81177

82178
1. Follow the same steps as in the `New project creation` section.

packages/react-native/Libraries/Spatial/NativeSpatialManager.js

Lines changed: 0 additions & 3 deletions
This file was deleted.

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

Lines changed: 0 additions & 8 deletions
This file was deleted.

packages/react-native/Libraries/Spatial/Spatial.js

Lines changed: 0 additions & 12 deletions
This file was deleted.

packages/react-native/Libraries/SwiftExtensions/RCTMainWindow.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import SwiftUI
2-
import React
3-
42

53
/**
64
This SwiftUI struct returns main React Native scene. It should be used only once as it conains setup code.

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

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

2626
s.dependency "React-Core"
27-
s.dependency "React-RCTSpatial"
27+
s.dependency "React-RCTXR"
2828
end

packages/react-native/Libraries/Spatial/ImmersiveBridge.swift renamed to packages/react-native/Libraries/XR/ImmersiveBridge.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import SwiftUI
99

1010
public typealias CompletionHandlerType = (_ result: ImmersiveSpaceResult) -> Void
1111

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+
*/
1217
struct ImmersiveBridgeView: View {
1318
@Environment(\.openImmersiveSpace) private var openImmersiveSpace
1419
@Environment(\.dismissImmersiveSpace) private var dismissImmersiveSpace
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;
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#import <Foundation/Foundation.h>
22
#import <React/RCTBridgeModule.h>
33

4-
@interface RCTSpatialManager : NSObject <RCTBridgeModule>
4+
@interface RCTXRModule : NSObject <RCTBridgeModule>
55

66
@end
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,47 @@
1-
#import <React/RCTSpatialManager.h>
1+
#import <React/RCTXRModule.h>
2+
3+
#import <FBReactNativeSpec_visionOS/FBReactNativeSpec_visionOS.h>
24

3-
#import <FBReactNativeSpec/FBReactNativeSpec.h>
45
#import <React/RCTBridge.h>
56
#import <React/RCTConvert.h>
67
#import <React/RCTUtils.h>
7-
#import "RCTSpatial-Swift.h"
8+
#import "RCTXR-Swift.h"
89

9-
@interface RCTSpatialManager () <NativeSpatialManagerSpec>
10+
@interface RCTXRModule () <NativeXRModuleSpec>
1011
@end
1112

12-
@implementation RCTSpatialManager {
13+
@implementation RCTXRModule {
1314
UIViewController *_immersiveBridgeView;
1415
}
1516

1617
RCT_EXPORT_MODULE()
1718

18-
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params {
19-
return std::make_shared<facebook::react::NativeSpatialManagerSpecJSI>(params);
20-
}
21-
22-
RCT_EXPORT_METHOD(dismissImmersiveSpace
19+
RCT_EXPORT_METHOD(endSession
2320
: (RCTPromiseResolveBlock)resolve reject
2421
: (RCTPromiseRejectBlock)reject)
2522
{
26-
RCTExecuteOnMainQueue(^{
27-
[self->_immersiveBridgeView willMoveToParentViewController:nil];
28-
[self->_immersiveBridgeView.view removeFromSuperview];
29-
[self->_immersiveBridgeView removeFromParentViewController];
30-
self->_immersiveBridgeView = nil;
31-
32-
resolve(nil);
33-
});
23+
[self removeImmersiveBridge];
24+
resolve(nil);
3425
}
3526

36-
RCT_EXPORT_METHOD(openImmersiveSpace
37-
: (NSString *)sceneId resolve
27+
28+
RCT_EXPORT_METHOD(requestSession
29+
: (NSString *)sessionId resolve
3830
: (RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
3931
{
4032
RCTExecuteOnMainQueue(^{
4133
UIWindow *keyWindow = RCTKeyWindow();
4234
UIViewController *rootViewController = keyWindow.rootViewController;
4335

4436
if (self->_immersiveBridgeView == nil) {
45-
self->_immersiveBridgeView = [ImmersiveBridgeFactory makeImmersiveBridgeViewWithSpaceId:sceneId
46-
completionHandler:^(enum ImmersiveSpaceResult result){
37+
self->_immersiveBridgeView = [ImmersiveBridgeFactory makeImmersiveBridgeViewWithSpaceId:sessionId
38+
completionHandler:^(enum ImmersiveSpaceResult result){
4739
if (result == ImmersiveSpaceResultError) {
4840
reject(@"ERROR", @"Immersive Space failed to open, the system cannot fulfill the request.", nil);
41+
[self removeImmersiveBridge];
4942
} else if (result == ImmersiveSpaceResultUserCancelled) {
5043
reject(@"ERROR", @"Immersive Space canceled by user", nil);
44+
[self removeImmersiveBridge];
5145
} else if (result == ImmersiveSpaceResultOpened) {
5246
resolve(nil);
5347
}
@@ -62,4 +56,33 @@ @implementation RCTSpatialManager {
6256
});
6357
}
6458

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+
6588
@end

packages/react-native/Libraries/Spatial/React-RCTSpatial.podspec renamed to packages/react-native/Libraries/XR/React-RCTXR.podspec

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ header_search_paths = [
2121
]
2222

2323
Pod::Spec.new do |s|
24-
s.name = "React-RCTSpatial"
24+
s.name = "React-RCTXR"
2525
s.version = version
26-
s.summary = "Spatial module for React Native."
26+
s.summary = "XR module for React Native."
2727
s.homepage = "https://reactnative.dev/"
2828
s.documentation_url = "https://reactnative.dev/docs/settings"
2929
s.license = package["license"]
@@ -33,7 +33,7 @@ Pod::Spec.new do |s|
3333
s.source = source
3434
s.source_files = "*.{m,mm,swift}"
3535
s.preserve_paths = "package.json", "LICENSE", "LICENSE-docs"
36-
s.header_dir = "RCTSpatial"
36+
s.header_dir = "RCTXR"
3737
s.pod_target_xcconfig = {
3838
"USE_HEADERMAP" => "YES",
3939
"CLANG_CXX_LANGUAGE_STANDARD" => "c++20",
@@ -43,7 +43,7 @@ Pod::Spec.new do |s|
4343
s.dependency "RCT-Folly", folly_version
4444
s.dependency "RCTTypeSafety"
4545
s.dependency "React-jsi"
46-
s.dependency "React-Core/RCTSpatialHeaders"
46+
s.dependency "React-Core/RCTXRHeaders"
4747

4848
add_dependency(s, "React-Codegen", :additional_framework_paths => ["build/generated/ios"])
4949
add_dependency(s, "ReactCommon", :subspec => "turbomodule/core", :additional_framework_paths => ["react/nativemodule/core"])
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
@@ -9151,6 +9151,22 @@ exports[`public API should not change unintentionally Libraries/WebSocket/WebSoc
91519151

91529152
exports[`public API should not change unintentionally Libraries/WebSocket/WebSocketInterceptor.js 1`] = `"UNTYPED MODULE"`;
91539153

9154+
exports[`public API should not change unintentionally Libraries/XR/NativeXRModule.js 1`] = `
9155+
"export * from \\"../../src/private/specs/visionos_modules/NativeXRModule\\";
9156+
declare export default typeof NativeXRModule;
9157+
"
9158+
`;
9159+
9160+
exports[`public API should not change unintentionally Libraries/XR/XR.js 1`] = `
9161+
"declare const XR: {
9162+
requestSession: (sessionId?: string) => Promise<void>,
9163+
endSession: () => Promise<void>,
9164+
get supportsMultipleScenes(): boolean,
9165+
};
9166+
declare module.exports: XR;
9167+
"
9168+
`;
9169+
91549170
exports[`public API should not change unintentionally Libraries/YellowBox/YellowBoxDeprecated.js 1`] = `
91559171
"declare const React: $FlowFixMe;
91569172
type Props = $ReadOnly<{||}>;
@@ -9268,6 +9284,7 @@ declare module.exports: {
92689284
get PushNotificationIOS(): PushNotificationIOS,
92699285
get Settings(): Settings,
92709286
get Share(): Share,
9287+
get XR(): XR,
92719288
get StyleSheet(): StyleSheet,
92729289
get Systrace(): Systrace,
92739290
get ToastAndroid(): ToastAndroid,

packages/react-native/React-Core.podspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ header_subspecs = {
3737
'RCTSettingsHeaders' => 'Libraries/Settings/*.h',
3838
'RCTTextHeaders' => 'Libraries/Text/**/*.h',
3939
'RCTVibrationHeaders' => 'Libraries/Vibration/*.h',
40-
'RCTSpatialHeaders' => 'Libraries/Spatial/*.h',
40+
'RCTXRHeaders' => 'Libraries/XR/*.h',
4141
}
4242

4343
frameworks_search_paths = []

0 commit comments

Comments
 (0)