Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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; 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:
Expand Down
12 changes: 10 additions & 2 deletions Hinges.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
39 changes: 17 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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. 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 { AnimatedHingesProvider, useAnimatedHinges } from 'react-native-hinges/reanimated';
import { useAnimatedHinges } from 'react-native-hinges/reanimated';
import { useAnimatedStyle } from 'react-native-reanimated';

function useHingeCardStyle() {
Expand All @@ -108,25 +109,19 @@ function useHingeCardStyle() {
};
});
}

export default function App() {
return (
<AnimatedHingesProvider>
<Screen />
</AnimatedHingesProvider>
);
}
```

The hook returns the provider's read-only-by-contract `SharedValue<readonly Hinge[]>`,
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.
The hook returns a read-only-by-contract `SharedValue<readonly Hinge[]>`,
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 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.
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

Expand Down Expand Up @@ -158,7 +153,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.
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

Expand Down
13 changes: 13 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -35,23 +35,53 @@ 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"

compileSdkVersion getExtOrDefault("compileSdkVersion")

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
}
}
37 changes: 37 additions & 0 deletions android/src/main/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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()
92 changes: 92 additions & 0 deletions android/src/main/cpp/HingesWorkletsModule.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#include <HingesWorklets.h>
#include <ReactCommon/BindingsInstallerHolder.h>
#include <fbjni/fbjni.h>

#include <memory>
#include <string>
#include <utility>
#include <vector>

namespace hinges {

using namespace facebook;

class HingesWorkletsModule : public jni::HybridClass<HingesWorkletsModule> {
public:
static constexpr auto kJavaDescriptor = "Lcom/appandflow/hinges/HingesModule;";

static jni::local_ref<jhybriddata> initWorkletsBridge(
jni::alias_ref<jhybridobject> jThis) {
return makeCxxInstance(jThis);
}

static jni::local_ref<react::BindingsInstallerHolder::javaobject> getBindingsInstaller(
jni::alias_ref<HingesWorkletsModule::javaobject> 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<react::CallInvoker>&) {
bridge->install(
runtime,
[module](int rootTag) {
jni::ThreadScope::WithClassLoader([&] {
static const auto startObserving = module->getClass()->getMethod<void(double)>("startObserving");
startObserving(module, static_cast<jdouble>(rootTag));
});
},
[module](int rootTag) {
jni::ThreadScope::WithClassLoader([&] {
static const auto stopObserving = module->getClass()->getMethod<void(double)>("stopObserving");
stopObserving(module, static_cast<jdouble>(rootTag));
});
});
});
}

static void publishHinges(
jni::alias_ref<HingesWorkletsModule::javaobject> jThis,
jint rootTag,
jni::alias_ref<jni::JArrayClass<jni::JString>> statuses,
jni::alias_ref<jni::JArrayDouble> angles,
jni::alias_ref<jni::JArrayBoolean> hasAngles) {
const auto count = statuses->size();
std::vector<HingeReading> 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<jni::JString> 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<HingesWorkletsModule::javaobject> 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<jhybridobject>) : bridge_(std::make_shared<WorkletsBridge>()) {}

std::shared_ptr<WorkletsBridge> bridge_;
};

} // namespace hinges

JNIEXPORT jint JNI_OnLoad(JavaVM* vm, void*) {
return facebook::jni::initialize(vm, [] { hinges::HingesWorkletsModule::registerNatives(); });
}
32 changes: 31 additions & 1 deletion android/src/main/java/com/appandflow/hinges/HingesModule.kt
Original file line number Diff line number Diff line change
@@ -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<Int, Observation>()
private val snapshots = ConcurrentHashMap<Int, List<HingeState>>()
@field:DoNotStrip private val mHybridData: HybridData = initWorkletsBridge()
@Volatile private var invalidated = false

override fun getName() = NAME
Expand Down Expand Up @@ -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)
Expand All @@ -71,9 +78,28 @@ class HingesModule(context: ReactApplicationContext) : NativeHingesSpec(context)

private fun emit(rootTag: Int, hinges: List<HingeState>) {
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<String>,
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 ->
Expand All @@ -96,6 +122,10 @@ class HingesModule(context: ReactApplicationContext) : NativeHingesSpec(context)
}

companion object {
init {
com.facebook.soloader.SoLoader.loadLibrary("hingesworklets")
}

const val NAME = "NativeHinges"
}
}
Loading
Loading