diff --git a/RNRive.podspec b/RNRive.podspec index 99664968..3b896068 100644 --- a/RNRive.podspec +++ b/RNRive.podspec @@ -41,13 +41,20 @@ Pod::Spec.new do |s| s.platforms = { :ios => min_ios_version_supported } s.source = { :git => "https://github.com/rive-app/rive-nitro-react-native.git", :tag => "#{s.version}" } - s.source_files = "ios/**/*.{h,m,mm,swift}" + s.source_files = "ios/**/*.{h,m,mm,swift}", "cpp/**/*.{hpp,cpp}" s.public_header_files = ['ios/RCTSwiftLog.h'] + s.private_header_files = ['cpp/**/*.hpp'] + + # Set pod_target_xcconfig BEFORE add_nitrogen_files so it gets merged with Nitro's settings + s.pod_target_xcconfig = { + 'HEADER_SEARCH_PATHS' => '"$(PODS_TARGET_SRCROOT)/cpp"' + } + load 'nitrogen/generated/ios/RNRive+autolinking.rb' add_nitrogen_files(s) s.dependency "RiveRuntime", rive_ios_version - install_modules_dependencies(s) + install_modules_dependencies(s) end diff --git a/android/CMakeLists.txt b/android/CMakeLists.txt index 038c7a6e..8bbf93e1 100644 --- a/android/CMakeLists.txt +++ b/android/CMakeLists.txt @@ -6,7 +6,10 @@ set(CMAKE_VERBOSE_MAKEFILE ON) set(CMAKE_CXX_STANDARD 20) # Define C++ library and add all sources -add_library(${PACKAGE_NAME} SHARED src/main/cpp/cpp-adapter.cpp) +add_library(${PACKAGE_NAME} SHARED + src/main/cpp/cpp-adapter.cpp + src/main/cpp/JRiveWorkletDispatcher.cpp +) # Add Nitrogen specs :) include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/rive+autolinking.cmake) diff --git a/android/src/main/cpp/JRiveWorkletDispatcher.cpp b/android/src/main/cpp/JRiveWorkletDispatcher.cpp new file mode 100644 index 00000000..f0745bfa --- /dev/null +++ b/android/src/main/cpp/JRiveWorkletDispatcher.cpp @@ -0,0 +1,81 @@ +#include "JRiveWorkletDispatcher.hpp" +#include + +namespace margelo::nitro::rive { + +using namespace facebook; + +JRiveWorkletDispatcher::JRiveWorkletDispatcher( + jni::alias_ref jThis) + : _javaPart(jni::make_global(jThis)) {} + +jni::local_ref JRiveWorkletDispatcher::initHybrid( + jni::alias_ref jThis) { + return makeCxxInstance(jThis); +} + +jni::local_ref JRiveWorkletDispatcher::create() { + return newObjectJavaArgs(); +} + +void JRiveWorkletDispatcher::trigger() { + std::unique_lock lock(_mutex); + while (!_jobs.empty()) { + auto job = std::move(_jobs.front()); + _jobs.pop(); + lock.unlock(); + job(); + lock.lock(); + } +} + +void JRiveWorkletDispatcher::scheduleTrigger() { + static const auto method = _javaPart->getClass()->getMethod("scheduleTrigger"); + method(_javaPart.get()); +} + +void JRiveWorkletDispatcher::runAsync(std::function&& function) { + std::unique_lock lock(_mutex); + _jobs.push(std::move(function)); + lock.unlock(); + scheduleTrigger(); +} + +void JRiveWorkletDispatcher::runSync(std::function&& function) { + std::mutex mtx; + std::condition_variable cv; + bool done = false; + + runAsync([&]() { + function(); + { + std::lock_guard lock(mtx); + done = true; + } + cv.notify_one(); + }); + + std::unique_lock lock(mtx); + cv.wait(lock, [&]{ return done; }); +} + +void JRiveWorkletDispatcher::registerNatives() { + registerHybrid({ + makeNativeMethod("initHybrid", JRiveWorkletDispatcher::initHybrid), + makeNativeMethod("trigger", JRiveWorkletDispatcher::trigger), + }); +} + +AndroidMainThreadDispatcher::AndroidMainThreadDispatcher( + jni::local_ref javaDispatcher) + : _javaDispatcher(jni::make_global(javaDispatcher)) {} + +void AndroidMainThreadDispatcher::runAsync(std::function&& function) { + _javaDispatcher->cthis()->runAsync(std::move(function)); +} + +void AndroidMainThreadDispatcher::runSync(std::function&& function) { + _javaDispatcher->cthis()->runSync(std::move(function)); +} + +} // namespace margelo::nitro::rive diff --git a/android/src/main/cpp/JRiveWorkletDispatcher.hpp b/android/src/main/cpp/JRiveWorkletDispatcher.hpp new file mode 100644 index 00000000..280d1dd4 --- /dev/null +++ b/android/src/main/cpp/JRiveWorkletDispatcher.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace margelo::nitro::rive { + +using namespace facebook; + +class JRiveWorkletDispatcher : public jni::HybridClass { +public: + static auto constexpr kJavaDescriptor = "Lcom/margelo/nitro/rive/RiveWorkletDispatcher;"; + + static jni::local_ref initHybrid(jni::alias_ref jThis); + static void registerNatives(); + + static jni::local_ref create(); + + void runAsync(std::function&& function); + void runSync(std::function&& function); + +private: + friend HybridBase; + + void trigger(); + void scheduleTrigger(); + + jni::global_ref _javaPart; + std::queue> _jobs; + std::recursive_mutex _mutex; + + explicit JRiveWorkletDispatcher(jni::alias_ref jThis); +}; + +class AndroidMainThreadDispatcher : public Dispatcher { +public: + explicit AndroidMainThreadDispatcher(jni::local_ref javaDispatcher); + + void runAsync(std::function&& function) override; + void runSync(std::function&& function) override; + +private: + jni::global_ref _javaDispatcher; +}; + +} // namespace margelo::nitro::rive diff --git a/android/src/main/cpp/cpp-adapter.cpp b/android/src/main/cpp/cpp-adapter.cpp index 5116d53c..4c5af328 100644 --- a/android/src/main/cpp/cpp-adapter.cpp +++ b/android/src/main/cpp/cpp-adapter.cpp @@ -1,6 +1,9 @@ #include #include "riveOnLoad.hpp" +#include "JRiveWorkletDispatcher.hpp" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void*) { - return margelo::nitro::rive::initialize(vm); + auto result = margelo::nitro::rive::initialize(vm); + margelo::nitro::rive::JRiveWorkletDispatcher::registerNatives(); + return result; } diff --git a/android/src/main/java/com/margelo/nitro/rive/RiveWorkletDispatcher.kt b/android/src/main/java/com/margelo/nitro/rive/RiveWorkletDispatcher.kt new file mode 100644 index 00000000..d6f08688 --- /dev/null +++ b/android/src/main/java/com/margelo/nitro/rive/RiveWorkletDispatcher.kt @@ -0,0 +1,49 @@ +package com.margelo.nitro.rive + +import android.os.Handler +import android.os.Looper +import androidx.annotation.Keep +import com.facebook.jni.HybridData +import com.facebook.proguard.annotations.DoNotStrip +import java.util.concurrent.atomic.AtomicBoolean + +@Suppress("JavaJniMissingFunction") +@Keep +@DoNotStrip +class RiveWorkletDispatcher { + @DoNotStrip + @Suppress("unused") + private val mHybridData: HybridData = initHybrid() + + private val mainHandler = Handler(Looper.getMainLooper()) + private val active = AtomicBoolean(true) + + private val triggerRunnable = Runnable { + synchronized(active) { + if (active.get()) { + trigger() + } + } + } + + private external fun initHybrid(): HybridData + private external fun trigger() + + @DoNotStrip + @Suppress("unused") + private fun scheduleTrigger() { + mainHandler.post(triggerRunnable) + } + + fun deactivate() { + synchronized(active) { + active.set(false) + } + } + + companion object { + init { + System.loadLibrary("rive") + } + } +} diff --git a/cpp/HybridRiveWorkletBridge.hpp b/cpp/HybridRiveWorkletBridge.hpp new file mode 100644 index 00000000..f58d8cc2 --- /dev/null +++ b/cpp/HybridRiveWorkletBridge.hpp @@ -0,0 +1,77 @@ +#pragma once + +#include "HybridRiveWorkletBridgeSpec.hpp" +#include + +#if __APPLE__ +#include +#include +#elif __ANDROID__ +#include "JRiveWorkletDispatcher.hpp" +#endif + +namespace margelo::nitro::rive { + +#if __APPLE__ + +/** + * iOS: A dispatcher that runs work on the main thread using GCD. + */ +class MainThreadDispatcher : public Dispatcher { +public: + void runAsync(std::function&& function) override { + __block auto func = std::move(function); + dispatch_async(dispatch_get_main_queue(), ^{ + func(); + }); + } + + void runSync(std::function&& function) override { + if (pthread_main_np() != 0) { + function(); + } else { + __block auto func = std::move(function); + dispatch_sync(dispatch_get_main_queue(), ^{ + func(); + }); + } + } +}; + +#endif + +class HybridRiveWorkletBridge : public HybridRiveWorkletBridgeSpec { +public: + HybridRiveWorkletBridge() : HybridObject(TAG) {} + + void install() override { + throw std::runtime_error("install() requires runtime access - use raw method"); + } + +protected: + void loadHybridMethods() override { + HybridObject::loadHybridMethods(); + registerHybrids(this, [](Prototype& prototype) { + prototype.registerRawHybridMethod("install", 0, &HybridRiveWorkletBridge::installRaw); + }); + } + +private: + jsi::Value installRaw(jsi::Runtime& runtime, + const jsi::Value& thisValue, + const jsi::Value* args, + size_t count) { +#if __APPLE__ + auto dispatcher = std::make_shared(); + Dispatcher::installRuntimeGlobalDispatcher(runtime, dispatcher); +#elif __ANDROID__ + // Create the Java dispatcher instance and wrap it in the C++ dispatcher + auto javaDispatcher = JRiveWorkletDispatcher::create(); + auto dispatcher = std::make_shared(javaDispatcher); + Dispatcher::installRuntimeGlobalDispatcher(runtime, dispatcher); +#endif + return jsi::Value::undefined(); + } +}; + +} // namespace margelo::nitro::rive diff --git a/example/assets/rive/bouncing_ball.riv b/example/assets/rive/bouncing_ball.riv new file mode 100644 index 00000000..d83155fa Binary files /dev/null and b/example/assets/rive/bouncing_ball.riv differ diff --git a/example/src/App.tsx b/example/src/App.tsx index 8291510c..e820f737 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -7,8 +7,13 @@ import { } from 'react-native'; import { NavigationContainer } from '@react-navigation/native'; import { createStackNavigator } from '@react-navigation/stack'; +import { runOnUI } from 'react-native-reanimated'; +import { installWorkletDispatcher } from '@rive-app/react-native'; import { PagesList } from './PagesList'; +// Install dispatcher on Reanimated's UI runtime for worklet-based listeners +installWorkletDispatcher(runOnUI); + type RootStackParamList = { Home: undefined; } & { diff --git a/example/src/pages/RiveToReactNativeExample.tsx b/example/src/pages/RiveToReactNativeExample.tsx new file mode 100644 index 00000000..cf7cfaf1 --- /dev/null +++ b/example/src/pages/RiveToReactNativeExample.tsx @@ -0,0 +1,330 @@ +import { + View, + Text, + StyleSheet, + ActivityIndicator, + Pressable, + Switch, +} from 'react-native'; +import { useEffect, useMemo, useState } from 'react'; +import Animated, { + runOnUI, + useSharedValue, + useAnimatedStyle, + type SharedValue, +} from 'react-native-reanimated'; +import { NitroModules } from 'react-native-nitro-modules'; +import { + Fit, + RiveView, + useRiveFile, + type RiveFile, + type ViewModelInstance, + type ViewModelNumberProperty, +} from '@rive-app/react-native'; +import { type Metadata } from '../helpers/metadata'; + +declare global { + var __callMicrotasks: () => void; +} + +/** + * Syncs a Rive ViewModel number property to a Reanimated SharedValue. + * @param useUIThread - If true, runs listener on UI thread (won't freeze when JS blocked). + * If false, runs on JS thread (will freeze when JS blocked). + */ +function useRiveNumberListener( + property: ViewModelNumberProperty | undefined, + sharedValue: SharedValue, + useUIThread: boolean +) { + useEffect(() => { + if (!property) return; + + if (useUIThread) { + // UI thread version - won't freeze when JS thread is blocked + const boxedProperty = NitroModules.box(property); + const sv = sharedValue; + + runOnUI(() => { + 'worklet'; + const prop = boxedProperty.unbox(); + prop.addListener((value: number) => { + 'worklet'; + sv.value = value; + global.__callMicrotasks(); + }); + })(); + + return () => { + property.removeListeners(); + }; + } else { + // JS thread version - will freeze when JS thread is blocked + const removeListener = property.addListener((value: number) => { + sharedValue.value = value; + }); + + return removeListener; + } + }, [property, sharedValue, useUIThread]); +} + +export default function RiveToReactNativeExample() { + const { riveFile, isLoading, error } = useRiveFile( + require('../../assets/rive/bouncing_ball.riv') + ); + + return ( + + {isLoading ? ( + + ) : riveFile ? ( + + ) : ( + {error || 'Unexpected error'} + )} + + ); +} + +function WithViewModelSetup({ file }: { file: RiveFile }) { + const viewModel = useMemo(() => file.defaultArtboardViewModel(), [file]); + const instance = useMemo( + () => viewModel?.createDefaultInstance(), + [viewModel] + ); + const [useUIThread, setUseUIThread] = useState(true); + + if (!instance || !viewModel) { + return ( + + + {!viewModel + ? 'No view model found.' + : 'Failed to create view model instance'} + + + This demo requires a Rive file (bouncing_ball.riv) with:{'\n'} + {'\n'}• A ViewModel with a "ypos" number property{'\n'}• A bouncing + ball animation{'\n'}• Target-to-source binding from ball Y position to + ypos{'\n'} + {'\n'} + See Rive docs for data binding setup. + + + ); + } + + return ( + + ); +} + +function BouncingBallTracker({ + instance, + file, + useUIThread, + onToggle, +}: { + instance: ViewModelInstance; + file: RiveFile; + useUIThread: boolean; + onToggle: (value: boolean) => void; +}) { + const pointerY = useSharedValue(0); + + const yposProperty = useMemo( + () => instance.numberProperty('ypos'), + [instance] + ); + + useRiveNumberListener(yposProperty, pointerY, useUIThread); + + const pointerStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: pointerY.value }], + })); + + if (!yposProperty) { + return ( + + Property "ypos" not found + + Make sure the Rive file has a "ypos" number property in its ViewModel + with target-to-source binding from the ball's Y position. + + + ); + } + + return ( + + + Rive drives the ball position via data binding.{'\n'}React Native tracks + it with the blue pointer using addListener. + + + + JS Thread + + UI Thread + + + + + + + RN + + + + + + ); +} + +function BlockJSThreadButton() { + const [isBlocking, setIsBlocking] = useState(false); + + const handlePress = () => { + setIsBlocking(true); + + // Use setTimeout to let the state update render before blocking + setTimeout(() => { + const start = Date.now(); + while (Date.now() - start < 2000) { + // Busy poll - blocks JS thread for 2 seconds + } + setIsBlocking(false); + }, 50); + }; + + return ( + + + {isBlocking ? 'JS Thread Blocked...' : 'Block JS Thread (2s)'} + + + ); +} + +RiveToReactNativeExample.metadata = { + name: 'Rive → React Native', + description: + 'Demonstrates Rive animation driving React Native UI through data binding listeners', +} satisfies Metadata; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#fff', + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + subtitle: { + fontSize: 14, + color: '#666', + textAlign: 'center', + marginVertical: 10, + paddingHorizontal: 20, + }, + switchContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 10, + marginBottom: 10, + }, + switchLabel: { + fontSize: 14, + color: '#333', + }, + contentContainer: { + position: 'relative', + height: 600, + width: 200, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: '#ccc', + }, + rive: { + width: 100, + height: 600, + }, + pointer: { + position: 'absolute', + top: -10, + right: 40, + flexDirection: 'row', + alignItems: 'center', + }, + pointerArrow: { + width: 0, + height: 0, + borderTopWidth: 10, + borderBottomWidth: 10, + borderRightWidth: 15, + borderTopColor: 'transparent', + borderBottomColor: 'transparent', + borderRightColor: '#007AFF', + }, + pointerText: { + backgroundColor: '#007AFF', + color: '#fff', + fontSize: 12, + fontWeight: 'bold', + paddingHorizontal: 6, + paddingVertical: 4, + borderTopRightRadius: 4, + borderBottomRightRadius: 4, + }, + errorText: { + color: 'red', + textAlign: 'center', + fontSize: 16, + fontWeight: 'bold', + marginBottom: 10, + }, + instructionText: { + color: '#666', + textAlign: 'left', + fontSize: 14, + lineHeight: 22, + }, + blockButton: { + backgroundColor: '#4CAF50', + paddingHorizontal: 20, + paddingVertical: 12, + borderRadius: 8, + marginTop: 20, + alignSelf: 'center', + }, + blockButtonActive: { + backgroundColor: '#f44336', + }, + blockButtonText: { + color: '#fff', + fontSize: 16, + fontWeight: 'bold', + }, +}); diff --git a/expo-example/app/_layout.tsx b/expo-example/app/_layout.tsx index d10c2b6e..752b4568 100644 --- a/expo-example/app/_layout.tsx +++ b/expo-example/app/_layout.tsx @@ -5,9 +5,14 @@ import { } from '@react-navigation/native'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; +import { runOnUI } from 'react-native-reanimated'; +import { installWorkletDispatcher } from '@rive-app/react-native'; import { useColorScheme } from '@/hooks/use-color-scheme'; +// Install dispatcher on Reanimated's UI runtime for worklet-based listeners +installWorkletDispatcher(runOnUI); + export default function RootLayout() { const colorScheme = useColorScheme(); diff --git a/expo-example/assets/rive/bouncing_ball.riv b/expo-example/assets/rive/bouncing_ball.riv new file mode 100644 index 00000000..d83155fa Binary files /dev/null and b/expo-example/assets/rive/bouncing_ball.riv differ diff --git a/expo-example/metro.config.js b/expo-example/metro.config.js index 874dce7a..ac65f857 100644 --- a/expo-example/metro.config.js +++ b/expo-example/metro.config.js @@ -13,6 +13,26 @@ const finalConfig = getConfig(config, { project: __dirname, }); +// Block resolution from example/node_modules to avoid version conflicts +// (expo-example uses different reanimated/worklets versions than example app) +const escapeRegex = (str) => str.replace(/[/\\]/g, '[/\\\\]'); +const exampleNodeModules = path.join(root, 'example', 'node_modules'); +const blockPatterns = [ + new RegExp( + escapeRegex(path.join(exampleNodeModules, 'react-native-reanimated')) + '.*' + ), + new RegExp( + escapeRegex(path.join(exampleNodeModules, 'react-native-worklets')) + '.*' + ), +]; +const existingBlockList = finalConfig.resolver.blockList; +if (existingBlockList) { + blockPatterns.push(existingBlockList); +} +finalConfig.resolver.blockList = new RegExp( + blockPatterns.map((r) => r.source).join('|') +); + /** * Resolves @example/* path aliases to the example/src/* directory. * Metro doesn't natively understand TypeScript path mappings, so this diff --git a/expo-example/package.json b/expo-example/package.json index 8595c4f0..4ddda499 100644 --- a/expo-example/package.json +++ b/expo-example/package.json @@ -35,11 +35,11 @@ "react-native": "0.81.5", "react-native-gesture-handler": "2.29.1", "react-native-nitro-modules": "0.31.10", - "react-native-reanimated": "4.1.5", + "react-native-reanimated": "4.2.0", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", "react-native-web": "~0.21.0", - "react-native-worklets": "0.6.1" + "react-native-worklets": "0.7.1" }, "devDependencies": { "@types/react": "~19.1.0", diff --git a/nitro.json b/nitro.json index 2213016c..c0bf4f1d 100644 --- a/nitro.json +++ b/nitro.json @@ -28,6 +28,9 @@ "RiveImageFactory": { "swift": "HybridRiveImageFactory", "kotlin": "HybridRiveImageFactory" + }, + "RiveWorkletBridge": { + "cpp": "HybridRiveWorkletBridge" } }, "ignorePaths": ["node_modules"] diff --git a/nitrogen/generated/android/rive+autolinking.cmake b/nitrogen/generated/android/rive+autolinking.cmake index d3d7c065..147b978a 100644 --- a/nitrogen/generated/android/rive+autolinking.cmake +++ b/nitrogen/generated/android/rive+autolinking.cmake @@ -41,6 +41,7 @@ target_sources( ../nitrogen/generated/shared/c++/HybridRiveImageFactorySpec.cpp ../nitrogen/generated/shared/c++/HybridRiveViewSpec.cpp ../nitrogen/generated/shared/c++/views/HybridRiveViewComponent.cpp + ../nitrogen/generated/shared/c++/HybridRiveWorkletBridgeSpec.cpp ../nitrogen/generated/shared/c++/HybridViewModelSpec.cpp ../nitrogen/generated/shared/c++/HybridViewModelInstanceSpec.cpp ../nitrogen/generated/shared/c++/HybridViewModelPropertySpec.cpp diff --git a/nitrogen/generated/android/riveOnLoad.cpp b/nitrogen/generated/android/riveOnLoad.cpp index 9c74c45d..abcc07ef 100644 --- a/nitrogen/generated/android/riveOnLoad.cpp +++ b/nitrogen/generated/android/riveOnLoad.cpp @@ -42,6 +42,7 @@ #include "JHybridViewModelListPropertySpec.hpp" #include "JHybridViewModelArtboardPropertySpec.hpp" #include +#include "HybridRiveWorkletBridge.hpp" namespace margelo::nitro::rive { @@ -120,6 +121,15 @@ int initialize(JavaVM* vm) { return instance->cthis()->shared(); } ); + HybridObjectRegistry::registerHybridObjectConstructor( + "RiveWorkletBridge", + []() -> std::shared_ptr { + static_assert(std::is_default_constructible_v, + "The HybridObject \"HybridRiveWorkletBridge\" is not default-constructible! " + "Create a public constructor that takes zero arguments to be able to autolink this HybridObject."); + return std::make_shared(); + } + ); }); } diff --git a/nitrogen/generated/ios/RNRiveAutolinking.mm b/nitrogen/generated/ios/RNRiveAutolinking.mm index e904f1c3..fb155897 100644 --- a/nitrogen/generated/ios/RNRiveAutolinking.mm +++ b/nitrogen/generated/ios/RNRiveAutolinking.mm @@ -15,6 +15,7 @@ #include "HybridRiveFileSpecSwift.hpp" #include "HybridRiveViewSpecSwift.hpp" #include "HybridRiveImageFactorySpecSwift.hpp" +#include "HybridRiveWorkletBridge.hpp" @interface RNRiveAutolinking : NSObject @end @@ -60,6 +61,15 @@ + (void) load { return hybridObject; } ); + HybridObjectRegistry::registerHybridObjectConstructor( + "RiveWorkletBridge", + []() -> std::shared_ptr { + static_assert(std::is_default_constructible_v, + "The HybridObject \"HybridRiveWorkletBridge\" is not default-constructible! " + "Create a public constructor that takes zero arguments to be able to autolink this HybridObject."); + return std::make_shared(); + } + ); } @end diff --git a/nitrogen/generated/shared/c++/HybridRiveWorkletBridgeSpec.cpp b/nitrogen/generated/shared/c++/HybridRiveWorkletBridgeSpec.cpp new file mode 100644 index 00000000..49a955c1 --- /dev/null +++ b/nitrogen/generated/shared/c++/HybridRiveWorkletBridgeSpec.cpp @@ -0,0 +1,21 @@ +/// +/// HybridRiveWorkletBridgeSpec.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2025 Marc Rousavy @ Margelo +/// + +#include "HybridRiveWorkletBridgeSpec.hpp" + +namespace margelo::nitro::rive { + + void HybridRiveWorkletBridgeSpec::loadHybridMethods() { + // load base methods/properties + HybridObject::loadHybridMethods(); + // load custom methods/properties + registerHybrids(this, [](Prototype& prototype) { + prototype.registerHybridMethod("install", &HybridRiveWorkletBridgeSpec::install); + }); + } + +} // namespace margelo::nitro::rive diff --git a/nitrogen/generated/shared/c++/HybridRiveWorkletBridgeSpec.hpp b/nitrogen/generated/shared/c++/HybridRiveWorkletBridgeSpec.hpp new file mode 100644 index 00000000..df955d2a --- /dev/null +++ b/nitrogen/generated/shared/c++/HybridRiveWorkletBridgeSpec.hpp @@ -0,0 +1,62 @@ +/// +/// HybridRiveWorkletBridgeSpec.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2025 Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + + + + + +namespace margelo::nitro::rive { + + using namespace margelo::nitro; + + /** + * An abstract base class for `RiveWorkletBridge` + * Inherit this class to create instances of `HybridRiveWorkletBridgeSpec` in C++. + * You must explicitly call `HybridObject`'s constructor yourself, because it is virtual. + * @example + * ```cpp + * class HybridRiveWorkletBridge: public HybridRiveWorkletBridgeSpec { + * public: + * HybridRiveWorkletBridge(...): HybridObject(TAG) { ... } + * // ... + * }; + * ``` + */ + class HybridRiveWorkletBridgeSpec: public virtual HybridObject { + public: + // Constructor + explicit HybridRiveWorkletBridgeSpec(): HybridObject(TAG) { } + + // Destructor + ~HybridRiveWorkletBridgeSpec() override = default; + + public: + // Properties + + + public: + // Methods + virtual void install() = 0; + + protected: + // Hybrid Setup + void loadHybridMethods() override; + + protected: + // Tag for logging + static constexpr auto TAG = "RiveWorkletBridge"; + }; + +} // namespace margelo::nitro::rive diff --git a/src/core/WorkletBridge.ts b/src/core/WorkletBridge.ts new file mode 100644 index 00000000..9bf82706 --- /dev/null +++ b/src/core/WorkletBridge.ts @@ -0,0 +1,47 @@ +import { NitroModules } from 'react-native-nitro-modules'; +import type { RiveWorkletBridge } from '../specs/RiveWorkletBridge.nitro'; + +let isInstalled = false; + +/** + * Install the Nitro Dispatcher on Reanimated's UI runtime. + * This enables using HybridObject callbacks (like addListener) from worklets + * and having shared value updates trigger useAnimatedStyle. + * + * Call this once at app startup. It will schedule the installation on the UI thread. + * + * @param runOnUI - The runOnUI function from react-native-reanimated + * + * @example + * ```tsx + * import { installWorkletDispatcher } from '@rive-app/react-native'; + * import { runOnUI } from 'react-native-reanimated'; + * + * // Call once at app startup + * installWorkletDispatcher(runOnUI); + * ``` + */ +export function installWorkletDispatcher( + runOnUI: ( + worklet: (...args: Args) => ReturnValue + ) => (...args: Args) => void +): void { + if (isInstalled) { + return; + } + isInstalled = true; + + // Create bridge on JS thread + const bridge = + NitroModules.createHybridObject('RiveWorkletBridge'); + + // Box it so we can use it in worklet + const boxedBridge = NitroModules.box(bridge); + + // Call install on Reanimated's UI runtime so dispatcher is installed there + runOnUI(() => { + 'worklet'; + const b = boxedBridge.unbox(); + b.install(); + })(); +} diff --git a/src/index.tsx b/src/index.tsx index f37bd9eb..0277ed52 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -56,3 +56,4 @@ export { useRiveFile } from './hooks/useRiveFile'; export { type RiveFileInput } from './hooks/useRiveFile'; export { type SetValueAction } from './types'; export { DataBindMode }; +export { installWorkletDispatcher } from './core/WorkletBridge'; diff --git a/src/specs/RiveWorkletBridge.nitro.ts b/src/specs/RiveWorkletBridge.nitro.ts new file mode 100644 index 00000000..fa26a331 --- /dev/null +++ b/src/specs/RiveWorkletBridge.nitro.ts @@ -0,0 +1,13 @@ +import type { HybridObject } from 'react-native-nitro-modules'; + +/** + * Bridge for installing Nitro Dispatcher on the worklets UI runtime. + */ +export interface RiveWorkletBridge + extends HybridObject<{ ios: 'c++'; android: 'c++' }> { + /** + * Install the dispatcher on the current runtime. + * Must be called from the UI runtime (via scheduleOnUI). + */ + install(): void; +} diff --git a/yarn.lock b/yarn.lock index 435febbf..8de8ee42 100644 --- a/yarn.lock +++ b/yarn.lock @@ -680,7 +680,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-arrow-functions@npm:^7.0.0-0, @babel/plugin-transform-arrow-functions@npm:^7.24.7, @babel/plugin-transform-arrow-functions@npm:^7.27.1": +"@babel/plugin-transform-arrow-functions@npm:7.27.1, @babel/plugin-transform-arrow-functions@npm:^7.0.0-0, @babel/plugin-transform-arrow-functions@npm:^7.24.7, @babel/plugin-transform-arrow-functions@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-transform-arrow-functions@npm:7.27.1" dependencies: @@ -739,7 +739,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-class-properties@npm:^7.0.0-0, @babel/plugin-transform-class-properties@npm:^7.25.4, @babel/plugin-transform-class-properties@npm:^7.27.1": +"@babel/plugin-transform-class-properties@npm:7.27.1, @babel/plugin-transform-class-properties@npm:^7.0.0-0, @babel/plugin-transform-class-properties@npm:^7.25.4, @babel/plugin-transform-class-properties@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-transform-class-properties@npm:7.27.1" dependencies: @@ -763,7 +763,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-classes@npm:^7.0.0-0, @babel/plugin-transform-classes@npm:^7.25.4, @babel/plugin-transform-classes@npm:^7.28.4": +"@babel/plugin-transform-classes@npm:7.28.4, @babel/plugin-transform-classes@npm:^7.0.0-0, @babel/plugin-transform-classes@npm:^7.25.4, @babel/plugin-transform-classes@npm:^7.28.4": version: 7.28.4 resolution: "@babel/plugin-transform-classes@npm:7.28.4" dependencies: @@ -1037,7 +1037,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-nullish-coalescing-operator@npm:^7.0.0-0, @babel/plugin-transform-nullish-coalescing-operator@npm:^7.24.7, @babel/plugin-transform-nullish-coalescing-operator@npm:^7.27.1": +"@babel/plugin-transform-nullish-coalescing-operator@npm:7.27.1, @babel/plugin-transform-nullish-coalescing-operator@npm:^7.0.0-0, @babel/plugin-transform-nullish-coalescing-operator@npm:^7.24.7, @babel/plugin-transform-nullish-coalescing-operator@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-transform-nullish-coalescing-operator@npm:7.27.1" dependencies: @@ -1097,6 +1097,18 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-optional-chaining@npm:7.27.1": + version: 7.27.1 + resolution: "@babel/plugin-transform-optional-chaining@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": ^7.27.1 + "@babel/helper-skip-transparent-expression-wrappers": ^7.27.1 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: c4428d31f182d724db6f10575669aad3dbccceb0dea26aa9071fa89f11b3456278da3097fcc78937639a13c105a82cd452dc0218ce51abdbcf7626a013b928a5 + languageName: node + linkType: hard + "@babel/plugin-transform-optional-chaining@npm:^7.0.0-0, @babel/plugin-transform-optional-chaining@npm:^7.24.8, @babel/plugin-transform-optional-chaining@npm:^7.27.1, @babel/plugin-transform-optional-chaining@npm:^7.28.5": version: 7.28.5 resolution: "@babel/plugin-transform-optional-chaining@npm:7.28.5" @@ -1277,7 +1289,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-shorthand-properties@npm:^7.0.0-0, @babel/plugin-transform-shorthand-properties@npm:^7.24.7, @babel/plugin-transform-shorthand-properties@npm:^7.27.1": +"@babel/plugin-transform-shorthand-properties@npm:7.27.1, @babel/plugin-transform-shorthand-properties@npm:^7.0.0-0, @babel/plugin-transform-shorthand-properties@npm:^7.24.7, @babel/plugin-transform-shorthand-properties@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-transform-shorthand-properties@npm:7.27.1" dependencies: @@ -1322,7 +1334,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-template-literals@npm:^7.0.0-0, @babel/plugin-transform-template-literals@npm:^7.27.1": +"@babel/plugin-transform-template-literals@npm:7.27.1, @babel/plugin-transform-template-literals@npm:^7.0.0-0, @babel/plugin-transform-template-literals@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-transform-template-literals@npm:7.27.1" dependencies: @@ -1344,7 +1356,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-typescript@npm:^7.25.2, @babel/plugin-transform-typescript@npm:^7.28.5": +"@babel/plugin-transform-typescript@npm:^7.25.2, @babel/plugin-transform-typescript@npm:^7.27.1, @babel/plugin-transform-typescript@npm:^7.28.5": version: 7.28.5 resolution: "@babel/plugin-transform-typescript@npm:7.28.5" dependencies: @@ -1382,7 +1394,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-transform-unicode-regex@npm:^7.0.0-0, @babel/plugin-transform-unicode-regex@npm:^7.24.7, @babel/plugin-transform-unicode-regex@npm:^7.27.1": +"@babel/plugin-transform-unicode-regex@npm:7.27.1, @babel/plugin-transform-unicode-regex@npm:^7.0.0-0, @babel/plugin-transform-unicode-regex@npm:^7.24.7, @babel/plugin-transform-unicode-regex@npm:^7.27.1": version: 7.27.1 resolution: "@babel/plugin-transform-unicode-regex@npm:7.27.1" dependencies: @@ -1515,6 +1527,21 @@ __metadata: languageName: node linkType: hard +"@babel/preset-typescript@npm:7.27.1": + version: 7.27.1 + resolution: "@babel/preset-typescript@npm:7.27.1" + dependencies: + "@babel/helper-plugin-utils": ^7.27.1 + "@babel/helper-validator-option": ^7.27.1 + "@babel/plugin-syntax-jsx": ^7.27.1 + "@babel/plugin-transform-modules-commonjs": ^7.27.1 + "@babel/plugin-transform-typescript": ^7.27.1 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 38020f1b23e88ec4fbffd5737da455d8939244bddfb48a2516aef93fb5947bd9163fb807ce6eff3e43fa5ffe9113aa131305fef0fb5053998410bbfcfe6ce0ec + languageName: node + linkType: hard + "@babel/preset-typescript@npm:^7.16.7, @babel/preset-typescript@npm:^7.23.0, @babel/preset-typescript@npm:^7.24.7": version: 7.28.5 resolution: "@babel/preset-typescript@npm:7.28.5" @@ -6736,7 +6763,7 @@ __metadata: languageName: node linkType: hard -"convert-source-map@npm:^2.0.0": +"convert-source-map@npm:2.0.0, convert-source-map@npm:^2.0.0": version: 2.0.0 resolution: "convert-source-map@npm:2.0.0" checksum: 63ae9933be5a2b8d4509daca5124e20c14d023c820258e484e32dc324d34c2754e71297c94a05784064ad27615037ef677e3f0c00469fb55f409d2bb21261035 @@ -8207,11 +8234,11 @@ __metadata: react-native: 0.81.5 react-native-gesture-handler: 2.29.1 react-native-nitro-modules: 0.31.10 - react-native-reanimated: 4.1.5 + react-native-reanimated: 4.2.0 react-native-safe-area-context: ~5.6.0 react-native-screens: ~4.16.0 react-native-web: ~0.21.0 - react-native-worklets: 0.6.1 + react-native-worklets: 0.7.1 typescript: ~5.9.2 languageName: unknown linkType: soft @@ -13828,7 +13855,7 @@ __metadata: languageName: node linkType: hard -"react-native-is-edge-to-edge@npm:^1.1.6, react-native-is-edge-to-edge@npm:^1.2.1": +"react-native-is-edge-to-edge@npm:1.2.1, react-native-is-edge-to-edge@npm:^1.1.6, react-native-is-edge-to-edge@npm:^1.2.1": version: 1.2.1 resolution: "react-native-is-edge-to-edge@npm:1.2.1" peerDependencies: @@ -13873,6 +13900,20 @@ __metadata: languageName: node linkType: hard +"react-native-reanimated@npm:4.2.0": + version: 4.2.0 + resolution: "react-native-reanimated@npm:4.2.0" + dependencies: + react-native-is-edge-to-edge: 1.2.1 + semver: 7.7.3 + peerDependencies: + react: "*" + react-native: "*" + react-native-worklets: ">=0.7.0" + checksum: a8a4c321513cdca93a66b90c284c86eb56b05ac7d8989cd36b3e090055b59b9613870891868432835a13d1ddcaad3b1763bb9c7ed267616d90e138625d15ef23 + languageName: node + linkType: hard + "react-native-rive-example@workspace:example": version: 0.0.0-use.local resolution: "react-native-rive-example@workspace:example" @@ -13968,6 +14009,29 @@ __metadata: languageName: node linkType: hard +"react-native-worklets@npm:0.7.1": + version: 0.7.1 + resolution: "react-native-worklets@npm:0.7.1" + dependencies: + "@babel/plugin-transform-arrow-functions": 7.27.1 + "@babel/plugin-transform-class-properties": 7.27.1 + "@babel/plugin-transform-classes": 7.28.4 + "@babel/plugin-transform-nullish-coalescing-operator": 7.27.1 + "@babel/plugin-transform-optional-chaining": 7.27.1 + "@babel/plugin-transform-shorthand-properties": 7.27.1 + "@babel/plugin-transform-template-literals": 7.27.1 + "@babel/plugin-transform-unicode-regex": 7.27.1 + "@babel/preset-typescript": 7.27.1 + convert-source-map: 2.0.0 + semver: 7.7.3 + peerDependencies: + "@babel/core": "*" + react: "*" + react-native: "*" + checksum: d6ca920ce53cad6ad45ac8379914adfaf73a92d76dc7c68d9b8a8a2913f7042ec8d60bbb6fcf72afe593993d087cbe6277c3f81927d2c0ade9a94acaf58b5dc3 + languageName: node + linkType: hard + "react-native@npm:0.79.2": version: 0.79.2 resolution: "react-native@npm:0.79.2" @@ -14721,21 +14785,21 @@ __metadata: languageName: node linkType: hard -"semver@npm:^6.3.0, semver@npm:^6.3.1": - version: 6.3.1 - resolution: "semver@npm:6.3.1" +"semver@npm:7.7.3, semver@npm:^7.1.3, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.5.2, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.3, semver@npm:^7.7.1": + version: 7.7.3 + resolution: "semver@npm:7.7.3" bin: semver: bin/semver.js - checksum: ae47d06de28836adb9d3e25f22a92943477371292d9b665fb023fae278d345d508ca1958232af086d85e0155aee22e313e100971898bbb8d5d89b8b1d4054ca2 + checksum: f013a3ee4607857bcd3503b6ac1d80165f7f8ea94f5d55e2d3e33df82fce487aa3313b987abf9b39e0793c83c9fc67b76c36c067625141a9f6f704ae0ea18db2 languageName: node linkType: hard -"semver@npm:^7.1.3, semver@npm:^7.3.4, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.5.2, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.0, semver@npm:^7.6.3, semver@npm:^7.7.1": - version: 7.7.3 - resolution: "semver@npm:7.7.3" +"semver@npm:^6.3.0, semver@npm:^6.3.1": + version: 6.3.1 + resolution: "semver@npm:6.3.1" bin: semver: bin/semver.js - checksum: f013a3ee4607857bcd3503b6ac1d80165f7f8ea94f5d55e2d3e33df82fce487aa3313b987abf9b39e0793c83c9fc67b76c36c067625141a9f6f704ae0ea18db2 + checksum: ae47d06de28836adb9d3e25f22a92943477371292d9b665fb023fae278d345d508ca1958232af086d85e0155aee22e313e100971898bbb8d5d89b8b1d4054ca2 languageName: node linkType: hard