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

docs: add image to README, annotate nightly APIs

fix: export XR library from typescript
  • Loading branch information
okwasniewski committed Sep 16, 2024
1 parent c468e42 commit 3998f25
Show file tree
Hide file tree
Showing 23 changed files with 384 additions and 166 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,6 @@ vendor/

# CircleCI
.circleci/generated_config.yml
.circleci/storage

.yarn
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
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;
Original file line number Diff line number Diff line change
Expand Up @@ -9771,6 +9771,22 @@ exports[`public API should not change unintentionally Libraries/WebSocket/WebSoc

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

exports[`public API should not change unintentionally Libraries/XR/NativeXRModule.js 1`] = `
"export * from \\"../../src/private/specs/visionos_modules/NativeXRModule\\";
declare export default typeof NativeXRModule;
"
`;

exports[`public API should not change unintentionally Libraries/XR/XR.js 1`] = `
"declare const XR: {
requestSession: (sessionId?: string) => Promise<void>,
endSession: () => Promise<void>,
get supportsMultipleScenes(): boolean,
};
declare module.exports: XR;
"
`;

exports[`public API should not change unintentionally Libraries/YellowBox/YellowBoxDeprecated.js 1`] = `
"declare const React: $FlowFixMe;
type Props = $ReadOnly<{||}>;
Expand Down Expand Up @@ -9889,6 +9905,7 @@ declare module.exports: {
get PushNotificationIOS(): PushNotificationIOS,
get Settings(): Settings,
get Share(): Share,
get XR(): XR,
get StyleSheet(): StyleSheet,
get Systrace(): Systrace,
get ToastAndroid(): ToastAndroid,
Expand Down
1 change: 1 addition & 0 deletions packages/react-native/React-Core.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ header_subspecs = {
'RCTSettingsHeaders' => 'Libraries/Settings/*.h',
'RCTTextHeaders' => 'Libraries/Text/**/*.h',
'RCTVibrationHeaders' => 'Libraries/Vibration/*.h',
'RCTXRHeaders' => 'Libraries/XR/*.h',
}

frameworks_search_paths = []
Expand Down
1 change: 1 addition & 0 deletions packages/react-native/React.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,5 @@ Pod::Spec.new do |s|
s.dependency "React-RCTSettings", version
s.dependency "React-RCTText", version
s.dependency "React-RCTVibration", version
s.dependency "React-RCTXR", version
end
7 changes: 7 additions & 0 deletions packages/react-native/React/Base/RCTUtils.m
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,13 @@ BOOL RCTRunningInAppExtension(void)
continue;
}

#if TARGET_OS_VISION
/// Presenting scenes over Immersive Spaces leads to crash: "Presentations are not permitted within volumetric window scenes."
if (scene.session.role == UISceneSessionRoleImmersiveSpaceApplication) {
continue;
}
#endif

if (scene.activationState == UISceneActivationStateForegroundActive) {
foregroundActiveScene = scene;
break;
Expand Down
4 changes: 4 additions & 0 deletions packages/react-native/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import typeof Platform from './Libraries/Utilities/Platform';
import typeof useColorScheme from './Libraries/Utilities/useColorScheme';
import typeof useWindowDimensions from './Libraries/Utilities/useWindowDimensions';
import typeof Vibration from './Libraries/Vibration/Vibration';
import typeof XR from './Libraries/XR/XR';
import typeof YellowBox from './Libraries/YellowBox/YellowBoxDeprecated';

const warnOnce = require('./Libraries/Utilities/warnOnce');
Expand Down Expand Up @@ -302,6 +303,9 @@ module.exports = {
get Share(): Share {
return require('./Libraries/Share/Share');
},
get XR(): XR {
return require('./Libraries/XR/XR');
},
get StyleSheet(): StyleSheet {
return require('./Libraries/StyleSheet/StyleSheet');
},
Expand Down
7 changes: 7 additions & 0 deletions packages/react-native/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,13 @@
"android": {},
"jsSrcsDir": "src"
},
{
"name": "FBReactNativeSpec_visionOS",
"type": "modules",
"ios": {},
"android": {},
"jsSrcsDir": "src/private/specs/visionos_modules"
},
{
"name": "rncore",
"type": "components",
Expand Down
1 change: 1 addition & 0 deletions packages/react-native/scripts/cocoapods/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,7 @@ def self.react_native_pods
"glog",
"hermes-engine",
"React-hermes",
"React-RCTXR", # visionOS
]
end

Expand Down
1 change: 1 addition & 0 deletions packages/react-native/scripts/react_native_pods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ def use_react_native! (
pod 'React-nativeconfig', :path => "#{prefix}/ReactCommon"
pod 'RCTDeprecation', :path => "#{prefix}/ReactApple/Libraries/RCTFoundation/RCTDeprecation"
pod 'React-RCTSwiftExtensions', :path => "#{prefix}/Libraries/SwiftExtensions"
pod 'React-RCTXR', :path => "#{prefix}/Libraries/XR"

if hermes_enabled
setup_hermes!(:react_native_path => prefix)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* @flow strict
* @format
*/

import type {TurboModule} from '../../../../Libraries/TurboModule/RCTExport';

import * as TurboModuleRegistry from '../../../../Libraries/TurboModule/TurboModuleRegistry';

export type XRModuleConstants = {|
+supportsMultipleScenes?: boolean,
|};

export interface Spec extends TurboModule {
+getConstants: () => XRModuleConstants;

+requestSession: (sessionId?: string) => Promise<void>;
+endSession: () => Promise<void>;
}

export default (TurboModuleRegistry.get<Spec>('XRModule'): ?Spec);
1 change: 1 addition & 0 deletions packages/react-native/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ export * from '../Libraries/Utilities/Dimensions';
export * from '../Libraries/Utilities/PixelRatio';
export * from '../Libraries/Utilities/Platform';
export * from '../Libraries/Vibration/Vibration';
export * from '../Libraries/XR/XR';
export * from '../Libraries/YellowBox/YellowBoxDeprecated';
export * from '../Libraries/vendor/core/ErrorUtils';
export {
Expand Down
Loading

0 comments on commit 3998f25

Please sign in to comment.