From ac612cc6e54ea1acbd0cd60672dc7ec0db18533b Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 21 Sep 2026 10:43:37 -0400 Subject: [PATCH 1/4] docs: refresh the foldable demo --- .github/workflows/docs.yml | 2 +- README.md | 2 +- docs/launch.md | 10 +++++----- website/docs/example.md | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 31da181..8761ed4 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -46,7 +46,7 @@ jobs: - name: Download demo videos run: | mkdir -p website/build/demo - for video in fold-showcase.mp4 fold-showcase-v2.mp4 fold-showcase-v3.mp4; do + for video in fold-showcase.mp4 fold-showcase-v2.mp4 fold-showcase-v3.mp4 fold-showcase-v4.mp4; do curl --fail --location --retry 3 \ --output "website/build/demo/$video" \ "https://github.com/appandflow/react-native-hinges/releases/download/demo-2026-09-21/$video" diff --git a/README.md b/README.md index 180909b..4d224ca 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ multiple-hinge hardware have not yet been validated. ## Example -[Watch the folding Field Notes demo](https://appandflow.github.io/react-native-hinges/demo/fold-showcase-v3.mp4): native Android emulator footage mapped onto a Galaxy Z Fold 3 model. Hinge angles turn daylight into sunset and drive fades and slides; reserved regions keep the pages and toolbar clear of the fold and cutout. The example includes the Worklets patch described above. +[Watch the folding Field Notes demo](https://appandflow.github.io/react-native-hinges/demo/fold-showcase-v4.mp4): native Android emulator footage mapped onto a Galaxy Z Fold 3 model. Hinge angles turn daylight into sunset and drive fades and slides; reserved regions keep the pages and toolbar clear of the fold and cutout. The example includes the Worklets patch described above. ## Development diff --git a/docs/launch.md b/docs/launch.md index 95865c1..e578d17 100644 --- a/docs/launch.md +++ b/docs/launch.md @@ -13,7 +13,7 @@ dist-tags before posting. This document does not authorize posting to X. > Here they are together in Field Notes. New Architecture, Reanimated support, > and alpha releases available now. -Attach the [folding Field Notes demo](https://appandflow.github.io/react-native-hinges/demo/fold-showcase-v3.mp4). +Attach the [folding Field Notes demo](https://appandflow.github.io/react-native-hinges/demo/fold-showcase-v4.mp4). Suggested reply: @@ -40,9 +40,9 @@ installing either library. They must apply it, rebuild, and validate their app. The video maps real Android emulator app footage onto a [Galaxy Z Fold 3 model by RHModels / CGTrader](https://www.cgtrader.com/free-3d-models/electronics/phone/samsung-galaxy-z-fold-3-black-free-3d-model). It is a device illustration, not a physical-device recording. The model follows -the native angle readout extracted from the app recording. +a lightly smoothed curve extracted from the native angle readout in the app recording. The library readings and app footage stay unfiltered. -The app runs on the owned API 34 emulator `emulator-5590`, with React Native +The app runs on the owned API 34 emulator `emulator-5592`, with React Native `0.88.0-rc.1`, Reanimated `4.7.0`, Worklets `0.13.0` plus the example FIFO patch, and npm-installed reserved regions `0.1.0-alpha.2`. Hinges and the example use local repository source. The recording uses the native Fabric observer implementation and example @@ -65,13 +65,13 @@ APIs; the video does not use the app's simulated preview. The regions API report occlusion geometry and does not identify a camera. The scrcpy recording is cropped to remove the Android taskbar and converted to -24 fps for the render using the source timing, with the final static frame held. Output frame rate is not native event frequency. The camera moves through +60 fps for the render using the source timing, with the final static frame held. The app viewport matches the model display aspect ratio, so the screen content keeps its proportions. A Gaussian filter with a 0.16-second standard deviation smooths only the illustrated phone angle. Output frame rate is not native event frequency. The move from the fold close-up to the camera close-up takes two seconds. The camera moves through a fold sequence and two close-ups; it does not establish hardware performance or continuous iOS hinge-angle delivery. ## Retained evidence -The task's `fold-showcase-v3/` artifact directory retains the source capture, +The task's `fold-showcase-v4/` artifact directory retains the source capture, input and tab logs, audited texture, OCR angle readings, rendering scripts, final video, poster, and provenance. The original alpha.2 capture remains under `alpha2-demo/android/`; it is superseded for the launch video. diff --git a/website/docs/example.md b/website/docs/example.md index 2e8dc10..0f24788 100644 --- a/website/docs/example.md +++ b/website/docs/example.md @@ -67,7 +67,7 @@ recording; tap it again to restore them. Hiding the controls also selects native The example installs `react-native-reserved-regions@0.1.0-alpha.2` from npm. -[Watch the folding Field Notes demo](https://appandflow.github.io/react-native-hinges/demo/fold-showcase-v3.mp4). The 34-second video maps native Android emulator footage onto a [Galaxy Z Fold 3 model by RHModels](https://www.cgtrader.com/free-3d-models/electronics/phone/samsung-galaxy-z-fold-3-black-free-3d-model). The model follows the recorded native angle; continuous camera moves reveal the fold region and the toolbar switching tabs around camera clearance. Callouts fade in as each move settles. The video is stored as a release asset and included in the website deployment; video binaries stay outside the source tree. The recording uses local Hinges and example source after alpha.2, the example Worklets patch, and npm-installed reserved regions alpha.2. The illustrated device is not a physical-device recording. +[Watch the folding Field Notes demo](https://appandflow.github.io/react-native-hinges/demo/fold-showcase-v4.mp4). The 30-second video maps native Android emulator footage onto a [Galaxy Z Fold 3 model by RHModels](https://www.cgtrader.com/free-3d-models/electronics/phone/samsung-galaxy-z-fold-3-black-free-3d-model). The app viewport matches the model’s display proportions. The model follows a lightly smoothed curve extracted from the recorded native angle; continuous camera moves reveal the fold region and the toolbar switching tabs around camera clearance. The fold-to-camera move takes two seconds. Callouts fade in as each move settles. The illustrated phone is rendered at 60 fps; the app footage keeps its original cadence and native readings. The video is stored as a release asset and included in the website deployment; video binaries stay outside the source tree. The recording uses local Hinges and example source after alpha.2, the example Worklets patch, and npm-installed reserved regions alpha.2. The illustrated device is not a physical-device recording. ## Sensor Lab From 66b67a16b833943a45bae5c097a1f088c8ad8db6 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Mon, 21 Sep 2026 11:45:31 -0400 Subject: [PATCH 2/4] docs: keep demo media local --- .github/workflows/docs.yml | 8 -------- README.md | 2 +- docs/launch.md | 12 +++++++++--- website/docs/example.md | 2 +- 4 files changed, 11 insertions(+), 13 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 8761ed4..376b6f6 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -43,14 +43,6 @@ jobs: run: pnpm install --frozen-lockfile - name: Build documentation run: pnpm run docs:build - - name: Download demo videos - run: | - mkdir -p website/build/demo - for video in fold-showcase.mp4 fold-showcase-v2.mp4 fold-showcase-v3.mp4 fold-showcase-v4.mp4; do - curl --fail --location --retry 3 \ - --output "website/build/demo/$video" \ - "https://github.com/appandflow/react-native-hinges/releases/download/demo-2026-09-21/$video" - done - name: Configure Pages uses: actions/configure-pages@v5 with: diff --git a/README.md b/README.md index 4d224ca..5b596a6 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ multiple-hinge hardware have not yet been validated. ## Example -[Watch the folding Field Notes demo](https://appandflow.github.io/react-native-hinges/demo/fold-showcase-v4.mp4): native Android emulator footage mapped onto a Galaxy Z Fold 3 model. Hinge angles turn daylight into sunset and drive fades and slides; reserved regions keep the pages and toolbar clear of the fold and cutout. The example includes the Worklets patch described above. +The Field Notes example uses native Android emulator footage mapped onto a Galaxy Z Fold 3 model. Hinge angles turn daylight into sunset and drive fades and slides; reserved regions keep the pages and toolbar clear of the fold and cutout. The example includes the Worklets patch described above. The review render is kept as a local artifact outside the repository and website deployment. ## Development diff --git a/docs/launch.md b/docs/launch.md index e578d17..342e200 100644 --- a/docs/launch.md +++ b/docs/launch.md @@ -13,7 +13,8 @@ dist-tags before posting. This document does not authorize posting to X. > Here they are together in Field Notes. New Architecture, Reanimated support, > and alpha releases available now. -Attach the [folding Field Notes demo](https://appandflow.github.io/react-native-hinges/demo/fold-showcase-v4.mp4). +The folding Field Notes review render is kept as a local artifact outside the +repository and website deployment. Suggested reply: @@ -37,7 +38,7 @@ installing either library. They must apply it, rebuild, and validate their app. ## Recorded demo -The video maps real Android emulator app footage onto a +The v5 local review render maps real Android emulator app footage onto a [Galaxy Z Fold 3 model by RHModels / CGTrader](https://www.cgtrader.com/free-3d-models/electronics/phone/samsung-galaxy-z-fold-3-black-free-3d-model). It is a device illustration, not a physical-device recording. The model follows a lightly smoothed curve extracted from the native angle readout in the app recording. The library readings and app footage stay unfiltered. @@ -69,9 +70,14 @@ The scrcpy recording is cropped to remove the Android taskbar and converted to a fold sequence and two close-ups; it does not establish hardware performance or continuous iOS hinge-angle delivery. +The clean v5 composition retains the title, hinge angle, divider lines, and +device while removing callout overlays and footer labels. The source capture was +taken from Hinges at commit `8578e26`; the documentation/main comparison point +is `f38954e`. This artifact does not claim full native current-release validation. + ## Retained evidence -The task's `fold-showcase-v4/` artifact directory retains the source capture, +The task's `fold-showcase-v5/` artifact directory retains the source capture, input and tab logs, audited texture, OCR angle readings, rendering scripts, final video, poster, and provenance. The original alpha.2 capture remains under `alpha2-demo/android/`; it is superseded for the launch video. diff --git a/website/docs/example.md b/website/docs/example.md index 0f24788..d8ea2c0 100644 --- a/website/docs/example.md +++ b/website/docs/example.md @@ -67,7 +67,7 @@ recording; tap it again to restore them. Hiding the controls also selects native The example installs `react-native-reserved-regions@0.1.0-alpha.2` from npm. -[Watch the folding Field Notes demo](https://appandflow.github.io/react-native-hinges/demo/fold-showcase-v4.mp4). The 30-second video maps native Android emulator footage onto a [Galaxy Z Fold 3 model by RHModels](https://www.cgtrader.com/free-3d-models/electronics/phone/samsung-galaxy-z-fold-3-black-free-3d-model). The app viewport matches the model’s display proportions. The model follows a lightly smoothed curve extracted from the recorded native angle; continuous camera moves reveal the fold region and the toolbar switching tabs around camera clearance. The fold-to-camera move takes two seconds. Callouts fade in as each move settles. The illustrated phone is rendered at 60 fps; the app footage keeps its original cadence and native readings. The video is stored as a release asset and included in the website deployment; video binaries stay outside the source tree. The recording uses local Hinges and example source after alpha.2, the example Worklets patch, and npm-installed reserved regions alpha.2. The illustrated device is not a physical-device recording. +The example's local review render maps native Android emulator footage onto a [Galaxy Z Fold 3 model by RHModels](https://www.cgtrader.com/free-3d-models/electronics/phone/samsung-galaxy-z-fold-3-black-free-3d-model). The app viewport matches the model's display proportions. The model follows a lightly smoothed curve extracted from the recorded native angle; continuous camera moves reveal the fold region and the toolbar switching tabs around camera clearance. The fold-to-camera move takes two seconds. The illustrated phone is rendered at 60 fps; the app footage keeps its original cadence and native readings. The clean composition keeps the title, hinge angle, divider lines, and device, with no callout overlays or footer labels. The review render stays outside the repository and website deployment. The recording uses local Hinges and example source after alpha.2, the example Worklets patch, and npm-installed reserved regions alpha.2. The illustrated device is not a physical-device recording. ## Sensor Lab From 4d8c8f08f4ea825960892f58cdc548c8c41fc604 Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Tue, 22 Sep 2026 08:28:27 -0400 Subject: [PATCH 3/4] feat: observe animated hinges without a provider --- Hinges.podspec | 12 +- README.md | 35 +- android/build.gradle | 30 ++ android/src/main/cpp/CMakeLists.txt | 37 +++ android/src/main/cpp/HingesWorkletsModule.cpp | 92 ++++++ .../com/appandflow/hinges/HingesModule.kt | 32 +- .../appandflow/hinges/HingesObserverView.kt | 41 --- .../hinges/HingesObserverViewManager.kt | 29 -- .../com/appandflow/hinges/HingesPackage.kt | 4 - cpp/HingesWorklets.cpp | 115 +++++++ cpp/HingesWorklets.h | 33 ++ .../worklets-bridge-2026-09-22.md | 113 +++++++ example/ios/Podfile.lock | 30 +- example/package.json | 1 + example/src/App.tsx | 34 +- example/src/FieldNotes.tsx | 302 +++++++++++------- ios/HingesModule.h | 3 +- ios/HingesModule.mm | 19 ++ ios/HingesObserverView.h | 8 - ios/HingesObserverView.mm | 78 ----- package.json | 7 +- pnpm-lock.yaml | 14 + scripts/check-package.mjs | 4 + src/HingesObserverViewNativeComponent.ts | 27 -- src/__tests__/reanimated.test.tsx | 176 +++++----- src/reanimated.tsx | 103 ++---- website/docs/api.md | 16 +- website/docs/example.md | 2 +- website/docs/platforms.md | 4 +- website/docs/reanimated.md | 26 +- 30 files changed, 904 insertions(+), 523 deletions(-) create mode 100644 android/src/main/cpp/CMakeLists.txt create mode 100644 android/src/main/cpp/HingesWorkletsModule.cpp delete mode 100644 android/src/main/java/com/appandflow/hinges/HingesObserverView.kt delete mode 100644 android/src/main/java/com/appandflow/hinges/HingesObserverViewManager.kt create mode 100644 cpp/HingesWorklets.cpp create mode 100644 cpp/HingesWorklets.h create mode 100644 docs/verification/worklets-bridge-2026-09-22.md delete mode 100644 ios/HingesObserverView.h delete mode 100644 ios/HingesObserverView.mm delete mode 100644 src/HingesObserverViewNativeComponent.ts diff --git a/Hinges.podspec b/Hinges.podspec index 582a36e..67974b5 100644 --- a/Hinges.podspec +++ b/Hinges.podspec @@ -13,8 +13,16 @@ Pod::Spec.new do |s| s.platforms = { :ios => min_ios_version_supported } s.source = { :git => "https://github.com/AppAndFlow/react-native-hinges.git", :tag => "#{s.version}" } - s.source_files = "ios/**/*.{h,m,mm,swift,cpp}" - s.private_header_files = "ios/**/*.h" + s.source_files = "ios/**/*.{h,m,mm,swift,cpp}", "cpp/**/*.{h,cpp}" + s.private_header_files = "ios/**/*.h", "cpp/**/*.h" + + worklets_available = system("node", "-e", + "require.resolve('react-native-worklets/package.json', { paths: [process.argv[1]] })", + Pod::Config.instance.installation_root.to_s, :out => File::NULL, :err => File::NULL) + if worklets_available + s.dependency "RNWorklets" + s.pod_target_xcconfig = { "GCC_PREPROCESSOR_DEFINITIONS" => "$(inherited) HINGES_WORKLETS_ENABLED=1" } + end install_modules_dependencies(s) end diff --git a/README.md b/README.md index 5b596a6..99bf63d 100644 --- a/README.md +++ b/README.md @@ -91,11 +91,12 @@ The optional `react-native-hinges/reanimated` entry point requires Reanimated the Worklets Babel plugin and rebuild native dependencies using [Reanimated's setup guide](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/getting-started/). -Wrap the consuming tree in `AnimatedHingesProvider`. It renders one hidden native -view that observes hinges and delivers each update to Reanimated's UI runtime. +Call `useAnimatedHinges()` inside a React Native root. It needs no provider or +additional native view. This provider-free integration is unreleased; the +published `0.1.0-alpha.2` still uses `AnimatedHingesProvider`. ```tsx -import { AnimatedHingesProvider, useAnimatedHinges } from 'react-native-hinges/reanimated'; +import { useAnimatedHinges } from 'react-native-hinges/reanimated'; import { useAnimatedStyle } from 'react-native-reanimated'; function useHingeCardStyle() { @@ -108,25 +109,19 @@ function useHingeCardStyle() { }; }); } - -export default function App() { - return ( - - - - ); -} ``` -The hook returns the provider's read-only-by-contract `SharedValue`, -seeded with `[]`. Read it inside worklets and do not write to it. It throws when -rendered without the provider. Native events update the value on the UI runtime, -preserving raw radians and nullable angles without JS delivery as an intermediate -step. The library adds no smoothing or sampling-frequency guarantee. - -Unmounting the provider removes its native view and ends that observation. An -externally retained shared value keeps its last snapshot. See the -[integration guide](website/docs/reanimated.md) for setup and compatibility limits. +The hook returns a read-only-by-contract `SharedValue`, +seeded from the root's native cache or `[]` before its first reading. Each hook +owns a shared value; consumers of the same root share native observation with +the ordinary hook and explicit observers. Read it inside worklets and do not +write to it. Native callbacks update it through Worklets' stable C++ API, +preserving raw radians and nullable angles without a JavaScript-thread hop. +The library adds no smoothing or sampling-frequency guarantee. + +Unmounting releases the subscription. The last subscriber releases native +observation and its cache; an externally retained shared value keeps its last +snapshot. See the [integration guide](website/docs/reanimated.md). ## Native behavior diff --git a/android/build.gradle b/android/build.gradle index cd69c60..cdf6da2 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -35,6 +35,14 @@ if (project.extensions.findByName("kotlin") == null) { apply plugin: "com.facebook.react" +def reactNativeDir = file(providers.exec { + workingDir projectDir + commandLine "node", "--print", "require.resolve('react-native/package.json')" +}.standardOutput.asText.get().trim()).parentFile + +def reactNativeArchitectures = (findProperty("reactNativeArchitectures") ?: "armeabi-v7a,arm64-v8a,x86,x86_64") + .split(",").collect { it.trim() }.findAll { it } + android { namespace "com.appandflow.hinges" @@ -42,16 +50,38 @@ android { defaultConfig { minSdkVersion getExtOrDefault("minSdkVersion") + + externalNativeBuild { + cmake { + arguments "-DANDROID_STL=c++_shared", "-DREACT_NATIVE_DIR=${reactNativeDir}" + targets "hingesworklets" + abiFilters.addAll(reactNativeArchitectures) + } + } + } + + externalNativeBuild { + cmake { + path file("src/main/cpp/CMakeLists.txt") + } } compileOptions { sourceCompatibility JavaVersion.VERSION_17 targetCompatibility JavaVersion.VERSION_17 } + + buildFeatures { + prefab true + } } dependencies { implementation "com.facebook.react:react-android" implementation "androidx.window:window:1.5.1" implementation "androidx.window:window-java:1.5.1" + def workletsProject = rootProject.findProject(":react-native-worklets") + if (workletsProject != null) { + implementation workletsProject + } } diff --git a/android/src/main/cpp/CMakeLists.txt b/android/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000..ea2e7f0 --- /dev/null +++ b/android/src/main/cpp/CMakeLists.txt @@ -0,0 +1,37 @@ +cmake_minimum_required(VERSION 3.16) +project(hingesworklets) + +set(CMAKE_CXX_STANDARD 20) + +find_package(fbjni REQUIRED CONFIG) +find_package(ReactAndroid REQUIRED CONFIG) +find_package(react-native-worklets CONFIG QUIET) + +get_filename_component(PACKAGE_ROOT "${CMAKE_CURRENT_LIST_DIR}/../../../.." ABSOLUTE) + +add_library( + hingesworklets + SHARED + HingesWorkletsModule.cpp + "${PACKAGE_ROOT}/cpp/HingesWorklets.cpp") + +target_include_directories( + hingesworklets + PRIVATE + "${PACKAGE_ROOT}/cpp" + "${REACT_NATIVE_DIR}/ReactAndroid/src/main/jni/react/turbomodule") + +target_link_libraries( + hingesworklets + android + log + ReactAndroid::reactnative + ReactAndroid::jsi + fbjni::fbjni) + +if(TARGET react-native-worklets::worklets) + target_compile_definitions(hingesworklets PRIVATE HINGES_WORKLETS_ENABLED=1) + target_link_libraries(hingesworklets react-native-worklets::worklets) +else() + target_compile_definitions(hingesworklets PRIVATE HINGES_WORKLETS_ENABLED=0) +endif() diff --git a/android/src/main/cpp/HingesWorkletsModule.cpp b/android/src/main/cpp/HingesWorkletsModule.cpp new file mode 100644 index 0000000..d590134 --- /dev/null +++ b/android/src/main/cpp/HingesWorkletsModule.cpp @@ -0,0 +1,92 @@ +#include +#include +#include + +#include +#include +#include +#include + +namespace hinges { + +using namespace facebook; + +class HingesWorkletsModule : public jni::HybridClass { + public: + static constexpr auto kJavaDescriptor = "Lcom/appandflow/hinges/HingesModule;"; + + static jni::local_ref initWorkletsBridge( + jni::alias_ref jThis) { + return makeCxxInstance(jThis); + } + + static jni::local_ref getBindingsInstaller( + jni::alias_ref jThis) { + auto bridge = jThis->cthis()->bridge_; + auto module = jni::make_global(jThis); + return react::BindingsInstallerHolder::newObjectCxxArgs( + [bridge = std::move(bridge), module = std::move(module)]( + jsi::Runtime& runtime, const std::shared_ptr&) { + bridge->install( + runtime, + [module](int rootTag) { + jni::ThreadScope::WithClassLoader([&] { + static const auto startObserving = module->getClass()->getMethod("startObserving"); + startObserving(module, static_cast(rootTag)); + }); + }, + [module](int rootTag) { + jni::ThreadScope::WithClassLoader([&] { + static const auto stopObserving = module->getClass()->getMethod("stopObserving"); + stopObserving(module, static_cast(rootTag)); + }); + }); + }); + } + + static void publishHinges( + jni::alias_ref jThis, + jint rootTag, + jni::alias_ref> statuses, + jni::alias_ref angles, + jni::alias_ref hasAngles) { + const auto count = statuses->size(); + std::vector hinges; + hinges.reserve(count); + + auto angleValues = angles->getRegion(0, count); + auto hasAngleValues = hasAngles->getRegion(0, count); + for (jsize index = 0; index < count; index++) { + jni::local_ref status = (*statuses)[index]; + hinges.push_back({status->toStdString(), angleValues[index], hasAngleValues[index] == JNI_TRUE}); + } + + jThis->cthis()->bridge_->publish(rootTag, hinges); + } + + static void invalidateWorkletsBridge(jni::alias_ref jThis) { + jThis->cthis()->bridge_->invalidate(); + } + + static void registerNatives() { + javaClassLocal()->registerNatives({ + makeNativeMethod("initWorkletsBridge", HingesWorkletsModule::initWorkletsBridge), + makeNativeMethod("getBindingsInstaller", HingesWorkletsModule::getBindingsInstaller), + makeNativeMethod("publishHinges", HingesWorkletsModule::publishHinges), + makeNativeMethod("invalidateWorkletsBridge", HingesWorkletsModule::invalidateWorkletsBridge), + }); + } + + private: + friend HybridBase; + + explicit HingesWorkletsModule(jni::alias_ref) : bridge_(std::make_shared()) {} + + std::shared_ptr bridge_; +}; + +} // namespace hinges + +JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void*) { + return facebook::jni::initialize(vm, [] { hinges::HingesWorkletsModule::registerNatives(); }); +} diff --git a/android/src/main/java/com/appandflow/hinges/HingesModule.kt b/android/src/main/java/com/appandflow/hinges/HingesModule.kt index fd55ef3..1e2567a 100644 --- a/android/src/main/java/com/appandflow/hinges/HingesModule.kt +++ b/android/src/main/java/com/appandflow/hinges/HingesModule.kt @@ -1,17 +1,23 @@ package com.appandflow.hinges import android.view.View +import com.facebook.jni.HybridData +import com.facebook.proguard.annotations.DoNotStrip import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.bridge.WritableMap import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.turbomodule.core.interfaces.BindingsInstallerHolder +import com.facebook.react.turbomodule.core.interfaces.TurboModuleWithJSIBindings import com.facebook.react.uimanager.UIManagerHelper import java.util.concurrent.ConcurrentHashMap @ReactModule(name = HingesModule.NAME) -class HingesModule(context: ReactApplicationContext) : NativeHingesSpec(context) { +class HingesModule(context: ReactApplicationContext) : + NativeHingesSpec(context), TurboModuleWithJSIBindings { private val observations = mutableMapOf() private val snapshots = ConcurrentHashMap>() + @field:DoNotStrip private val mHybridData: HybridData = initWorkletsBridge() @Volatile private var invalidated = false override fun getName() = NAME @@ -58,6 +64,7 @@ class HingesModule(context: ReactApplicationContext) : NativeHingesSpec(context) override fun invalidate() { invalidated = true + invalidateWorkletsBridge() UiThreadUtil.runOnUiThread { observations.values.forEach { observation -> observation.root.removeOnAttachStateChangeListener(observation) @@ -71,9 +78,28 @@ class HingesModule(context: ReactApplicationContext) : NativeHingesSpec(context) private fun emit(rootTag: Int, hinges: List) { if (invalidated) return + publishHinges( + rootTag, + Array(hinges.size) { hinges[it].status }, + DoubleArray(hinges.size) { hinges[it].angle ?: 0.0 }, + BooleanArray(hinges.size) { hinges[it].angle != null }, + ) emitOnHingesChange(payload(hinges).apply { putDouble("rootTag", rootTag.toDouble()) }) } + @DoNotStrip external override fun getBindingsInstaller(): BindingsInstallerHolder + + private external fun initWorkletsBridge(): HybridData + + private external fun publishHinges( + rootTag: Int, + statuses: Array, + angles: DoubleArray, + hasAngles: BooleanArray, + ) + + private external fun invalidateWorkletsBridge() + private inner class Observation(val rootTag: Int, val root: View) : View.OnAttachStateChangeListener { var retainCount = 1 private val source = HingeSource(root.context) { hinges -> @@ -96,6 +122,10 @@ class HingesModule(context: ReactApplicationContext) : NativeHingesSpec(context) } companion object { + init { + com.facebook.soloader.SoLoader.loadLibrary("hingesworklets") + } + const val NAME = "NativeHinges" } } diff --git a/android/src/main/java/com/appandflow/hinges/HingesObserverView.kt b/android/src/main/java/com/appandflow/hinges/HingesObserverView.kt deleted file mode 100644 index 8681360..0000000 --- a/android/src/main/java/com/appandflow/hinges/HingesObserverView.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.appandflow.hinges - -import android.content.Context -import com.facebook.react.bridge.ReactContext -import com.facebook.react.bridge.WritableMap -import com.facebook.react.uimanager.UIManagerHelper -import com.facebook.react.uimanager.events.Event -import com.facebook.react.views.view.ReactViewGroup - -internal class HingesChangeEvent(surfaceId: Int, viewTag: Int, private val data: WritableMap) : - Event(surfaceId, viewTag) { - override fun getEventName() = EVENT_NAME - - override fun getEventData() = data - - companion object { - const val EVENT_NAME = "topHingesChange" - } -} - -class HingesObserverView(context: Context) : ReactViewGroup(context) { - private val source = HingeSource(context) { emitChange(it) } - - override fun onAttachedToWindow() { - super.onAttachedToWindow() - source.start() - } - - override fun onDetachedFromWindow() { - source.stop() - super.onDetachedFromWindow() - } - - fun refresh() = emitChange(source.hinges) - - private fun emitChange(hinges: List) { - val reactContext = context as? ReactContext ?: return - val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) ?: return - dispatcher.dispatchEvent(HingesChangeEvent(UIManagerHelper.getSurfaceId(this), id, payload(hinges))) - } -} diff --git a/android/src/main/java/com/appandflow/hinges/HingesObserverViewManager.kt b/android/src/main/java/com/appandflow/hinges/HingesObserverViewManager.kt deleted file mode 100644 index 08697e6..0000000 --- a/android/src/main/java/com/appandflow/hinges/HingesObserverViewManager.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.appandflow.hinges - -import com.facebook.react.module.annotations.ReactModule -import com.facebook.react.uimanager.SimpleViewManager -import com.facebook.react.uimanager.ThemedReactContext -import com.facebook.react.uimanager.ViewManagerDelegate -import com.facebook.react.viewmanagers.HingesObserverViewManagerDelegate -import com.facebook.react.viewmanagers.HingesObserverViewManagerInterface - -@ReactModule(name = HingesObserverViewManager.NAME) -class HingesObserverViewManager : - SimpleViewManager(), HingesObserverViewManagerInterface { - private val delegate = HingesObserverViewManagerDelegate(this) - - override fun getDelegate(): ViewManagerDelegate = delegate - - override fun getName() = NAME - - override fun createViewInstance(context: ThemedReactContext) = HingesObserverView(context) - - override fun refresh(view: HingesObserverView) = view.refresh() - - override fun getExportedCustomDirectEventTypeConstants(): MutableMap = - mutableMapOf(HingesChangeEvent.EVENT_NAME to mutableMapOf("registrationName" to "onHingesChange")) - - companion object { - const val NAME = "HingesObserverView" - } -} diff --git a/android/src/main/java/com/appandflow/hinges/HingesPackage.kt b/android/src/main/java/com/appandflow/hinges/HingesPackage.kt index 4780513..33245a0 100644 --- a/android/src/main/java/com/appandflow/hinges/HingesPackage.kt +++ b/android/src/main/java/com/appandflow/hinges/HingesPackage.kt @@ -5,12 +5,8 @@ import com.facebook.react.bridge.NativeModule import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.module.model.ReactModuleInfo import com.facebook.react.module.model.ReactModuleInfoProvider -import com.facebook.react.uimanager.ViewManager class HingesViewPackage : BaseReactPackage() { - override fun createViewManagers(reactContext: ReactApplicationContext): List> = - listOf(HingesObserverViewManager()) - override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = if (name == HingesModule.NAME) HingesModule(reactContext) else null diff --git a/cpp/HingesWorklets.cpp b/cpp/HingesWorklets.cpp new file mode 100644 index 0000000..0a5341e --- /dev/null +++ b/cpp/HingesWorklets.cpp @@ -0,0 +1,115 @@ +#include "HingesWorklets.h" + +#if HINGES_WORKLETS_ENABLED +#include +#include + +#include +#include +#include + +namespace hinges { +namespace jsi = facebook::jsi; + +struct Subscription { + int rootTag; + std::atomic active{true}; + std::weak_ptr runtime; + std::shared_ptr scheduler; + std::shared_ptr callback; +}; + +struct WorkletsBridge::State { + std::mutex mutex; + bool invalidated = false; + std::vector> subscriptions; +}; + +WorkletsBridge::WorkletsBridge() : state_(std::make_unique()) {} +WorkletsBridge::~WorkletsBridge() { invalidate(); } + +void WorkletsBridge::install(jsi::Runtime &runtime, std::function start, std::function stop) { + auto bridge = weak_from_this(); + auto subscribe = jsi::Function::createFromHostFunction( + runtime, jsi::PropNameID::forAscii(runtime, "nativeHingesSubscribe"), 4, + [bridge, start = std::move(start), stop = std::move(stop)]( + jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args, size_t count) -> jsi::Value { + auto owner = bridge.lock(); + if (!owner) throw jsi::JSError(rt, "Hinges native module was invalidated"); + if (count != 4) throw jsi::JSError(rt, "Expected root, runtime, scheduler and worklet"); + auto subscription = std::make_shared(); + subscription->rootTag = static_cast(args[0].asNumber()); + subscription->runtime = worklets::getWorkletRuntimeFromHolder(rt, args[1].asObject(rt)); + subscription->scheduler = worklets::getUISchedulerFromHolder(rt, args[2].asObject(rt)); + subscription->callback = worklets::extractSerializable( + rt, args[3], "Hinges callback must be a worklet", worklets::Serializable::ValueType::WorkletType); + { + std::lock_guard lock(owner->state_->mutex); + if (owner->state_->invalidated) throw jsi::JSError(rt, "Hinges native module was invalidated"); + owner->state_->subscriptions.push_back(subscription); + } + start(subscription->rootTag); + return jsi::Function::createFromHostFunction( + rt, jsi::PropNameID::forAscii(rt, "unsubscribeHinges"), 0, + [bridge, subscription, stop](jsi::Runtime &, const jsi::Value &, const jsi::Value *, size_t) -> jsi::Value { + auto owner = bridge.lock(); + if (!owner || !subscription->active.exchange(false)) return jsi::Value::undefined(); + { + std::lock_guard lock(owner->state_->mutex); + std::erase(owner->state_->subscriptions, subscription); + } + stop(subscription->rootTag); + return jsi::Value::undefined(); + }); + }); + runtime.global().setProperty(runtime, "nativeHingesSubscribe", std::move(subscribe)); +} + +void WorkletsBridge::publish(int rootTag, const std::vector &hinges) { + std::vector> listeners; + { + std::lock_guard lock(state_->mutex); + if (state_->invalidated) return; + for (const auto &subscription : state_->subscriptions) { + if (subscription->rootTag == rootTag) listeners.push_back(subscription); + } + } + for (const auto &subscription : listeners) { + auto deliver = [subscription, hinges] { + if (!subscription->active) return; + auto runtime = subscription->runtime.lock(); + if (!runtime) return; + auto &rt = worklets::getJSIRuntimeFromWorkletRuntime(runtime); + jsi::Array readings(rt, hinges.size()); + for (size_t index = 0; index < hinges.size(); index++) { + jsi::Object reading(rt); + reading.setProperty(rt, "status", jsi::String::createFromUtf8(rt, hinges[index].status)); + reading.setProperty(rt, "angle", hinges[index].angle); + reading.setProperty(rt, "hasAngle", hinges[index].hasAngle); + readings.setValueAtIndex(rt, index, std::move(reading)); + } + worklets::runSyncOnRuntime(runtime, subscription->callback, std::move(readings)); + }; + if (worklets::isOnUIThread(subscription->scheduler)) deliver(); + else worklets::scheduleOnUI(subscription->scheduler, std::move(deliver)); + } +} + +void WorkletsBridge::invalidate() { + std::lock_guard lock(state_->mutex); + state_->invalidated = true; + for (const auto &subscription : state_->subscriptions) subscription->active = false; + state_->subscriptions.clear(); +} + +} +#else +namespace hinges { +struct WorkletsBridge::State {}; +WorkletsBridge::WorkletsBridge() = default; +WorkletsBridge::~WorkletsBridge() = default; +void WorkletsBridge::install(facebook::jsi::Runtime &, std::function, std::function) {} +void WorkletsBridge::publish(int, const std::vector &) {} +void WorkletsBridge::invalidate() {} +} +#endif diff --git a/cpp/HingesWorklets.h b/cpp/HingesWorklets.h new file mode 100644 index 0000000..5860e68 --- /dev/null +++ b/cpp/HingesWorklets.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include +#include + +namespace facebook::jsi { +class Runtime; +} + +namespace hinges { + +struct HingeReading { + std::string status; + double angle; + bool hasAngle; +}; + +class WorkletsBridge : public std::enable_shared_from_this { + public: + WorkletsBridge(); + ~WorkletsBridge(); + void install(facebook::jsi::Runtime &runtime, std::function start, std::function stop); + void publish(int rootTag, const std::vector &hinges); + void invalidate(); + + private: + struct State; + std::unique_ptr state_; +}; + +} diff --git a/docs/verification/worklets-bridge-2026-09-22.md b/docs/verification/worklets-bridge-2026-09-22.md new file mode 100644 index 0000000..ce65e1c --- /dev/null +++ b/docs/verification/worklets-bridge-2026-09-22.md @@ -0,0 +1,113 @@ +# Provider-free Worklets bridge verification + +## Implementation + +`useAnimatedHinges()` uses the React root tag, a root-local native observation, +and Worklets' stable C++ API. It registers a serialized callback before starting +native observation. Cached snapshots are replayed through the same callback. +The hook creates no provider or observer view. The ordinary React hook and +non-React observers share the same native observation. + +React Native TurboModule JSI binding installers supply the adapter on both +platforms. The adapter uses `getWorkletRuntimeFromHolder`, +`getUISchedulerFromHolder`, `extractSerializable`, and `runSyncOnRuntime`. +Reanimated and Worklets remain optional for the ordinary API. + +## iOS + +- Device: owned iPhone Duo simulator, iOS 27.1, + `EC33DE7B-AFC0-43F4-AEC1-A16960820B4C`. +- Xcode: 27.1 beta; React Native: 0.88.0-rc.1. +- Native build passed after regenerating pods for the removed Fabric component. +- A standalone test screen rendered `useAnimatedHinges()` and `useHinges()` + together without an animated provider. +- Both paths reported `partiallyOpen` and `1.7376933033587998` radians. +- The JS thread was blocked for five seconds; no native fold changes were + supplied during this interval, so this run does not prove changing-angle + delivery during the stall. +- Device Hub motion control was unavailable while the Mac was locked. + +## Android + +- Device: owned `fold3-camera` emulator, `emulator-5598`. +- Fresh native build and installation exposed the TurboModule JSI binding. +- The standalone test screen used both hooks without an animated provider. +- The emulator hinge sensor was driven through 90, 110, 130, 150, and 170 degrees. +- During a five-second JS busy loop, the UI runtime recorded **18 native angle + updates covering five distinct values**. The ordinary React updates resumed + after the JS stall. +- Evidence interval: `1790051082599` through `1790051087599` milliseconds since + epoch. Samples were timestamped inside `useAnimatedReaction` on the UI runtime. +- This demonstrates delivery independent of the JS thread; the approximately + four updates per second reflect the test's sensor injection cadence, not a + measured device sampling limit. +- The temporary probe was removed and the normal example restored afterward. + +### Android architecture selection + +The Android adapter honors React Native's `reactNativeArchitectures` Gradle +property when setting its CMake ABI filters. When no selection is supplied, it +builds the usual four Android ABIs, matching Worklets' defaults. + +The missing-symbol failure was an ABI mismatch in Hinges' new CMake integration. +Stim supplied `-PreactNativeArchitectures=arm64-v8a`, which Worklets honored, but +Hinges originally built all four architectures. Worklets' imported target was a +shared library for arm64 and headers-only for the unbuilt architectures. The +failure occurred while linking Hinges for `armeabi-v7a`. + +The same failure reproduced without Stim or its staging directory: + +```sh +cd example/android +./gradlew :react-native-hinges:assembleDebug -PreactNativeArchitectures=arm64-v8a +``` + +After adding the CMake ABI filter, that exact command passed and scheduled only +Hinges' arm64 CMake tasks. `stim android --slot fold3-camera` then passed with +standard `react-native-worklets::worklets` linkage and launched the example on +`emulator-5598`. No explicit library paths, Stim changes, or additional Worklets +patches were needed. The earlier diagnosis attributing this failure to Stim's +nested staging directory was incorrect. + +The original runtime probe above used an experimental explicit library link. +The repaired standard-link build produced an identical APK to the already +installed build; Stim verified it and skipped reinstalling those same bytes. +After an app-scoped restart, Android logcat reported `Running "HingesExample"` +from the new process and no native linker error. Stim's automatic launch check +had not observed a fresh Metro bundle request, so this is direct runtime-log +evidence rather than a Stim launch-verification pass. +The example retains its pre-existing Worklets FIFO patch. + +## Upstream key-window comparison + +An isolated UIKit harness compiled the unmodified Reanimated native sensor files +at commit `b462c6ecbdfc5e0b959eb229ec71bfcd778b2ad2` from +`@pawicao/animated-sensor-hinge`. This did not run their full React Native hook. + +A sensor registered while a temporary overlay window was key attached to that +window. After dismissing the overlay and restoring the visible content window +as key, the sensor remained attached to the dismissed window and reported +`angle=0`, `status=UNKNOWN`. The branch has no window-change observer or +reattachment path. In an app this concerns a first sensor registration while +such an overlay is key; Reanimated otherwise shares sensor subscriptions. + +Two visible windows in the same scene initially reported the same angle. We +have not established different readings merely from selecting another visible +window, or tested multiple scenes with different display environments. + +An immediate inspection before the queued attachment block ran initially looked +like an attachment failure. The later trace showed successful attachment; that +was a test error, not an upstream race. A deliberately pre-window registration +remained unattached, but normal React startup was not shown to reach that order. + +## Automated checks + +The hook tests cover cached initialization, update mapping without React +renders, root isolation, root replacement, retained values after unmount, and +an actionable missing-native-integration error. They mock the native boundary +and do not replace runtime tests. + +The no-Worklets shared C++ fallback compiled with +`HINGES_WORKLETS_ENABLED=0`. This is not a complete core-only application build. +Local package inspection confirmed the common C++ adapter, Android JNI/CMake +sources, and public entry-point declarations are included in the tarball. diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index dfc1997..fe0864e 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -15,6 +15,30 @@ PODS: - hermes-engine/Pre-built (= 260318099.0.3) - hermes-engine/Pre-built (260318099.0.3) - Hinges (0.1.0-alpha.2): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-bridging + - React-Core + - React-Core-prebuilt + - React-cxxstableapi + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - RNWorklets + - Yoga + - LiquidGlass (0.8.2): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2050,6 +2074,7 @@ DEPENDENCIES: - glog (from `build/rndeps-facades/glog`) - hermes-engine (from `../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - Hinges (from `../..`) + - "LiquidGlass (from `../../node_modules/@callstack/liquid-glass`)" - RCT-Folly (from `build/rndeps-facades/RCT-Folly`) - RCTDeprecation (from `build/rncore-facades/RCTDeprecation`) - RCTRequired (from `build/rncore-facades/RCTRequired`) @@ -2153,6 +2178,8 @@ EXTERNAL SOURCES: :tag: hermes-v260318099.0.2 Hinges: :path: "../.." + LiquidGlass: + :path: "../../node_modules/@callstack/liquid-glass" RCT-Folly: :path: build/rndeps-facades/RCT-Folly RCTDeprecation: @@ -2328,7 +2355,8 @@ SPEC CHECKSUMS: fmt: ba0886e864ebb21d14c98b2e12e164b8002f5b93 glog: 3cf7cb46f743c4bc758ae3e6d9c15bd1b4cfd6d4 hermes-engine: 7e5f90072b91f62ead9584eebdced74fa9c742a0 - Hinges: 0aa917de16d8777f334ab5124fdc336180606e46 + Hinges: 0caf256668e211eedfd5909b443dd4ca452370cc + LiquidGlass: 90d4f92e7a6c8577f38515c30a3fe36d3c157760 RCT-Folly: 82921e585c0400eda46fac1068297aa9dfb035e4 RCTDeprecation: 791606bb67573791862d7af9a787878fefcf1aff RCTRequired: 57e3c3f5e7102529bf9a42348ff77153b01cfa0b diff --git a/example/package.json b/example/package.json index bb8a5ed..7fe9753 100644 --- a/example/package.json +++ b/example/package.json @@ -10,6 +10,7 @@ "build:ios": "react-native build-ios --mode Debug" }, "dependencies": { + "@callstack/liquid-glass": "0.8.2", "react": "19.3.0", "react-native": "0.88.0-rc.1", "react-native-reanimated": "4.7.0", diff --git a/example/src/App.tsx b/example/src/App.tsx index a07b086..31d5715 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -23,7 +23,7 @@ import Animated, { withTiming, } from 'react-native-reanimated'; import { createHingeObserver, useHinges } from 'react-native-hinges'; -import { AnimatedHingesProvider, useAnimatedHinges } from 'react-native-hinges/reanimated'; +import { useAnimatedHinges } from 'react-native-hinges/reanimated'; import { useHingeTelemetry } from './useHingeTelemetry'; import { FieldNotes } from './FieldNotes'; @@ -215,23 +215,21 @@ function Choice({ label, selected, onPress }: { label: string; selected: boolean export default function App() { const [lab, setLab] = useState(false); return ( - - - - - {lab ? ( - <> - - setLab(false)}> - Back to Field Notes - - - ) : ( - setLab(true)} /> - )} - - - + + + + {lab ? ( + <> + + setLab(false)}> + Back to Field Notes + + + ) : ( + setLab(true)} /> + )} + + ); } diff --git a/example/src/FieldNotes.tsx b/example/src/FieldNotes.tsx index 49a4ec4..94a2783 100644 --- a/example/src/FieldNotes.tsx +++ b/example/src/FieldNotes.tsx @@ -1,6 +1,7 @@ -import { useEffect, useState } from 'react'; -import { Pressable, ScrollView, StatusBar, StyleSheet, Text, View, type LayoutRectangle } from 'react-native'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { Platform, Pressable, ScrollView, StatusBar, StyleSheet, Text, View, type LayoutRectangle } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { LiquidGlassView, isLiquidGlassSupported } from '@callstack/liquid-glass'; import { ReservedRegionsProvider, useReservedRegions, useReservedRegionsReady } from 'react-native-reserved-regions'; import { useHinges } from 'react-native-hinges'; import { useAnimatedHinges } from 'react-native-hinges/reanimated'; @@ -78,56 +79,82 @@ function FieldNotesContent({ viewportWidth, onOpenLab }: { viewportWidth: number } const usableSpaces = spaces.filter((space) => space.end - space.start >= 44); const totalSpace = usableSpaces.reduce((total, space) => total + space.end - space.start, 0); - let nextSection = 0; - const toolbar = usableSpaces.map((space, index) => { - const count = - index === usableSpaces.length - 1 - ? sectionNames.length - nextSection - : Math.min( - sectionNames.length - nextSection, - Math.max(1, Math.round((sectionNames.length * (space.end - space.start)) / totalSpace)), - ); - const items = sectionNames.slice(nextSection, nextSection + count); - nextSection += count; - return { ...space, items }; - }); + const toolbar = usableSpaces.reduce>( + (result, space, index) => { + const nextSection = result.reduce((count, group) => count + group.items.length, 0); + const count = + index === usableSpaces.length - 1 + ? sectionNames.length - nextSection + : Math.min( + sectionNames.length - nextSection, + Math.max(1, Math.round((sectionNames.length * (space.end - space.start)) / totalSpace)), + ); + return [...result, { ...space, items: sectionNames.slice(nextSection, nextSection + count) }]; + }, + [], + ); const [section, setSection] = useState
('Journal'); const entry = sections[section]; - const [tabLayouts, setTabLayouts] = useState>>({}); + const [saved, setSaved] = useState>>({}); + const controlRegion = occlusions.reduce<(typeof occlusions)[number] | undefined>( + (rightmost, region) => (!rightmost || region.frame.x > rightmost.frame.x ? region : rightmost), + undefined, + ); + const controlCenter = controlRegion ? controlRegion.frame.x + controlRegion.frame.width / 2 : viewportWidth - 38; + const controlsTop = + occlusions.reduce( + (bottom, { frame }) => + frame.x < controlCenter + 26 && frame.x + frame.width > controlCenter - 26 + ? Math.max(bottom, frame.y + frame.height) + : bottom, + headerHeight, + ) + 16; + const toolbarKey = JSON.stringify([headerHeight, toolbar]); + const [tabMeasurements, setTabMeasurements] = useState<{ + key: string; + layouts: Partial>; + }>({ key: toolbarKey, layouts: {} }); + const tabLayouts = tabMeasurements.key === toolbarKey ? tabMeasurements.layouts : {}; const selectedLayout = tabLayouts[section]; + const previousSelection = useRef({ section, toolbarKey }); const reducedMotion = useReducedMotion(); const selection = useSharedValue({ x: 0, y: 0, width: 0, height: 0 }); - useEffect(() => { + useLayoutEffect(() => { if (!selectedLayout) return; + const previous = previousSelection.current; + const animate = !reducedMotion && previous.toolbarKey === toolbarKey && previous.section !== section; selection.set( - reducedMotion || selection.get().width === 0 - ? selectedLayout - : withTiming(selectedLayout, { duration: 250, easing: Easing.bezier(0.77, 0, 0.175, 1) }), + animate + ? withTiming(selectedLayout, { duration: 250, easing: Easing.bezier(0.77, 0, 0.175, 1) }) + : selectedLayout, ); - }, [reducedMotion, selectedLayout, selection]); + previousSelection.current = { section, toolbarKey }; + }, [previousSelection, reducedMotion, section, selectedLayout, selection, toolbarKey]); const selectionStyle = useAnimatedStyle(() => { const { x, y, width, height } = selection.get(); return { width, height, transform: [{ translateX: x }, { translateY: y }] }; }); const [preview, setPreview] = useState(false); - const [controlsVisible, setControlsVisible] = useState(true); + const [controlsVisible, setControlsVisible] = useState(false); const [width, setWidth] = useState(0); return ( <> { setControlsVisible(!controlsVisible); setPreview(false); @@ -138,18 +165,12 @@ function FieldNotesContent({ viewportWidth, onOpenLab }: { viewportWidth: number {entry.title} {entry.subtitle} - {ready && occlusions.length > 0 && ( - RESERVED SPACE · CONTROLS CLEAR - )} - setWidth(event.nativeEvent.layout.width)} style={styles.spread}> + setWidth(event.nativeEvent.layout.width)}> - - - Open a little. Let the light in. Your journal follows the fold, with room for every word. - + {controlsVisible && } {controlsVisible && ( )} - react-native-hinges + react-native-reserved-regions + {Platform.OS === 'ios' && ready && controlRegion && ( + + + setSaved((previous) => ({ ...previous, [section]: !previous[section] }))} + style={styles.glassButton} + > + {saved[section] ? '✓' : '+'} + + + + + setSection( + sectionNames[(sectionNames.indexOf(section) + sectionNames.length - 1) % sectionNames.length]!, + ) + } + style={styles.glassButton} + > + + + setSection(sectionNames[(sectionNames.indexOf(section) + 1) % sectionNames.length]!)} + style={styles.glassButton} + > + + + + + )} + {ready && toolbar.map((space, index) => ( - + {selectedLayout && ( + + )} {index === 0 && space.end - space.start >= 148 + space.items.length * 84 && ( FIELD NOTES @@ -184,35 +259,32 @@ function FieldNotesContent({ viewportWidth, onOpenLab }: { viewportWidth: number key={name} accessibilityRole="tab" accessibilityState={{ selected: section === name }} - style={styles.tab} + style={[styles.tab, !selectedLayout && section === name && styles.selectedTab]} onLayout={({ nativeEvent: { layout } }) => { const next = { ...layout, x: space.start + layout.x }; - setTabLayouts((previous) => { - const current = previous[name]; + setTabMeasurements((previous) => { + const layouts = previous.key === toolbarKey ? previous.layouts : {}; + const current = layouts[name]; return current?.x === next.x && current.y === next.y && current.width === next.width && current.height === next.height ? previous - : { ...previous, [name]: next }; + : { key: toolbarKey, layouts: { ...layouts, [name]: next } }; }); }} onPress={() => setSection(name)} > - + ))} ))} - {occlusions.map(({ frame }, index) => ( - - ))} ); } @@ -221,16 +293,20 @@ function TabLabel({ name, layout, selection, + selected, }: { name: Section; layout: LayoutRectangle | undefined; selection: SharedValue; + selected: boolean; }) { const style = useAnimatedStyle(() => { const { x, width } = selection.get(); const overlap = layout ? Math.max(0, Math.min(x + width, layout.x + layout.width) - Math.max(x, layout.x)) / layout.width - : 0; + : selected + ? 1 + : 0; return { color: interpolateColor(overlap, [0, 1], ['#c0cdb8', '#284031']) }; }); return ( @@ -273,9 +349,36 @@ function Spread({ width, preview, section }: { width: number; preview: boolean; region.frame.x + region.frame.width < width, ); const seam = division?.frame; - const leftWidth = Math.max(0, (seam?.x ?? width / 2) - (seam ? 12 : 2)); + const stacked = width < 500 && !seam; + const leftWidth = stacked ? width : Math.max(0, (seam?.x ?? width / 2) - (seam ? 12 : 2)); const rightX = seam ? seam.x + seam.width + 12 : width / 2 + 2; + const pageLayout = useSharedValue({ + leftWidth, + rightX: stacked ? 0 : rightX, + rightWidth: stacked ? width : Math.max(0, width - rightX), + rightTop: stacked ? 364 : 0, + }); + const previousLayout = useRef({ width, stacked }); + useLayoutEffect(() => { + const previous = previousLayout.current; + const animate = !reducedMotion && previous.width === width && previous.stacked === stacked; + const next = { + leftWidth, + rightX: stacked ? 0 : rightX, + rightWidth: stacked ? width : Math.max(0, width - rightX), + rightTop: stacked ? 364 : 0, + }; + pageLayout.set(animate ? withTiming(next, { duration: 220 }) : next); + previousLayout.current = { width, stacked }; + }, [leftWidth, pageLayout, reducedMotion, rightX, stacked, width]); + const leftPageStyle = useAnimatedStyle(() => ({ width: pageLayout.get().leftWidth })); + const rightPageStyle = useAnimatedStyle(() => ({ + left: pageLayout.get().rightX, + top: pageLayout.get().rightTop, + width: pageLayout.get().rightWidth, + })); + useEffect(() => { if (preview && !reducedMotion) { previewAngle.set(Math.PI / 3); @@ -286,53 +389,45 @@ function Spread({ width, preview, section }: { width: number; preview: boolean; } return () => cancelAnimation(previewAngle); }, [preview, previewAngle, reducedMotion]); - const angle = useDerivedValue(() => - Math.max(0, Math.min(Math.PI, preview ? previewAngle.get() : (hinges.get()[0]?.angle ?? Math.PI))), - ); + const angle = useDerivedValue(() => { + const target = Math.max(0, Math.min(Math.PI, preview ? previewAngle.get() : (hinges.get()[0]?.angle ?? Math.PI))); + return Platform.OS === 'android' && !preview && !reducedMotion + ? withTiming(target, { duration: 120, easing: Easing.linear }) + : target; + }); + const colorAngle = useDerivedValue(() => { + const progress = angle.get() / Math.PI; + return progress * progress * (3 - 2 * progress) * Math.PI; + }); const landscapeStyle = useAnimatedStyle(() => ({ - backgroundColor: interpolateColor(angle.get(), [0, Math.PI / 2, Math.PI], ['#302b50', '#d18a80', '#abc9ba']), + backgroundColor: interpolateColor(colorAngle.get(), [0, Math.PI / 2, Math.PI], ['#302b50', '#d18a80', '#abc9ba']), })); const mountainFarStyle = useAnimatedStyle(() => ({ - backgroundColor: interpolateColor(angle.get(), [0, Math.PI / 2, Math.PI], ['#504366', '#a66e80', '#6b9380']), + backgroundColor: interpolateColor(colorAngle.get(), [0, Math.PI / 2, Math.PI], ['#504366', '#a66e80', '#6b9380']), })); const mountainNearStyle = useAnimatedStyle(() => ({ - backgroundColor: interpolateColor(angle.get(), [0, Math.PI / 2, Math.PI], ['#332c49', '#704858', '#345d4d']), + backgroundColor: interpolateColor(colorAngle.get(), [0, Math.PI / 2, Math.PI], ['#332c49', '#704858', '#345d4d']), })); const lakeStyle = useAnimatedStyle(() => ({ - backgroundColor: interpolateColor(angle.get(), [0, Math.PI / 2, Math.PI], ['#29263f', '#553f53', '#24473f']), + backgroundColor: interpolateColor(colorAngle.get(), [0, Math.PI / 2, Math.PI], ['#29263f', '#553f53', '#24473f']), })); const journalStyle = useAnimatedStyle(() => ({ - backgroundColor: interpolateColor(angle.get(), [0, Math.PI], ['#b2ac92', '#f2ebd7']), + backgroundColor: interpolateColor(colorAngle.get(), [0, Math.PI], ['#b2ac92', '#f2ebd7']), })); const detailsStyle = useAnimatedStyle(() => { - const opacity = Math.max(0, Math.min(1, (angle.get() - (Math.PI * 4) / 9) / ((Math.PI * 4) / 9))); + const opacity = stacked ? 1 : Math.max(0, Math.min(1, (angle.get() - (Math.PI * 4) / 9) / ((Math.PI * 4) / 9))); return { opacity, transform: [{ translateX: reducedMotion ? 0 : (1 - opacity) * 64 }] }; }); const sunStyle = useAnimatedStyle(() => ({ - backgroundColor: interpolateColor(angle.get(), [0, Math.PI / 2, Math.PI], ['#df7968', '#f4bd7e', '#f5edcb']), + backgroundColor: interpolateColor(colorAngle.get(), [0, Math.PI / 2, Math.PI], ['#df7968', '#f4bd7e', '#f5edcb']), transform: [{ translateY: reducedMotion ? 0 : (1 - angle.get() / Math.PI) * 50 }], opacity: 0.45 + (angle.get() / Math.PI) * 0.55, })); return ( - <> + {width > 0 && regionsReady && ( <> - {seam && ( - - - - )} - + A PLACE TO EXHALE @@ -345,8 +440,8 @@ function Spread({ width, preview, section }: { width: number; preview: boolean; SAGUENAY / QUÉBEC - - {entry.label} + + {entry.label} {entry.number} {entry.heading} @@ -360,21 +455,27 @@ function Spread({ width, preview, section }: { width: number; preview: boolean; )} - - - {!regionsReady - ? 'MEASURING REGIONS' - : division - ? 'TWO PAGES · NATIVE FOLD' - : 'REGIONS READY · NO PAGE DIVISION'} - - - + ); } const styles = StyleSheet.create({ screen: { flex: 1, backgroundColor: '#172521' }, + floatingControls: { position: 'absolute', gap: 12 }, + glassControl: { width: 52, borderRadius: 26 }, + glassFallback: { backgroundColor: '#273a32ee', borderWidth: 1, borderColor: '#ffffff30' }, + glassButton: { width: 52, height: 52, alignItems: 'center', justifyContent: 'center' }, + saveGlyph: { color: '#f0f2e8', fontSize: 28, fontWeight: '300' }, + chevron: { width: 12, height: 12, borderTopWidth: 2, borderRightWidth: 2, borderColor: '#f0f2e8' }, + headerBackground: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + backgroundColor: '#172521', + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: '#829c8d40', + }, header: { position: 'absolute', top: 0, @@ -382,10 +483,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - backgroundColor: '#172521', gap: 8, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#829c8d40', }, tab: { flex: 1, @@ -396,25 +494,15 @@ const styles = StyleSheet.create({ borderRadius: 10, paddingHorizontal: 8, }, + selectedTab: { backgroundColor: '#edc278' }, selection: { position: 'absolute', top: 0, borderRadius: 10, backgroundColor: '#edc278' }, tabText: { color: '#c0cdb8', fontSize: 13, fontWeight: '600' }, - occlusionOutline: { - position: 'absolute', - borderWidth: 2, - borderColor: '#edc278', - backgroundColor: '#edc27815', - boxShadow: '0 0 18px #edc27880', - }, - occlusionCaption: { color: '#edc278', fontSize: 8, letterSpacing: 1.5, marginTop: 14 }, row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }, brand: { marginRight: 16, flexShrink: 1, color: '#e9eddb', fontSize: 12, letterSpacing: 3, fontWeight: '700' }, introduction: { gap: 12 }, edition: { color: '#829c8d', fontSize: 8, letterSpacing: 1.2 }, heading: { color: '#eef0e3', fontSize: 37, fontWeight: '500', letterSpacing: -1.8 }, subtitle: { color: '#a6b7a6', fontSize: 13, lineHeight: 20, marginTop: 8 }, - spread: { height: 376 }, - divisionGuide: { position: 'absolute', left: 0, right: 0, top: 0, height: 344, overflow: 'hidden' }, - divisionMarker: { position: 'absolute', backgroundColor: '#c3e1a0', opacity: 0.35 }, page: { position: 'absolute', top: 0, height: 344, overflow: 'hidden', borderRadius: 12, padding: 17 }, landscape: { left: 0 }, landscapeArt: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, transform: [{ scaleX: -1 }] }, @@ -459,15 +547,12 @@ const styles = StyleSheet.create({ journalBody: { color: '#53634c', fontSize: 12, lineHeight: 18 }, journalFooter: { position: 'absolute', bottom: 17, left: 17, right: 17, gap: 4 }, arrow: { color: '#294133', fontSize: 26 }, - regionCaption: { position: 'absolute', left: 0, right: 0, bottom: 0, alignItems: 'center' }, - regionText: { color: '#829c8d', fontSize: 8, letterSpacing: 1.5 }, badge: { flexDirection: 'row', alignItems: 'center', gap: 7 }, dot: { width: 5, height: 5, borderRadius: 3, backgroundColor: '#c3e1a0' }, previewDot: { backgroundColor: '#edc278' }, unavailableDot: { backgroundColor: '#819580' }, badgeText: { color: '#c3d2b9', fontSize: 9, letterSpacing: 1.3 }, volume: { color: '#819580', fontSize: 10, letterSpacing: 1.4 }, - description: { color: '#c0cdb8', fontSize: 15, lineHeight: 23, maxWidth: 360 }, button: { minHeight: 44, borderRadius: 24, paddingHorizontal: 18, paddingVertical: 13, backgroundColor: '#d1e5b2' }, buttonText: { color: '#284031', fontSize: 12, fontWeight: '600' }, labButton: { @@ -479,5 +564,4 @@ const styles = StyleSheet.create({ backgroundColor: '#edc278', }, labText: { color: '#284031', fontSize: 12, fontWeight: '600' }, - credit: { color: '#758b77', fontSize: 9, lineHeight: 15 }, }); diff --git a/ios/HingesModule.h b/ios/HingesModule.h index 9888cc8..1905fa5 100644 --- a/ios/HingesModule.h +++ b/ios/HingesModule.h @@ -1,9 +1,10 @@ #import #import +#import NS_ASSUME_NONNULL_BEGIN -@interface HingesModule : NativeHingesSpecBase +@interface HingesModule : NativeHingesSpecBase @end NS_ASSUME_NONNULL_END diff --git a/ios/HingesModule.mm b/ios/HingesModule.mm index cdb74c1..92b0bd3 100644 --- a/ios/HingesModule.mm +++ b/ios/HingesModule.mm @@ -1,6 +1,7 @@ #import "HingesModule.h" #import "HingeInteraction.h" +#include "../cpp/HingesWorklets.h" #import #import @@ -23,6 +24,7 @@ @implementation HingesModule { NSMutableDictionary *_observations; NSMutableDictionary *> *_snapshots; std::atomic _invalidated; + std::shared_ptr _worklets; __weak RCTSurfacePresenter *_surfacePresenter; } @@ -42,10 +44,20 @@ - (instancetype)init _observations = [NSMutableDictionary new]; _snapshots = [NSMutableDictionary new]; _invalidated = false; + _worklets = std::make_shared(); } return self; } +- (void)installJSIBindingsWithRuntime:(facebook::jsi::Runtime &)runtime + callInvoker:(const std::shared_ptr &)callInvoker +{ + __weak HingesModule *weakSelf = self; + _worklets->install(runtime, + [weakSelf](int rootTag) { [weakSelf startObserving:rootTag]; }, + [weakSelf](int rootTag) { [weakSelf stopObserving:rootTag]; }); +} + - (void)setSurfacePresenter:(RCTSurfacePresenter *)surfacePresenter { _surfacePresenter = surfacePresenter; @@ -61,6 +73,12 @@ - (NSDictionary *)getSnapshot:(double)rootTag - (void)emitSnapshotForRoot:(NSNumber *)rootTag hinges:(NSArray *)hinges { if (_invalidated) return; + std::vector readings; + readings.reserve(hinges.count); + for (NSDictionary *hinge in hinges) { + readings.push_back({[hinge[@"status"] UTF8String], [hinge[@"angle"] doubleValue], [hinge[@"hasAngle"] boolValue]}); + } + _worklets->publish(rootTag.intValue, readings); [self emitOnHingesChange:@{@"rootTag": rootTag, @"hinges": hinges}]; } @@ -130,6 +148,7 @@ - (void)stopObserving:(double)rootTag - (void)invalidate { _invalidated = true; + _worklets->invalidate(); RCTExecuteOnMainQueue(^{ for (HingeRootObservation *observation in self->_observations.allValues) { if (observation.interaction != nil) { diff --git a/ios/HingesObserverView.h b/ios/HingesObserverView.h deleted file mode 100644 index f600630..0000000 --- a/ios/HingesObserverView.h +++ /dev/null @@ -1,8 +0,0 @@ -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface HingesObserverView : RCTViewComponentView -@end - -NS_ASSUME_NONNULL_END diff --git a/ios/HingesObserverView.mm b/ios/HingesObserverView.mm deleted file mode 100644 index 82649d2..0000000 --- a/ios/HingesObserverView.mm +++ /dev/null @@ -1,78 +0,0 @@ -#import "HingesObserverView.h" - -#import "HingeInteraction.h" - -#import -#import -#import -#import -#import - -using namespace facebook::react; - -@interface HingesObserverView () -- (void)emitSnapshot; -@end - -@implementation HingesObserverView { - HingesObserverViewEventEmitter::OnHingesChange _snapshot; -} - -+ (ComponentDescriptorProvider)componentDescriptorProvider -{ - return concreteComponentDescriptorProvider(); -} - -- (instancetype)initWithFrame:(CGRect)frame -{ - if (self = [super initWithFrame:frame]) { - static const auto defaultProps = std::make_shared(); - _props = defaultProps; - __weak HingesObserverView *weakSelf = self; - id interaction = HingesMakeInteraction(^(NSArray *hinges) { - HingesObserverView *strongSelf = weakSelf; - if (strongSelf == nil) return; - HingesObserverViewEventEmitter::OnHingesChange snapshot; - for (NSDictionary *hinge in hinges) { - snapshot.hinges.push_back( - {[hinge[@"status"] UTF8String], [hinge[@"angle"] doubleValue], [hinge[@"hasAngle"] boolValue]}); - } - strongSelf->_snapshot = std::move(snapshot); - [strongSelf emitSnapshot]; - }); - if (interaction != nil) { - [self addInteraction:interaction]; - } - } - return self; -} - -- (void)emitSnapshot -{ - if (_eventEmitter == nullptr) return; - std::static_pointer_cast(_eventEmitter)->onHingesChange(_snapshot); -} - -- (void)updateEventEmitter:(const EventEmitter::Shared &)eventEmitter -{ - [super updateEventEmitter:eventEmitter]; - [self emitSnapshot]; -} - -- (void)refresh -{ - [self emitSnapshot]; -} - -- (void)handleCommand:(const NSString *)commandName args:(const NSArray *)args -{ - RCTHingesObserverViewHandleCommand(self, commandName, args); -} - -- (void)prepareForRecycle -{ - _snapshot = {}; - [super prepareForRecycle]; -} - -@end diff --git a/package.json b/package.json index 6ecb6cc..4e60332 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,7 @@ "packageManager": "pnpm@12.5.1", "codegenConfig": { "name": "HingesSpec", - "type": "all", + "type": "modules", "jsSrcsDir": "src", "android": { "javaPackageName": "com.appandflow.hinges" @@ -133,11 +133,6 @@ "NativeHinges": { "className": "HingesModule" } - }, - "components": { - "HingesObserverView": { - "className": "HingesObserverView" - } } } }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0be56f7..1cfc026 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -220,6 +220,9 @@ importers: example: dependencies: + '@callstack/liquid-glass': + specifier: 0.8.2 + version: 0.8.2(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native-community/cli@20.2.0(supports-color@8.1.1)(typescript@7.0.2))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0) react: specifier: 19.3.0 version: 19.3.0 @@ -1092,6 +1095,12 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@callstack/liquid-glass@0.8.2': + resolution: {integrity: sha512-Z3bhZLkQLBquA7LIx6Ktv/fFd8nuOsAo1vb/S6Y3tqaQVnHu9RNFreidfZ+SUYnrQu5TqVzEOqq+9CR4jrwd9A==} + peerDependencies: + react: '*' + react-native: '*' + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -8234,6 +8243,11 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@callstack/liquid-glass@0.8.2(react-native@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native-community/cli@20.2.0(supports-color@8.1.1)(typescript@7.0.2))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1))(react@19.3.0)': + dependencies: + react: 19.3.0 + react-native: 0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(@react-native-community/cli@20.2.0(supports-color@8.1.1)(typescript@7.0.2))(@react-native/metro-config@0.88.0-rc.1(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1))(@types/react@19.3.0)(react@19.3.0)(supports-color@8.1.1) + '@colors/colors@1.5.0': optional: true diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs index 9cf3bee..23ca011 100644 --- a/scripts/check-package.mjs +++ b/scripts/check-package.mjs @@ -19,6 +19,10 @@ for (const required of [ 'Hinges.podspec', 'ios/HingesModule.mm', 'android/build.gradle', + 'cpp/HingesWorklets.h', + 'cpp/HingesWorklets.cpp', + 'android/src/main/cpp/CMakeLists.txt', + 'android/src/main/cpp/HingesWorkletsModule.cpp', ]) { if (!entries.includes(`package/${required}`)) throw new Error(`Missing package file: ${required}`); } diff --git a/src/HingesObserverViewNativeComponent.ts b/src/HingesObserverViewNativeComponent.ts deleted file mode 100644 index 543e4c0..0000000 --- a/src/HingesObserverViewNativeComponent.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type * as React from 'react'; -import { - codegenNativeCommands, - codegenNativeComponent, - type CodegenTypes, - type HostComponent, - type ViewProps, -} from 'react-native'; - -export type HingesObserverChangeEvent = Readonly<{ - hinges: { status: string; angle: CodegenTypes.Double; hasAngle: boolean }[]; -}>; - -export interface NativeProps extends ViewProps { - onHingesChange?: CodegenTypes.DirectEventHandler; -} - -export interface NativeCommands { - /** Re-emits the view's current hinge snapshot so a late listener observes it. */ - refresh: (viewRef: React.ComponentRef>) => void; -} - -export const Commands: NativeCommands = codegenNativeCommands({ - supportedCommands: ['refresh'], -}); - -export default codegenNativeComponent('HingesObserverView'); diff --git a/src/__tests__/reanimated.test.tsx b/src/__tests__/reanimated.test.tsx index c5af706..c9dbe71 100644 --- a/src/__tests__/reanimated.test.tsx +++ b/src/__tests__/reanimated.test.tsx @@ -1,68 +1,62 @@ import * as React from 'react'; import { beforeEach, expect, it, jest } from '@jest/globals'; import { act, create } from 'react-test-renderer'; +import { RootTagContext, type RootTag } from 'react-native'; import type { SharedValue } from 'react-native-reanimated'; -import { AnimatedHingesProvider, useAnimatedHinges } from '../reanimated'; +import { useAnimatedHinges } from '../reanimated'; import type { Hinge } from '../index'; -import { Commands, type HingesObserverChangeEvent } from '../HingesObserverViewNativeComponent'; +import type { NativeHinge } from '../NativeHinges'; -const mockUIQueue: (() => void)[] = []; -const mockRNQueue: (() => void)[] = []; jest.mock('react-native-worklets', () => ({ - scheduleOnUI: (callback: () => void) => mockUIQueue.push(callback), - scheduleOnRN: (callback: () => void) => mockRNQueue.push(callback), + createSerializable: (callback: unknown) => callback, + getUIRuntimeHolder: () => ({}), + getUISchedulerHolder: () => ({}), })); -function flushSetup() { - while (mockUIQueue.length) mockUIQueue.shift()?.(); - while (mockRNQueue.length) mockRNQueue.shift()?.(); -} - -const mockWorkletHandlers = new Set<(event: HingesObserverChangeEvent) => void>(); -jest.mock('react-native-reanimated', () => { - const react = jest.requireActual('react'); - return { - __esModule: true, - createAnimatedComponent: (component: unknown) => component, - useSharedValue: (initial: T) => - react.useMemo(() => { - let current = initial; - return { - get: () => current, - set: (value: T) => { - current = value; - }, - }; - }, []), - useEvent: (handler: (event: HingesObserverChangeEvent) => void) => - react.useMemo(() => { - mockWorkletHandlers.add(handler); - return handler; - }, []), - }; -}); -jest.mock('../HingesModule', () => ({ __esModule: true, default: {} })); -jest.mock('../HingesObserverViewNativeComponent', () => ({ +jest.mock('react-native-reanimated', () => ({ + makeMutable: (initial: T) => { + let current = initial; + return { + get: () => current, + set: (value: T) => { + current = value; + }, + }; + }, +})); +const mockSnapshots = new Map(); +jest.mock('../HingesModule', () => ({ __esModule: true, - default: 'HingesObserverView', - Commands: { refresh: jest.fn() }, + default: { getSnapshot: (root: number) => ({ hinges: mockSnapshots.get(root) ?? [] }) }, })); - -const mockNativeView = { id: 'hinges-observer-view' }; -function render(element: React.ReactElement) { - return create(element, { createNodeMock: () => mockNativeView }); +const listeners = new Map void>>(); +const unsubscribe = jest.fn(); +const native = [{ status: 'partiallyOpen', angle: Math.PI / 2, hasAngle: true }]; +function emit(root: number, readings: readonly NativeHinge[]) { + mockSnapshots.set(root, readings); + listeners.get(root)?.forEach((callback) => callback(readings)); } -function emit(hinges: HingesObserverChangeEvent['hinges']) { - for (const handler of mockWorkletHandlers) handler({ hinges }); +function tree(root: number, child: React.ReactNode) { + return {child}; } - beforeEach(() => { - mockWorkletHandlers.clear(); - mockUIQueue.length = 0; - mockRNQueue.length = 0; - jest.clearAllMocks(); + listeners.clear(); + mockSnapshots.clear(); + unsubscribe.mockClear(); + globalThis.nativeHingesSubscribe = (root, _runtime, _scheduler, callback) => { + const listener = callback as unknown as (readings: readonly NativeHinge[]) => void; + const callbacks = listeners.get(root) ?? new Set(); + listeners.set(root, callbacks); + callbacks.add(listener); + listener(mockSnapshots.get(root) ?? []); + return () => { + callbacks.delete(listener); + unsubscribe(root); + }; + }; }); -it('seeds an empty shared value, then updates it from native events without a React render', async () => { +it('seeds the native cache and applies readings without a provider or React render', async () => { + mockSnapshots.set(1, native); let value: SharedValue | undefined; let renders = 0; function Consumer() { @@ -73,58 +67,80 @@ it('seeds an empty shared value, then updates it from native events without a Re }); return null; } + let renderer: ReturnType; await act(() => { - render( - - - , - ); + renderer = create(tree(1, )); }); - expect(value?.get()).toEqual([]); - - const previousRenders = renders; - emit([{ status: 'partiallyOpen', angle: Math.PI / 2, hasAngle: true }]); expect(value?.get()).toEqual([{ status: 'partiallyOpen', angle: Math.PI / 2 }]); - emit([{ status: 'future', angle: 123, hasAngle: false }]); + const previousRenders = renders; + emit(2, [{ status: 'closed', angle: 0, hasAngle: true }]); + expect(value?.get()[0]?.angle).toBe(Math.PI / 2); + emit(1, [{ status: 'future', angle: 123, hasAngle: false }]); expect(value?.get()).toEqual([{ status: 'unknown', angle: null }]); expect(renders).toBe(previousRenders); + await act(() => renderer.unmount()); + emit(1, native); + expect(value?.get()).toEqual([{ status: 'unknown', angle: null }]); + expect(unsubscribe).toHaveBeenCalledWith(1); }); -it('requests the current snapshot only after the worklet registration hop', async () => { - await act(() => { - render(); - }); - expect(Commands.refresh).not.toHaveBeenCalled(); - await act(flushSetup); - expect(Commands.refresh).toHaveBeenCalledWith(mockNativeView); -}); - -it('does not request a snapshot when unmounted before the hop completes', async () => { +it('isolates consumers and replaces the shared value when their root changes', async () => { + let first: SharedValue | undefined; + let second: SharedValue | undefined; + function First() { + const hinges = useAnimatedHinges(); + React.useEffect(() => { + first = hinges; + }); + return null; + } + function Second() { + const hinges = useAnimatedHinges(); + React.useEffect(() => { + second = hinges; + }); + return null; + } let renderer: ReturnType; + const renderTree = (root: number) => ( + <> + {tree(root, )} + {tree(2, )} + + ); await act(() => { - renderer = render(); + renderer = create(renderTree(1)); }); + emit(1, native); + expect(first?.get()).toHaveLength(1); + expect(second?.get()).toEqual([]); + const previous = first; + await act(() => renderer.update(renderTree(3))); + expect(unsubscribe).toHaveBeenCalledWith(1); + expect(first).not.toBe(previous); + expect(first?.get()).toEqual([]); + emit(1, [{ status: 'closed', angle: 0, hasAngle: true }]); + expect(first?.get()).toEqual([]); + expect(previous?.get()[0]?.angle).toBe(Math.PI / 2); await act(() => renderer.unmount()); - await act(flushSetup); - expect(Commands.refresh).not.toHaveBeenCalled(); + expect(unsubscribe).toHaveBeenCalledWith(2); + expect(unsubscribe).toHaveBeenCalledWith(3); }); -function OrphanConsumer() { +function EmptyConsumer() { useAnimatedHinges(); return null; } -it('throws a clear error when useAnimatedHinges renders outside the provider', () => { +it('explains when the native app needs to be rebuilt with Worklets', () => { + globalThis.nativeHingesSubscribe = undefined; const error = jest.spyOn(console, 'error').mockImplementation(() => {}); try { expect(() => act(() => { - render(); + create(tree(1, )); }), - ).toThrow( - 'useAnimatedHinges must render inside an AnimatedHingesProvider from react-native-hinges/reanimated. ' + - 'Wrap the tree in at or above this component.', - ); + ).toThrow('Install react-native-worklets and rebuild the native app'); } finally { error.mockRestore(); } diff --git a/src/reanimated.tsx b/src/reanimated.tsx index cc93fd2..2692c44 100644 --- a/src/reanimated.tsx +++ b/src/reanimated.tsx @@ -1,79 +1,44 @@ -import { createContext, useContext, useEffect, useRef, type ComponentRef, type ReactNode } from 'react'; -import { StyleSheet } from 'react-native'; -import { scheduleOnRN, scheduleOnUI } from 'react-native-worklets'; -import { createAnimatedComponent, useEvent, useSharedValue, type SharedValue } from 'react-native-reanimated'; +import { useEffect, useMemo } from 'react'; +import { makeMutable, type SharedValue } from 'react-native-reanimated'; +import { createSerializable, getUIRuntimeHolder, getUISchedulerHolder } from 'react-native-worklets'; +import NativeHinges from './HingesModule'; import { mapHinges, type Hinge } from './HingeObserver'; -import HingesObserverView, { - Commands, - type HingesObserverChangeEvent, - type NativeProps, -} from './HingesObserverViewNativeComponent'; +import type { NativeHinge } from './NativeHinges'; +import { useRootTag } from './useRootTag'; -const HingesSharedValueContext = createContext | null>(null); -const AnimatedHingesObserverView = createAnimatedComponent(HingesObserverView); -const styles = StyleSheet.create({ - observer: { position: 'absolute', width: 0, height: 0 }, -}); - -/** - * Observes hinges for its subtree and publishes them to a Reanimated shared value. - * Renders one hidden native view that delivers native updates straight to the UI runtime. - * Read the value with useAnimatedHinges. - */ -export function AnimatedHingesProvider({ children }: { children?: ReactNode }) { - const hinges = useSharedValue([]); - const view = useRef | null>(null); - const onHingesChange = useEvent( - (event) => { - 'worklet'; - hinges.set(mapHinges(event.hinges)); - }, - ['onHingesChange'], - ); - - useEffect(() => { - let cancelled = false; - // Worklets only accepts a function defined on the React Native runtime in scheduleOnRN. - const requestSnapshot = () => { - if (cancelled || view.current === null) return; - Commands.refresh(view.current); - }; - // Reanimated registers worklet event handlers on the UI runtime asynchronously; ask for the - // current snapshot only after a hop through it, so the handler exists when the view re-emits. - scheduleOnUI(() => { - scheduleOnRN(requestSnapshot); - }); - return () => { - cancelled = true; - }; - }, []); - - return ( - - {children} - - - ); +declare global { + var nativeHingesSubscribe: + | ((rootTag: number, runtime: object, scheduler: object, callback: object) => () => void) + | undefined; } /** - * Returns this provider's native hinge snapshots as a shared value on the UI runtime. - * Read with get() inside worklets; do not write to the value. Angles are raw radians, - * with null for unavailable readings. No interpolation or sampling rate is imposed. + * Returns this React root's hinge readings as a shared value without a provider or native view. + * Seeds from the native cache, then receives native updates directly on the UI runtime. + * Angles are raw radians, with null for unavailable readings. Read with get() inside worklets; + * do not write to the value. Unmounting releases observation and retains the last snapshot. */ export function useAnimatedHinges(): SharedValue { - const hinges = useContext(HingesSharedValueContext); - if (hinges === null) { - throw new Error( - 'useAnimatedHinges must render inside an AnimatedHingesProvider from react-native-hinges/reanimated. ' + - 'Wrap the tree in at or above this component.', + const rootTag = useRootTag('useAnimatedHinges'); + const hinges = useMemo( + () => makeMutable(mapHinges(NativeHinges.getSnapshot(rootTag).hinges)), + [rootTag], + ); + useEffect(() => { + if (!globalThis.nativeHingesSubscribe) { + throw new Error( + 'Hinges Worklets integration is unavailable. Install react-native-worklets and rebuild the native app.', + ); + } + return globalThis.nativeHingesSubscribe( + rootTag, + getUIRuntimeHolder(), + getUISchedulerHolder(), + createSerializable((readings: readonly NativeHinge[]) => { + 'worklet'; + hinges.set(mapHinges(readings)); + }), ); - } + }, [rootTag, hinges]); return hinges; } diff --git a/website/docs/api.md b/website/docs/api.md index 017b47f..b0ad7bf 100644 --- a/website/docs/api.md +++ b/website/docs/api.md @@ -60,22 +60,14 @@ function createHingeObserver(rootTag: number | RootTag): HingeObserver; Creates an observer for an existing React root. Obtain its tag from React Native's `RootTagContext` or a native host integration. Creation reads the native cache once as a seed; `get()` returns the latest observed snapshot, and subscribing triggers a native replay of the current snapshot, with later updates arriving as native change events. The last native subscriber releases observation and the cache. See [observer usage](./observers.md). -## AnimatedHingesProvider - -```tsx -function AnimatedHingesProvider(props: { children?: ReactNode }): ReactNode; -``` - -Provides one shared value to its subtree and renders one hidden native `HingesObserverView` that observes hinges and emits changes to Reanimated's UI runtime. Requires the optional Reanimated and Worklets peers. Place it above every `useAnimatedHinges()` consumer; one provider per tree is enough. - -The view is `0x0`, absolutely positioned and not interactive, so it does not affect layout. Unmounting the provider removes it and ends that observation. - ## useAnimatedHinges ```ts function useAnimatedHinges(): SharedValue; ``` -Returns the enclosing provider's shared value, seeded with `[]` and updated on the UI runtime when the native view reports a change. Read with `get()` inside worklets and do not mutate it. Throws when rendered outside `AnimatedHingesProvider`. +Returns this React root's hinge readings as a shared value. No provider or additional native view is needed. It seeds from the native cache or `[]` before the first reading, then updates directly on the UI runtime through Worklets. Read with `get()` inside worklets and do not mutate it. Requires the optional Reanimated and Worklets peers and a valid React Native root. + +Each hook owns its shared value; consumers of the same root share native observation. Unmounting releases the subscription. An externally retained shared value keeps its last snapshot. See [Reanimated integration](./reanimated.md). -Every consumer under one provider reads the same shared value. An externally retained shared value keeps its last snapshot after unmount. See [Reanimated integration](./reanimated.md). +This provider-free API is unreleased. Published `0.1.0-alpha.2` still requires `AnimatedHingesProvider`. diff --git a/website/docs/example.md b/website/docs/example.md index d8ea2c0..762b286 100644 --- a/website/docs/example.md +++ b/website/docs/example.md @@ -84,7 +84,7 @@ and the animation separately: A sample's changes-per-second value is not animation FPS or a hardware sampling-rate guarantee. Use changing native input during a stall test. A preview animation continuing to move only verifies that generated animation. -The example includes Reanimated 4.7.0 and Worklets 0.13.0. Its root is wrapped in `AnimatedHingesProvider`, so both screens read one shared value. See [the optional integration](./reanimated.md) for how that value is fed. +The example includes Reanimated 4.7.0 and Worklets 0.13.0. Its screens call `useAnimatedHinges()` directly and share native observation for the React root. See [the optional integration](./reanimated.md) for how that value is fed. ## Native testing diff --git a/website/docs/platforms.md b/website/docs/platforms.md index 3050cb1..6e5f2ce 100644 --- a/website/docs/platforms.md +++ b/website/docs/platforms.md @@ -5,7 +5,7 @@ description: How UIKit, WindowManager, and hinge sensors map to the API. ## iOS -Hinge observation uses `UIHingeInteraction`. The core hook attaches it to the React root's view hierarchy; the [Reanimated provider](./reanimated.md) attaches its own to the hidden `HingesObserverView` it renders. A native update supplies zero or one `UIHinge`. UIKit's status maps to the public strings, and its angle is exposed in radians. +Hinge observation uses `UIHingeInteraction`. The core hook, explicit observers, and [Reanimated hook](./reanimated.md) share an interaction attached to the React root's view hierarchy. A native update supplies zero or one `UIHinge`. UIKit's status maps to the public strings, and its angle is exposed in radians. | UIKit status | Public status | | -------------- | --------------- | @@ -34,7 +34,7 @@ The host app must adopt the UIScene lifecycle; the example uses `UISceneDelegate ## Android -The library observes Jetpack WindowManager folding features for its Activity window and, on API 30 or later, the optional device-level `TYPE_HINGE_ANGLE` sensor for angle. The native module runs that observation for a React root; the [Reanimated provider](./reanimated.md) runs the same observation from its hidden `HingesObserverView` while that view is attached. The combined snapshot is associated with the observing hierarchy; the two native sources do not have identical scope. +The library observes Jetpack WindowManager folding features for its Activity window and, on API 30 or later, the optional device-level `TYPE_HINGE_ANGLE` sensor for angle. The native module shares that root-scoped observation across the core hook, explicit observers, and [Reanimated hook](./reanimated.md). The combined snapshot is associated with the observing hierarchy; the two native sources do not have identical scope. | WindowManager state | Public status | | --------------------- | --------------- | diff --git a/website/docs/reanimated.md b/website/docs/reanimated.md index 32deed8..8d9f5ba 100644 --- a/website/docs/reanimated.md +++ b/website/docs/reanimated.md @@ -17,13 +17,13 @@ pnpm add react-native-reanimated@^4.7.0 react-native-worklets@^0.13.0 For a React Native Community CLI app, add `react-native-worklets/plugin` last in the Babel plugins list, install iOS pods, and rebuild the native app. Preserve your existing presets and plugins. Follow the [official Reanimated setup instructions](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/getting-started/). -This integration is included in `0.1.0-alpha.2`. See [installation](./installation.md); the old `0.1.0-alpha.0` placeholder does not contain it. +The provider-free integration described here is unreleased. Published `0.1.0-alpha.2` uses `AnimatedHingesProvider`; its API changes when upgrading to this version. See [installation](./installation.md). ## Use the hook ```tsx import Animated, { useAnimatedStyle } from 'react-native-reanimated'; -import { AnimatedHingesProvider, useAnimatedHinges } from 'react-native-hinges/reanimated'; +import { useAnimatedHinges } from 'react-native-hinges/reanimated'; function HingeCard() { const hinges = useAnimatedHinges(); @@ -39,19 +39,15 @@ function HingeCard() { } export default function App() { - return ( - - - - ); + return ; } ``` The example reads the first hinge and hides the card when its angle is unavailable. An app supporting several hinges must choose the appropriate observation; array order is not a stable hardware identity. -`AnimatedHingesProvider` is required. It holds the shared value and renders the native view that feeds it, so `useAnimatedHinges()` throws when there is no provider above it. Every consumer under one provider reads the same shared value. The regular hook and explicit observers keep their own root-scoped native observation and do not need the provider. +No provider or additional native view is needed. The hook reads `RootTagContext` to observe the calling React root. Each consumer owns a shared value, while all consumers of that root share native observation with the regular hook and explicit observers. -The provider is independent of `RootTagContext`, so a test can render it without an `AppContainer`. +Tests that render the hook outside React Native's `AppContainer` must supply a valid `RootTagContext`, just as for `useHinges()`. ## Shared-value contract @@ -59,7 +55,7 @@ The provider is independent of `RootTagContext`, so a test can render it without function useAnimatedHinges(): SharedValue; ``` -The shared value starts at `[]` and receives the provider view's first snapshot once it is mounted. Hinge status and raw radians have the same meaning as the ordinary API; unavailable angles remain `null`. +The shared value is seeded from the root's native cache, or `[]` if no reading exists. Subscription installs the worklet callback before starting observation and replays the latest native snapshot. Hinge status and raw radians have the same meaning as the ordinary API; unavailable angles remain `null`. Read it with `get()` inside a worklet such as `useAnimatedStyle`. Treat the value as read-only even though the underlying Reanimated type exposes setters. Reading a shared value during React rendering is not supported; use the regular `useHinges()` hook for rendered text. See [Reanimated's shared-value guidance](https://docs.swmansion.com/react-native-reanimated/docs/core/useSharedValue/). @@ -85,12 +81,14 @@ validate it themselves. ## How updates are delivered -`AnimatedHingesProvider` renders one `HingesObserverView`, a Fabric component the library ships. The view is `0x0`, absolutely positioned and not interactive. On iOS it holds a `UIHingeInteraction`; on Android it observes the Activity window's folding features and the default hinge-angle sensor while attached. Each change is emitted as the component's own `onHingesChange` direct event, which Reanimated's `useEvent` handler receives on the UI runtime. This is the ordinary public path for a custom Fabric view event, so no private React Native or Reanimated type is involved. +React Native's TurboModule JSI bindings register a serialized worklet and the UI runtime/scheduler holders. The library uses Worklets' stable C++ API to call that worklet from native hinge callbacks on the UI thread. No Fabric observer view, custom view event, or JavaScript-thread relay is involved. -Reanimated registers a worklet event handler on the UI runtime asynchronously. After mount the provider therefore hops through `scheduleOnUI` and back with `scheduleOnRN` before sending the view's `refresh` command, which makes the view re-emit its current snapshot for the now-registered handler. Without that hop the first snapshot can be emitted before anything is listening. +On iOS, `UIHingeInteraction` attaches to the calling React root. On Android, the observer resolves that root's Activity and combines WindowManager folding features with the default hinge-angle sensor. The native observation is shared with ordinary `useHinges()` and `createHingeObserver()` subscriptions for the same root. + +Worklets is an optional native dependency, detected during pod installation and Gradle configuration. Installing it after building the app requires a native rebuild. The core hook and observer continue to work without it. ## Lifetime and validation limits -Unmounting the provider removes the native view, which ends its observation. An externally retained shared value keeps its last snapshot. +Unmounting unsubscribes the callback. Queued deliveries for that subscription are ignored. Changing React roots creates a new shared value and releases the old root's subscription. The final subscriber releases native observation and its cache. An externally retained shared value keeps its last snapshot. -The Android path has been tested on a `pixel_9_pro_fold` emulator, driving `hinge-angle0` from 45 to 180 degrees and reading the angle and status back from `useAnimatedHinges()`. The iOS path has been tested on an iPhone Duo simulator, which reports only a static closed hinge. Changing iOS angles, physical hardware, and first-frame availability are not established. A simulated-preview animation does not verify sensor delivery. +The Android emulator check delivered 18 changing native readings to the UI runtime during a five-second JS stall, while an ordinary hook observed the same root. The iOS 27.1 Duo check confirmed the initial reading through both hooks; changing-angle delivery during a JS stall has not yet been verified there. Physical hardware, multiple-hinge devices, and first-frame availability require separate validation. From d9932e84fe2f607e27dc99ba6fd3c2ba83bad12b Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Tue, 22 Sep 2026 08:38:08 -0400 Subject: [PATCH 4/4] chore: prepare alpha.3 release --- README.md | 8 ++--- RELEASE.md | 13 +++++++++ docs/releases/0.1.0-alpha.3.md | 53 ++++++++++++++++++++++++++++++++++ example/ios/Podfile.lock | 4 +-- package.json | 2 +- website/docs/api.md | 2 +- website/docs/installation.md | 4 +-- website/docs/reanimated.md | 2 +- 8 files changed, 77 insertions(+), 11 deletions(-) create mode 100644 docs/releases/0.1.0-alpha.3.md diff --git a/README.md b/README.md index 99bf63d..832af94 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ Hinge posture and angle observations for React Native's New Architecture. ## Installation -Install the functional `0.1.0-alpha.2` release: +Install the `0.1.0-alpha.3` prerelease: ```sh -npm install react-native-hinges@0.1.0-alpha.2 +npm install react-native-hinges@0.1.0-alpha.3 ``` Install iOS pods and rebuild your native app. This is an early alpha; see the @@ -92,8 +92,8 @@ the Worklets Babel plugin and rebuild native dependencies using [Reanimated's setup guide](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/getting-started/). Call `useAnimatedHinges()` inside a React Native root. It needs no provider or -additional native view. This provider-free integration is unreleased; the -published `0.1.0-alpha.2` still uses `AnimatedHingesProvider`. +additional native view. Starting with `0.1.0-alpha.3`, remove the +`AnimatedHingesProvider` wrapper used by `0.1.0-alpha.2` and rebuild the native app. ```tsx import { useAnimatedHinges } from 'react-native-hinges/reanimated'; diff --git a/RELEASE.md b/RELEASE.md index ebdfd71..7c1443d 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -74,6 +74,19 @@ its deployment branches/tags to release tags (`v*`). Verify these settings in GitHub; declaring `environment: release` in YAML does not create review rules. The npm trust relationship is also configured separately from the repository. +## Alpha.3 distribution tags + +For `0.1.0-alpha.3`, the maintainer explicitly selected both `next` and `latest`. +The trusted Release workflow publishes the prerelease to `next`. After that +publish is verified, use an authenticated npm CLI to point `latest` at the same +version, then verify both tags. npm trusted publishing currently does not +support `npm dist-tag add`; do not add a long-lived CI token to work around it. + +```sh +npm dist-tag add react-native-hinges@0.1.0-alpha.3 latest +npm view react-native-hinges dist-tags --json +``` + ## Prepare a candidate Start from reviewed, up-to-date `main`. Check the registry rather than assuming diff --git a/docs/releases/0.1.0-alpha.3.md b/docs/releases/0.1.0-alpha.3.md new file mode 100644 index 0000000..b78bec7 --- /dev/null +++ b/docs/releases/0.1.0-alpha.3.md @@ -0,0 +1,53 @@ +# react-native-hinges 0.1.0-alpha.3 + +Native hinge observations and provider-free Reanimated integration. + +## New and fixes + +- `useAnimatedHinges()` receives native snapshots through Worklets' stable C++ + API, scoped to the calling React root. It creates no provider or observer view. +- Core and animated consumers share native observation for the same root. + The animated value starts from the native cache and updates on the UI runtime. +- Android native compilation honors `reactNativeArchitectures`, matching the + selected Worklets ABIs and fixing single-architecture linker failures. +- The example adds adaptive compact layouts, sunset animation, Liquid Glass + controls, and tab-selection fixes across display changes. +- The documentation landing page now has an interactive folding illustration. + Demo recordings remain outside Git and are not packaged or deployed. + +## Breaking changes and migration + +`AnimatedHingesProvider` has been removed. Delete its import and wrapper; call +`useAnimatedHinges()` inside the existing React Native root. Install iOS pods +and rebuild the native app after upgrading. `useHinges()` and +`createHingeObserver(rootTag)` retain their API. + +React Native's New Architecture is required. The tested example uses +0.88.0-rc.1; a broader React Native compatibility range is not established. +iOS hinge observations require the iOS 27.1 SDK/runtime and UIScene lifecycle. +The optional animated entry point requires Reanimated ^4.7.0 and Worklets +^0.13.0. Angles remain raw radians, with null for unavailable readings. + +## Verification and limits + +- The Android emulator delivered 18 changing native angle readings across five + values on the UI runtime during a five-second JS stall, alongside the ordinary + hook. The corrected standard-link Stim build passed and started the example. +- The iOS 27.1 Duo build delivered matching initial readings through ordinary and + animated hooks. Changing native iOS angles during a JS stall remain unverified. +- Hook tests cover cache initialization, root isolation/replacement, cleanup, + missing native integration, and updates without React renders. +- Package, type, lint, formatting, and documentation checks are release gates. + See the tag's CI for native compilation of the exact candidate. + +See [the verification report](../verification/worklets-bridge-2026-09-22.md). +No sampling-rate, frame-rate, smoothing, or first-frame guarantee is added. +Physical foldables, multiple-hinge hardware, and a full app built without the +optional Worklets dependency remain unverified. The shared C++ fallback was +compiled without Worklets, and the core JS import does not import its peers. + +The example retains its Worklets FIFO patch for readiness-gated animated +subtrees. Installing this library does not patch Worklets in consumer apps; +see the Reanimated integration guide before conditionally mounting animations. + +This remains an alpha release even when installed through `latest`. diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index fe0864e..7b10042 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -14,7 +14,7 @@ PODS: - hermes-engine (260318099.0.3): - hermes-engine/Pre-built (= 260318099.0.3) - hermes-engine/Pre-built (260318099.0.3) - - Hinges (0.1.0-alpha.2): + - Hinges (0.1.0-alpha.3): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2355,7 +2355,7 @@ SPEC CHECKSUMS: fmt: ba0886e864ebb21d14c98b2e12e164b8002f5b93 glog: 3cf7cb46f743c4bc758ae3e6d9c15bd1b4cfd6d4 hermes-engine: 7e5f90072b91f62ead9584eebdced74fa9c742a0 - Hinges: 0caf256668e211eedfd5909b443dd4ca452370cc + Hinges: 02e7219457e52d85a19532f52b8ad1e0ce4a7ec9 LiquidGlass: 90d4f92e7a6c8577f38515c30a3fe36d3c157760 RCT-Folly: 82921e585c0400eda46fac1068297aa9dfb035e4 RCTDeprecation: 791606bb67573791862d7af9a787878fefcf1aff diff --git a/package.json b/package.json index 4e60332..285eead 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-hinges", - "version": "0.1.0-alpha.2", + "version": "0.1.0-alpha.3", "description": "Hinge posture and angle observations for React Native", "keywords": [ "android", diff --git a/website/docs/api.md b/website/docs/api.md index b0ad7bf..613d08b 100644 --- a/website/docs/api.md +++ b/website/docs/api.md @@ -70,4 +70,4 @@ Returns this React root's hinge readings as a shared value. No provider or addit Each hook owns its shared value; consumers of the same root share native observation. Unmounting releases the subscription. An externally retained shared value keeps its last snapshot. See [Reanimated integration](./reanimated.md). -This provider-free API is unreleased. Published `0.1.0-alpha.2` still requires `AnimatedHingesProvider`. +Available starting with `0.1.0-alpha.3`. Remove the `AnimatedHingesProvider` wrapper when upgrading from `0.1.0-alpha.2`, then rebuild the native app. diff --git a/website/docs/installation.md b/website/docs/installation.md index 45c6f4e..01bd94e 100644 --- a/website/docs/installation.md +++ b/website/docs/installation.md @@ -7,10 +7,10 @@ description: Try the native hinge API and check platform requirements. ## Install the alpha -Install the functional `0.1.0-alpha.2` release: +Install the `0.1.0-alpha.3` prerelease: ```sh -npm install react-native-hinges@0.1.0-alpha.2 +npm install react-native-hinges@0.1.0-alpha.3 ``` Install iOS pods and rebuild the native app. The explicit version above avoids the old `0.1.0-alpha.0` name-reservation placeholder. This alpha's supported test configuration and limitations are listed below. [Run the example](./example.md) to explore native and animated readings. diff --git a/website/docs/reanimated.md b/website/docs/reanimated.md index 8d9f5ba..4880412 100644 --- a/website/docs/reanimated.md +++ b/website/docs/reanimated.md @@ -17,7 +17,7 @@ pnpm add react-native-reanimated@^4.7.0 react-native-worklets@^0.13.0 For a React Native Community CLI app, add `react-native-worklets/plugin` last in the Babel plugins list, install iOS pods, and rebuild the native app. Preserve your existing presets and plugins. Follow the [official Reanimated setup instructions](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/getting-started/). -The provider-free integration described here is unreleased. Published `0.1.0-alpha.2` uses `AnimatedHingesProvider`; its API changes when upgrading to this version. See [installation](./installation.md). +The provider-free integration is available starting with `0.1.0-alpha.3`. Remove `AnimatedHingesProvider` when upgrading from `0.1.0-alpha.2`, install pods, and rebuild the native app. See [installation](./installation.md). ## Use the hook