Skip to content

feat: implement XR API #81

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jan 31, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 97 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,110 @@ This is a prop on `<View />` component allowing to add hover effect. It's applie

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

```jsx
```tsx
<TouchableOpacity visionos_hoverEffect="lift">
<Text>Click me</Text>
</TouchableOpacity>
```

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

### `XR` API
Manage Immersive Experiences.

#### Methods
**`requestSession`**
```js
requestSession: (sessionId?: string) => Promise<void>
```
Opens a new [`ImmersiveSpace`](https://developer.apple.com/documentation/swiftui/immersive-spaces) given it's unique `Id`.

**`endSession`**
```js
endSession: () => Promise<void>
```
Closes currently open `ImmersiveSpace`.

#### Constants
**`supportsMultipleScenes`**
```js
supportsMultipleScenes: boolean
```
A Boolean value that indicates whether the app may display multiple scenes simultaneously. Returns the value of `UIApplicationSupportsMultipleScenes` key from `Info.plist`.

### Example Usage

1. Set `UIApplicationSupportsMultipleScenes` to `true` in `Info.plist`:
```diff
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationPreferredDefaultSceneSessionRole</key>
<string>UIWindowSceneSessionRoleApplication</string>
<key>UIApplicationSupportsMultipleScenes</key>
- <false/>
+ <true/>
<key>UISceneConfigurations</key>
<dict/>
</dict>
</dict>
</plist>

```


1. Inside `App.swift` add new `ImmersiveSpace`:
```diff
@main
struct HelloWorldApp: App {
@UIApplicationDelegateAdaptor var delegate: AppDelegate
+ @State private var immersionLevel: ImmersionStyle = .mixed

var body: some Scene {
RCTMainWindow(moduleName: "HelloWorldApp")
+ ImmersiveSpace(id: "TestImmersiveSpace") {
+ // RealityKit content goes here
+ }
+ .immersionStyle(selection: $immersionLevel, in: .mixed, .progressive, .full)
}
}
```
For more information about `ImmersiveSpace` API refer to [Apple documentation](https://developer.apple.com/documentation/swiftui/immersive-spaces).

In the above example we set `ImmersiveSpace` id to `TestImmersiveSpace`.

Now in our JS code, we can call:

```js
import {XR} from "@callstack/react-native-visionos"
//...
const openXRSession = async () => {
try {
if (!XR.supportsMultipleScenes) {
Alert.alert('Error', 'Multiple scenes are not supported');
return;
}
await XR.requestSession('TestImmersiveSpace'); // Pass the same identifier from `App.swift`
} catch (e) {
Alert.alert('Error', e.message);
}
};

