Skip to content

Commit 5dd933d

Browse files
committed
feat: implement XR API (#81)
* feat: implement Spatial API * feat: make RCTSpatial decoupled from RCTMainWindow() * feat: implement XR module # Conflicts: # packages/rn-tester/Podfile.lock # Conflicts: # packages/rn-tester/Podfile.lock
1 parent 383d50e commit 5dd933d

File tree

24 files changed

+526
-2
lines changed

24 files changed

+526
-2
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/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: 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 '../TurboModule/RCTExport';
7+
8+
import * as TurboModuleRegistry from '../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);
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: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
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+
+ (BOOL)requiresMainQueueSetup
89+
{
90+
return YES;
91+
}
92+
93+
@end
94+
95+
Class RCTXRModuleCls(void)
96+
{
97+
return RCTXRModule.class;
98+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#import <Foundation/Foundation.h>
2+
3+
#pragma GCC diagnostic push
4+
#pragma GCC diagnostic ignored "-Wreturn-type-c-linkage"
5+
6+
#ifdef __cplusplus
7+
extern "C" {
8+
#endif
9+
10+
// RCTTurboModuleManagerDelegate should call this to resolve module classes.
11+
Class RCTXRModuleClassProvider(const char* name);
12+
13+
// Lookup functions
14+
Class RCTXRModuleCls(void) __attribute__((used));
15+
16+
#ifdef __cplusplus
17+
}
18+
#endif
19+
20+
#pragma GCC diagnostic pop
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#import "RCTXRModulePlugins.h"
2+
3+
#import <string>
4+
#import <unordered_map>
5+
6+
Class RCTXRModuleClassProvider(const char *name) {
7+
// Intentionally leak to avoid crashing after static destructors are run.
8+
static const auto sCoreModuleClassMap = new const std::unordered_map<std::string, Class (*)(void)>{
9+
{"XR", RCTXRModuleCls},
10+
};
11+
12+
auto p = sCoreModuleClassMap->find(name);
13+
if (p != sCoreModuleClassMap->end()) {
14+
auto classFunc = p->second;
15+
return classFunc();
16+
}
17+
return nil;
18+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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_compiler_flags = '-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1 -DFOLLY_CFG_NO_COROUTINES=1 -Wno-comma -Wno-shorten-64-to-32'
15+
folly_version = '2022.05.16.00'
16+
17+
header_search_paths = [
18+
"\"$(PODS_ROOT)/RCT-Folly\"",
19+
"\"${PODS_ROOT}/Headers/Public/React-Codegen/react/renderer/components\"",
20+
]
21+
22+
Pod::Spec.new do |s|
23+
s.name = "React-RCTXR"
24+
s.version = version
25+
s.summary = "XR module for React Native."
26+
s.homepage = "https://reactnative.dev/"
27+
s.documentation_url = "https://reactnative.dev/docs/settings"
28+
s.license = package["license"]
29+
s.author = "Callstack"
30+
s.platforms = min_supported_versions
31+
s.compiler_flags = folly_compiler_flags + ' -Wno-nullability-completeness'
32+
s.source = source
33+
s.source_files = "*.{m,mm,swift}"
34+
s.preserve_paths = "package.json", "LICENSE", "LICENSE-docs"
35+
s.header_dir = "RCTXR"
36+
s.pod_target_xcconfig = {
37+
"USE_HEADERMAP" => "YES",
38+
"CLANG_CXX_LANGUAGE_STANDARD" => "c++20",
39+
"HEADER_SEARCH_PATHS" => header_search_paths.join(' ')
40+
}
41+
42+
s.dependency "RCT-Folly", folly_version
43+
s.dependency "RCTTypeSafety"
44+
s.dependency "React-jsi"
45+
s.dependency "React-Core/RCTXRHeaders"
46+
47+
add_dependency(s, "React-Codegen", :additional_framework_paths => ["build/generated/ios"])
48+
add_dependency(s, "ReactCommon", :subspec => "turbomodule/core", :additional_framework_paths => ["react/nativemodule/core"])
49+
add_dependency(s, "React-NativeModulesApple", :additional_framework_paths => ["build/generated/ios"])
50+
end

0 commit comments

Comments
 (0)