Skip to content

Commit d2eb21b

Browse files
authored
feat: implement XR API (#81)
* feat: implement Spatial API * feat: make RCTSpatial decoupled from RCTMainWindow() * feat: implement XR module
1 parent fd0c8ca commit d2eb21b

23 files changed

+572
-52
lines changed

README.md

+97-1
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/SwiftExtensions/React-RCTSwiftExtensions.podspec

+1
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
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+
}
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
@@ -0,0 +1,6 @@
1+
#import <Foundation/Foundation.h>
2+
#import <React/RCTBridgeModule.h>
3+
4+
@interface RCTXRModule : NSObject <RCTBridgeModule>
5+
6+
@end
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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
require "json"
2+
3+
package = JSON.parse(File.read(File.join(__dir__, "..", "..", "package.json")))
4+
version = package['version']
5+
6+
source = { :git => 'https://github.com/facebook/react-native.git' }
7+
if version == '1000.0.0'
8+
# This is an unpublished version, use the latest commit hash of the react-native repo, which we’re presumably in.
9+
source[:commit] = `git rev-parse HEAD`.strip if system("git rev-parse --git-dir > /dev/null 2>&1")
10+
else
11+
source[:tag] = "v#{version}"
12+
end
13+
14+
folly_config = get_folly_config()
15+
folly_compiler_flags = folly_config[:compiler_flags]
16+
folly_version = folly_config[:version]
17+
18+
header_search_paths = [
19+
"\"$(PODS_ROOT)/RCT-Folly\"",
20+
"\"${PODS_ROOT}/Headers/Public/React-Codegen/react/renderer/components\"",
21+
]
22+
23+
Pod::Spec.new do |s|
24+
s.name = "React-RCTXR"
25+
s.version = version
26+
s.summary = "XR module for React Native."
27+
s.homepage = "https://reactnative.dev/"
28+
s.documentation_url = "https://reactnative.dev/docs/settings"
29+
s.license = package["license"]
30+
s.author = "Callstack"
31+
s.platforms = min_supported_versions
32+
s.compiler_flags = folly_compiler_flags + ' -Wno-nullability-completeness'
33+
s.source = source
34+
s.source_files = "*.{m,mm,swift}"
35+
s.preserve_paths = "package.json", "LICENSE", "LICENSE-docs"
36+
s.header_dir = "RCTXR"
37+
s.pod_target_xcconfig = {
38+
"USE_HEADERMAP" => "YES",
39+
"CLANG_CXX_LANGUAGE_STANDARD" => "c++20",
40+
"HEADER_SEARCH_PATHS" => header_search_paths.join(' ')
41+
}
42+
43+
s.dependency "RCT-Folly", folly_version
44+
s.dependency "RCTTypeSafety"
45+
s.dependency "React-jsi"
46+
s.dependency "React-Core/RCTXRHeaders"
47+
48+
add_dependency(s, "React-Codegen", :additional_framework_paths => ["build/generated/ios"])
49+
add_dependency(s, "ReactCommon", :subspec => "turbomodule/core", :additional_framework_paths => ["react/nativemodule/core"])
50+
add_dependency(s, "React-NativeModulesApple", :additional_framework_paths => ["build/generated/ios"])
51+
end
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;
+33
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;

0 commit comments

Comments
 (0)