Skip to content

Commit

Permalink
feat: implement XR API (#81)
Browse files Browse the repository at this point in the history
* feat: implement Spatial API

* feat: make RCTSpatial decoupled from RCTMainWindow()

* feat: implement XR module
  • Loading branch information
okwasniewski authored Jan 31, 2024
1 parent fd0c8ca commit d2eb21b
Show file tree
Hide file tree
Showing 23 changed files with 572 additions and 52 deletions.
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

Check failure on line 18 in packages/react-native/Libraries/XR/ImmersiveBridge.swift

View workflow job for this annotation

GitHub Actions / test_ios_rntester-Hermes

value of type 'EnvironmentValues' has no member 'openImmersiveSpace'

Check failure on line 18 in packages/react-native/Libraries/XR/ImmersiveBridge.swift

View workflow job for this annotation

GitHub Actions / test_ios_rntester-Hermes

value of type 'EnvironmentValues' has no member 'openImmersiveSpace'

Check failure on line 18 in packages/react-native/Libraries/XR/ImmersiveBridge.swift

View workflow job for this annotation

GitHub Actions / test_ios_rntester-Hermes

value of type 'EnvironmentValues' has no member 'openImmersiveSpace'

Check failure on line 18 in packages/react-native/Libraries/XR/ImmersiveBridge.swift

View workflow job for this annotation

GitHub Actions / test_ios_rntester-Hermes

value of type 'EnvironmentValues' has no member 'openImmersiveSpace'
@Environment(\.dismissImmersiveSpace) private var dismissImmersiveSpace

Check failure on line 19 in packages/react-native/Libraries/XR/ImmersiveBridge.swift

View workflow job for this annotation

GitHub Actions / test_ios_rntester-Hermes

value of type 'EnvironmentValues' has no member 'dismissImmersiveSpace'

Check failure on line 19 in packages/react-native/Libraries/XR/ImmersiveBridge.swift

View workflow job for this annotation

GitHub Actions / test_ios_rntester-Hermes

value of type 'EnvironmentValues' has no member 'dismissImmersiveSpace'

Check failure on line 19 in packages/react-native/Libraries/XR/ImmersiveBridge.swift

View workflow job for this annotation

GitHub Actions / test_ios_rntester-Hermes

value of type 'EnvironmentValues' has no member 'dismissImmersiveSpace'

Check failure on line 19 in packages/react-native/Libraries/XR/ImmersiveBridge.swift

View workflow job for this annotation

GitHub Actions / test_ios_rntester-Hermes

value of type 'EnvironmentValues' has no member '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

0 comments on commit d2eb21b

Please sign in to comment.