const closeXRSession = async () => {
await XR.endSession();
};
```
> [!CAUTION]
> Opening an `ImmersiveSpace` can fail in this secarios:
> - `ImmersiveSpace` is not declared.
> - `UIApplicationSupportsMultipleScenes` is set to `false`.
> - User cancels the request.

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).

## Contributing

1. Follow the same steps as in the `New project creation` section.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ Pod::Spec.new do |s|
s.frameworks = ["UIKit", "SwiftUI"]

s.dependency "React-Core"
s.dependency "React-RCTXR"
end
55 changes: 55 additions & 0 deletions packages/react-native/Libraries/XR/ImmersiveBridge.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import Foundation
import SwiftUI

@objc public enum ImmersiveSpaceResult: Int {
case opened
case userCancelled
case error
}

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

/**
* Utility view used to bridge the gap between SwiftUI environment and UIKit.
*
* Calls `openImmersiveSpace` when view appears in the UIKit hierarchy and `dismissImmersiveSpace` when removed.
*/
struct ImmersiveBridgeView: View {
@Environment(\.openImmersiveSpace) private var openImmersiveSpace
@Environment(\.dismissImmersiveSpace) private var dismissImmersiveSpace

var spaceId: String
var completionHandler: CompletionHandlerType

var body: some View {
EmptyView()
.onAppear {
Task {
let result = await openImmersiveSpace(id: spaceId)

switch result {
case .opened:
completionHandler(.opened)
case .error:
completionHandler(.error)
case .userCancelled:
completionHandler(.userCancelled)
default:
break
}
}
}
.onDisappear {
Task { await dismissImmersiveSpace() }
}
}
}

@objc public class ImmersiveBridgeFactory: NSObject {
@objc public static func makeImmersiveBridgeView(
spaceId: String,
completionHandler: @escaping CompletionHandlerType
) -> UIViewController {
return UIHostingController(rootView: ImmersiveBridgeView(spaceId: spaceId, completionHandler: completionHandler))
}
}
8 changes: 8 additions & 0 deletions packages/react-native/Libraries/XR/NativeXRModule.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* @flow strict
* @format
*/

export * from '../../src/private/specs/visionos_modules/NativeXRModule';
import NativeXRModule from '../../src/private/specs/visionos_modules/NativeXRModule';
export default NativeXRModule;
6 changes: 6 additions & 0 deletions packages/react-native/Libraries/XR/RCTXRModule.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#import <Foundation/Foundation.h>
#import <React/RCTBridgeModule.h>

@interface RCTXRModule : NSObject <RCTBridgeModule>

@end
88 changes: 88 additions & 0 deletions packages/react-native/Libraries/XR/RCTXRModule.mm
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#import <React/RCTXRModule.h>

#import <FBReactNativeSpec_visionOS/FBReactNativeSpec_visionOS.h>

#import <React/RCTBridge.h>
#import <React/RCTConvert.h>
#import <React/RCTUtils.h>
#import "RCTXR-Swift.h"

@interface RCTXRModule () <NativeXRModuleSpec>
@end

@implementation RCTXRModule {
UIViewController *_immersiveBridgeView;
}

RCT_EXPORT_MODULE()

RCT_EXPORT_METHOD(endSession
: (RCTPromiseResolveBlock)resolve reject
: (RCTPromiseRejectBlock)reject)
{
[self removeImmersiveBridge];
resolve(nil);
}


RCT_EXPORT_METHOD(requestSession
: (NSString *)sessionId resolve
: (RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
{
RCTExecuteOnMainQueue(^{
UIWindow *keyWindow = RCTKeyWindow();
UIViewController *rootViewController = keyWindow.rootViewController;

if (self->_immersiveBridgeView == nil) {
self->_immersiveBridgeView = [ImmersiveBridgeFactory makeImmersiveBridgeViewWithSpaceId:sessionId
completionHandler:^(enum ImmersiveSpaceResult result){
if (result == ImmersiveSpaceResultError) {
reject(@"ERROR", @"Immersive Space failed to open, the system cannot fulfill the request.", nil);
[self removeImmersiveBridge];
} else if (result == ImmersiveSpaceResultUserCancelled) {
reject(@"ERROR", @"Immersive Space canceled by user", nil);
[self removeImmersiveBridge];
} else if (result == ImmersiveSpaceResultOpened) {
resolve(nil);
}
}];

[rootViewController.view addSubview:self->_immersiveBridgeView.view];
[rootViewController addChildViewController:self->_immersiveBridgeView];
[self->_immersiveBridgeView didMoveToParentViewController:rootViewController];
} else {
reject(@"ERROR", @"Immersive Space already opened", nil);
}
});
}

- (facebook::react::ModuleConstants<JS::NativeXRModule::Constants::Builder>)constantsToExport {
return [self getConstants];
}

- (facebook::react::ModuleConstants<JS::NativeXRModule::Constants>)getConstants {
__block facebook::react::ModuleConstants<JS::NativeXRModule::Constants> constants;
RCTUnsafeExecuteOnMainQueueSync(^{
constants = facebook::react::typedConstants<JS::NativeXRModule::Constants>({
.supportsMultipleScenes = RCTSharedApplication().supportsMultipleScenes
});
});

return constants;
}

- (void) removeImmersiveBridge
{
RCTExecuteOnMainQueue(^{
[self->_immersiveBridgeView willMoveToParentViewController:nil];
[self->_immersiveBridgeView.view removeFromSuperview];
[self->_immersiveBridgeView removeFromParentViewController];
self->_immersiveBridgeView = nil;
});
}

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params {
return std::make_shared<facebook::react::NativeXRModuleSpecJSI>(params);
}

@end
51 changes: 51 additions & 0 deletions packages/react-native/Libraries/XR/React-RCTXR.podspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
require "json"

package = JSON.parse(File.read(File.join(__dir__, "..", "..", "package.json")))
version = package['version']

source = { :git => 'https://github.com/facebook/react-native.git' }
if version == '1000.0.0'
# This is an unpublished version, use the latest commit hash of the react-native repo, which we’re presumably in.
source[:commit] = `git rev-parse HEAD`.strip if system("git rev-parse --git-dir > /dev/null 2>&1")
else
source[:tag] = "v#{version}"
end

folly_config = get_folly_config()
folly_compiler_flags = folly_config[:compiler_flags]
folly_version = folly_config[:version]

header_search_paths = [
"\"$(PODS_ROOT)/RCT-Folly\"",
"\"${PODS_ROOT}/Headers/Public/React-Codegen/react/renderer/components\"",
]

Pod::Spec.new do |s|
s.name = "React-RCTXR"
s.version = version
s.summary = "XR module for React Native."
s.homepage = "https://reactnative.dev/"
s.documentation_url = "https://reactnative.dev/docs/settings"
s.license = package["license"]
s.author = "Callstack"
s.platforms = min_supported_versions
s.compiler_flags = folly_compiler_flags + ' -Wno-nullability-completeness'
s.source = source
s.source_files = "*.{m,mm,swift}"
s.preserve_paths = "package.json", "LICENSE", "LICENSE-docs"
s.header_dir = "RCTXR"
s.pod_target_xcconfig = {
"USE_HEADERMAP" => "YES",
"CLANG_CXX_LANGUAGE_STANDARD" => "c++20",
"HEADER_SEARCH_PATHS" => header_search_paths.join(' ')
}

s.dependency "RCT-Folly", folly_version
s.dependency "RCTTypeSafety"
s.dependency "React-jsi"
s.dependency "React-Core/RCTXRHeaders"

add_dependency(s, "React-Codegen", :additional_framework_paths => ["build/generated/ios"])
add_dependency(s, "ReactCommon", :subspec => "turbomodule/core", :additional_framework_paths => ["react/nativemodule/core"])
add_dependency(s, "React-NativeModulesApple", :additional_framework_paths => ["build/generated/ios"])
end
9 changes: 9 additions & 0 deletions packages/react-native/Libraries/XR/XR.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@

export interface XRStatic {
requestSession(sessionId: string): Promise<void>;
endSession(): Promise<void>;
supportsMultipleScenes: boolean;
}

export const XR: XRStatic;
export type XR = XRStatic;
33 changes: 33 additions & 0 deletions packages/react-native/Libraries/XR/XR.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* @format
* @flow strict
* @jsdoc
*/

import NativeXRModule from './NativeXRModule';

const XR = {
requestSession: (sessionId?: string): Promise<void> => {
if (NativeXRModule != null && NativeXRModule.requestSession != null) {
return NativeXRModule.requestSession(sessionId);
}
return Promise.reject(new Error('NativeXRModule is not available'));
},
endSession: (): Promise<void> => {
if (NativeXRModule != null && NativeXRModule.endSession != null) {
return NativeXRModule.endSession();
}
return Promise.reject(new Error('NativeXRModule is not available'));
},
// $FlowIgnore[unsafe-getters-setters]
get supportsMultipleScenes(): boolean {
if (NativeXRModule == null) {
return false;
}

const nativeConstants = NativeXRModule.getConstants();
return nativeConstants.supportsMultipleScenes || false;
},
};

module.exports = XR;
Loading