diff --git a/README.md b/README.md index 6a06133..ed7b3e4 100644 --- a/README.md +++ b/README.md @@ -91,8 +91,11 @@ 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. + ```tsx -import { useAnimatedHinges } from 'react-native-hinges/reanimated'; +import { AnimatedHingesProvider, useAnimatedHinges } from 'react-native-hinges/reanimated'; import { useAnimatedStyle } from 'react-native-reanimated'; function useHingeCardStyle() { @@ -105,15 +108,23 @@ function useHingeCardStyle() { }; }); } + +export default function App() { + return ( + + + + ); +} ``` -No animated provider is needed. The hook returns a read-only-by-contract -`SharedValue`, initialized from the native cache or `[]`. -Read it inside worklets and do not write to it. 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 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 unregisters the worklet and releases its native subscription. An +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. diff --git a/android/src/main/java/com/appandflow/hinges/HingeSource.kt b/android/src/main/java/com/appandflow/hinges/HingeSource.kt new file mode 100644 index 0000000..b990713 --- /dev/null +++ b/android/src/main/java/com/appandflow/hinges/HingeSource.kt @@ -0,0 +1,117 @@ +package com.appandflow.hinges + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.os.Build +import androidx.core.content.ContextCompat +import androidx.core.util.Consumer +import androidx.window.WindowSdkExtensions +import androidx.window.java.layout.WindowInfoTrackerCallbackAdapter +import androidx.window.layout.FoldingFeature +import androidx.window.layout.WindowInfoTracker +import androidx.window.layout.WindowLayoutInfo +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap + +internal data class HingeState(val status: String, val angle: Double?) + +/** + * Observes the Activity window's folding features and the default hinge-angle sensor + * while started, reporting a deduplicated snapshot to [onChange]. + */ +internal class HingeSource(private val context: Context, private val onChange: (List) -> Unit) : + SensorEventListener { + var hinges: List = emptyList() + private set + + private val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager + private var hingeSensor: Sensor? = null + private var hingeAngle: Double? = null + private var tracker: WindowInfoTrackerCallbackAdapter? = null + private var foldingFeatures: List = emptyList() + private var active = false + private val layoutInfoConsumer = Consumer { info -> + if (active) { + foldingFeatures = info.displayFeatures.filterIsInstance() + update() + } + } + + fun start() { + if (active) return + val activity = context.findActivity() ?: return + active = true + if (Build.VERSION.SDK_INT >= 30) { + hingeSensor = sensorManager.getDefaultSensor(Sensor.TYPE_HINGE_ANGLE) + hingeSensor?.let { sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_NORMAL) } + } + val windowTracker = WindowInfoTracker.getOrCreate(activity) + if (WindowSdkExtensions.getInstance().extensionVersion >= 9) { + foldingFeatures = windowTracker.getCurrentWindowLayoutInfo(activity) + .displayFeatures.filterIsInstance() + } + tracker = WindowInfoTrackerCallbackAdapter(windowTracker).also { + it.addWindowLayoutInfoListener(activity, ContextCompat.getMainExecutor(activity), layoutInfoConsumer) + } + update() + } + + fun stop() { + active = false + tracker?.removeWindowLayoutInfoListener(layoutInfoConsumer) + tracker = null + sensorManager.unregisterListener(this) + hingeSensor = null + hingeAngle = null + foldingFeatures = emptyList() + hinges = emptyList() + } + + override fun onSensorChanged(event: SensorEvent) { + if (!active) return + hingeAngle = Math.toRadians(event.values[0].toDouble()) + update() + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit + + private fun update() { + // Android sensors have no WindowManager feature ID; do not associate multiple hinges. + val next = foldingFeatures.map { feature -> + val status = when (feature.state) { + FoldingFeature.State.FLAT -> "fullyOpen" + FoldingFeature.State.HALF_OPENED -> "partiallyOpen" + else -> "unknown" + } + HingeState(status, if (foldingFeatures.size == 1) hingeAngle else null) + }.ifEmpty { + if (hingeSensor != null) listOf(HingeState("unknown", hingeAngle)) else emptyList() + } + if (hinges == next) return + hinges = next + onChange(next) + } +} + +internal fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} + +internal fun payload(hinges: List): WritableMap = Arguments.createMap().apply { + putArray("hinges", Arguments.createArray().apply { + hinges.forEach { hinge -> + pushMap(Arguments.createMap().apply { + putString("status", hinge.status) + putDouble("angle", hinge.angle ?: 0.0) + putBoolean("hasAngle", hinge.angle != null) + }) + } + }) +} diff --git a/android/src/main/java/com/appandflow/hinges/HingesChangeEvent.java b/android/src/main/java/com/appandflow/hinges/HingesChangeEvent.java deleted file mode 100644 index 6f73fed..0000000 --- a/android/src/main/java/com/appandflow/hinges/HingesChangeEvent.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.appandflow.hinges; - -import com.facebook.react.bridge.WritableMap; -import com.facebook.react.fabric.events.FabricEventEmitter; -import com.facebook.react.uimanager.events.Event; -import com.facebook.react.uimanager.events.RCTModernEventEmitter; - -/** - * This file is Java, not Kotlin, because {@code FabricEventEmitter} is declared {@code internal} in - * react-native's Kotlin sources: Java can reference it, Kotlin cannot. - * - *

Without the {@link #dispatchModern} override, react-native routes this root-tag event through - * {@code FabricUIManager.receiveEvent} into - * {@code fabric/mounting/SurfaceMountingManager.kt#dispatchEvent}, which appends events for a - * {@code ViewState} with no {@code eventEmitter} to {@code pendingEventQueue}. That queue is only - * drained by {@code updateEventEmitter}, which never runs for the root tag, so every hinge update - * would accumulate there without bound. - */ -final class HingesChangeEvent extends Event { - private final WritableMap payload; - - HingesChangeEvent(int rootTag, WritableMap payload) { - super(rootTag, rootTag); - this.payload = payload; - } - - @Override - public String getEventName() { - return "topHingesChange"; - } - - @Override - protected WritableMap getEventData() { - return payload; - } - - @Override - public void dispatchModern(RCTModernEventEmitter emitter) { - // RN 0.88 notifies native event observers before FabricEventEmitter. The root - // has no hinges prop/event emitter; only observers such as Reanimated consume this event. - if (!(emitter instanceof FabricEventEmitter)) { - super.dispatchModern(emitter); - } - } -} diff --git a/android/src/main/java/com/appandflow/hinges/HingesModule.kt b/android/src/main/java/com/appandflow/hinges/HingesModule.kt index 6c0b42a..fd55ef3 100644 --- a/android/src/main/java/com/appandflow/hinges/HingesModule.kt +++ b/android/src/main/java/com/appandflow/hinges/HingesModule.kt @@ -1,22 +1,6 @@ package com.appandflow.hinges -import android.app.Activity -import android.content.Context -import android.content.ContextWrapper -import android.hardware.Sensor -import android.hardware.SensorEvent -import android.hardware.SensorEventListener -import android.hardware.SensorManager -import android.os.Build import android.view.View -import androidx.core.content.ContextCompat -import androidx.core.util.Consumer -import androidx.window.WindowSdkExtensions -import androidx.window.java.layout.WindowInfoTrackerCallbackAdapter -import androidx.window.layout.FoldingFeature -import androidx.window.layout.WindowInfoTracker -import androidx.window.layout.WindowLayoutInfo -import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.UiThreadUtil import com.facebook.react.bridge.WritableMap @@ -24,8 +8,6 @@ import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.UIManagerHelper import java.util.concurrent.ConcurrentHashMap -private data class HingeState(val status: String, val angle: Double?) - @ReactModule(name = HingesModule.NAME) class HingesModule(context: ReactApplicationContext) : NativeHingesSpec(context) { private val observations = mutableMapOf() @@ -89,110 +71,31 @@ class HingesModule(context: ReactApplicationContext) : NativeHingesSpec(context) private fun emit(rootTag: Int, hinges: List) { if (invalidated) return - UIManagerHelper.getEventDispatcher(reactApplicationContext) - ?.dispatchEvent(HingesChangeEvent(rootTag, payload(hinges))) emitOnHingesChange(payload(hinges).apply { putDouble("rootTag", rootTag.toDouble()) }) } - private inner class Observation(val rootTag: Int, val root: View) : - SensorEventListener, View.OnAttachStateChangeListener { + private inner class Observation(val rootTag: Int, val root: View) : View.OnAttachStateChangeListener { var retainCount = 1 - private val sensorManager = root.context.getSystemService(Context.SENSOR_SERVICE) as SensorManager - private var hingeSensor: Sensor? = null - private var hingeAngle: Double? = null - private var tracker: WindowInfoTrackerCallbackAdapter? = null - private var foldingFeatures: List = emptyList() - private var active = false - private val layoutInfoConsumer = Consumer { info -> - if (active) { - foldingFeatures = info.displayFeatures.filterIsInstance() - update() - } + private val source = HingeSource(root.context) { hinges -> + if (observations[rootTag] !== this) return@HingeSource + snapshots[rootTag] = hinges + emit(rootTag, hinges) } - fun start() { - if (active) return - val activity = root.context.findActivity() ?: return - active = true - if (Build.VERSION.SDK_INT >= 30) { - hingeSensor = sensorManager.getDefaultSensor(Sensor.TYPE_HINGE_ANGLE) - hingeSensor?.let { sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_NORMAL) } - } - val windowTracker = WindowInfoTracker.getOrCreate(activity) - if (WindowSdkExtensions.getInstance().extensionVersion >= 9) { - foldingFeatures = windowTracker.getCurrentWindowLayoutInfo(activity) - .displayFeatures.filterIsInstance() - } - tracker = WindowInfoTrackerCallbackAdapter(windowTracker).also { - it.addWindowLayoutInfoListener(activity, ContextCompat.getMainExecutor(activity), layoutInfoConsumer) - } - update() - } + fun start() = source.start() - fun stop() { - active = false - tracker?.removeWindowLayoutInfoListener(layoutInfoConsumer) - tracker = null - sensorManager.unregisterListener(this) - hingeSensor = null - hingeAngle = null - foldingFeatures = emptyList() - } + fun stop() = source.stop() - override fun onViewAttachedToWindow(view: View) = start() + override fun onViewAttachedToWindow(view: View) = source.start() override fun onViewDetachedFromWindow(view: View) { - stop() + source.stop() snapshots[rootTag] = emptyList() emit(rootTag, emptyList()) } - - override fun onSensorChanged(event: SensorEvent) { - if (!active) return - hingeAngle = Math.toRadians(event.values[0].toDouble()) - update() - } - - override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit - - private fun update() { - // Android sensors have no WindowManager feature ID; do not associate multiple hinges. - val hinges = foldingFeatures.map { feature -> - val status = when (feature.state) { - FoldingFeature.State.FLAT -> "fullyOpen" - FoldingFeature.State.HALF_OPENED -> "partiallyOpen" - else -> "unknown" - } - HingeState(status, if (foldingFeatures.size == 1) hingeAngle else null) - }.ifEmpty { - if (hingeSensor != null) listOf(HingeState("unknown", hingeAngle)) else emptyList() - } - if (snapshots[rootTag] != hinges) { - snapshots[rootTag] = hinges - emit(rootTag, hinges) - } - } } companion object { const val NAME = "NativeHinges" } } - -private fun Context.findActivity(): Activity? = when (this) { - is Activity -> this - is ContextWrapper -> baseContext.findActivity() - else -> null -} - -private fun payload(hinges: List): WritableMap = Arguments.createMap().apply { - putArray("hinges", Arguments.createArray().apply { - hinges.forEach { hinge -> - pushMap(Arguments.createMap().apply { - putString("status", hinge.status) - putDouble("angle", hinge.angle ?: 0.0) - putBoolean("hasAngle", hinge.angle != null) - }) - } - }) -} diff --git a/android/src/main/java/com/appandflow/hinges/HingesObserverView.kt b/android/src/main/java/com/appandflow/hinges/HingesObserverView.kt new file mode 100644 index 0000000..8681360 --- /dev/null +++ b/android/src/main/java/com/appandflow/hinges/HingesObserverView.kt @@ -0,0 +1,41 @@ +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 new file mode 100644 index 0000000..08697e6 --- /dev/null +++ b/android/src/main/java/com/appandflow/hinges/HingesObserverViewManager.kt @@ -0,0 +1,29 @@ +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 9b59283..4780513 100644 --- a/android/src/main/java/com/appandflow/hinges/HingesPackage.kt +++ b/android/src/main/java/com/appandflow/hinges/HingesPackage.kt @@ -8,7 +8,8 @@ import com.facebook.react.module.model.ReactModuleInfoProvider import com.facebook.react.uimanager.ViewManager class HingesViewPackage : BaseReactPackage() { - override fun createViewManagers(reactContext: ReactApplicationContext): List> = emptyList() + 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/docs/verification/animated-provider-view-2026-09-21.md b/docs/verification/animated-provider-view-2026-09-21.md new file mode 100644 index 0000000..222b71d --- /dev/null +++ b/docs/verification/animated-provider-view-2026-09-21.md @@ -0,0 +1,142 @@ +# Animated hinges provider view: September 21, 2026 + +Verification of the replacement of the Reanimated delivery mechanism. The +optional entry point now exposes `AnimatedHingesProvider`, which renders a +hidden `HingesObserverView` Fabric component and feeds its `onHingesChange` +direct event to a Reanimated `useEvent` handler. The previous mechanism +registered a root-tag event handler and depended on `notifyObserversOfEvent` +plus an explicit Reanimated module registration on iOS, and on React Native's +`internal` `FabricEventEmitter` type on Android. Both are removed. + +Stack: React Native `0.88.0-rc.1`, Reanimated `4.7.0`, Worklets `0.13.0`, +example app `hinges.example`. + +## Repository checks + +`pnpm run format`, `format:check`, `lint`, `typecheck`, `test`, `build` and +`docs:build` all pass on the working tree. The rewritten +`src/__tests__/reanimated.test.tsx` covers the seeded `[]` value, worklet updates +without a React render, the `refresh` request after the scheduling hop, no +request after unmount, and the error thrown outside the provider. +`src/__tests__/index.test.tsx` still asserts that the core entry point loads +neither Reanimated nor Worklets. + +## iOS + +Own iPhone Duo simulator `A7052FCA-BF42-4AC9-96E4-3B4D5EA4424B` +(`hinges-provider-duo`, iOS 27.1), built with Xcode 27.1 beta, Release +configuration, installed and cold launched with `simctl`. No Metro server: the +Release variant embeds the bundle. + +The first Release build of the new provider crashed on launch: + +``` +[Worklets] Locally defined function passed to scheduleOnRN. Only functions +defined on the RN Runtime or host functions can be scheduled on the RN Runtime. +``` + +The callback passed to `scheduleOnRN` was created inside the `scheduleOnUI` +worklet. Defining it on the React Native runtime and referencing it from the +worklet fixed it. The Jest mocks do not model this Worklets restriction, so the +unit tests passed while the app could not start. That gap remains. + +After the fix, a cold launch with the simulator held closed produced: + +| Consumer | Observed value | +| ------------------------------ | ------------------------------------ | +| Sensor Lab `NATIVE ANGLE` | `0.0°`, `closed`, 1 reading received | +| Sensor Lab mode pill | `NATIVE HINGE` | +| Sensor Lab React `useHinges()` | `0.000 rad` | +| Sensor Lab standalone observer | `0.000 rad` | +| Field Notes native readout | `NATIVE HINGE`, `NATIVE 0.0°` | + +The Sensor Lab angle, status and reading count come from +`useHingeTelemetry(useAnimatedHinges())`, so a non-empty reading there is +delivery through the new view to the shared value on the UI runtime. The Field +Notes readout comes from `useHinges()` and confirms the unchanged core path. +Screenshots: `/tmp/hinges-provider-ios/01-field-notes.png` and +`/tmp/hinges-provider-ios/02-sensor-lab-native-angle.png`. + +Capture used the active Duo display explicitly. `xcrun simctl io +enumerate` lists two `Display class: 0` framebuffers; the 2007x2853 inner +display is black while the device is closed, and agent-device's own screenshot +also defaulted to it. + +The simulator only reports a static closed hinge. Changing iOS angles, physical +hardware, several simultaneously mounted providers, and first-visible-frame +availability were not established by this run. + +### Compile guard + +The library also builds against the older SDK. With the default Xcode 27.0 +toolchain, a `generic/platform=iOS Simulator` destination and +`CODE_SIGNING_ALLOWED=NO`, the `Hinges` pod target succeeded for both arm64 and +x86_64 against `iPhoneSimulator27.0.sdk`, compiling `HingesObserverView.mm`. +The full application scheme was not built with that toolchain because the host +disk filled during linking; the target that contains the new code was. + +`HingesObserverView.mm` keeps the same +`__IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_27_1` guard and +`@available(iOS 27.1, *)` check the module used, so an older SDK compiles the +interaction out and the view emits nothing. + +## Android + +Emulator `emulator-5580`, AVD `reserved-regions-fold-demo`, a `pixel_9_pro_fold` +on API 36 with a hinge sensor. Release variant with the embedded bundle, no +Metro. + +The same Worklets crash appeared here, and for the same reason: the gradle +bundle task did not re-run when only the library source outside `example/` +changed, so the first rebuild after the fix still packaged the old bundle. +Deleting `app/build/generated/assets/react/release/index.android.bundle` before +`:app:assembleRelease` produced a bundle that launches. + +With that build installed, driving `hinge-angle0` through the emulator console +produced: + +| Input | Sensor Lab reading | Field Notes readout | +| ------------- | ------------------------ | ------------------- | +| 180 degrees | `NATIVE HINGE` pill | | +| 45 degrees | `45.0°`, `partiallyOpen` | | +| 180 degrees | `180.0°`, `fullyOpen` | | +| 180, relaunch | | `NATIVE 180.0°` | + +The Sensor Lab angle and status come from +`useHingeTelemetry(useAnimatedHinges())`, so they are delivery through the new +view. Screenshots are in `/tmp/hinges-android-evidence/`. + +`pnpm e2e:android` itself did not complete. Its gradle build, install and sensor +injection succeed, then the cold launch fails: + +``` +FAIL open field notes: cold launch +Error (DEVICE_IN_USE): Device is already in use by session "cwd:738c1e202b917171:default". +``` + +Another agent-device session has held that emulator's lease since 01:31 local +time and last issued a command at 05:48. Twelve retries over roughly thirty +minutes hit the same error, and the lease was not reclaimed. The readings above +were therefore taken with plain `adb`: `am start`, `uiautomator dump` for the +text assertions, `input tap` to open the Sensor Lab, and +`adb emu sensor set hinge-angle0` for the input. That covers the same states the +script asserts, but it is not a run of the committed check, and the committed +check remains unexercised against this branch. + +Two further notes from this run. `screencap` needs an explicit display id on this +AVD, which `cmd display get-displays` reports as `local:4619827259835644672`. +And `ViewManagerPropertyUpdater` logs `Could not find generated setter for class +com.appandflow.hinges.HingesObserverViewManager` at startup, which is expected +for a view manager with no props; the `refresh` command still arrives through +the generated delegate, as the readings show. + +## Not established + +- Changing iOS hinge angles, and any physical device on either platform. +- More than one mounted `AnimatedHingesProvider`, and provider remount timing. +- First-visible-frame availability. The provider seeds `[]` and the native view + emits afterwards. +- Reanimated and React Native versions other than the ones listed above. The + `useEvent` handler, the `scheduleOnUI` to `scheduleOnRN` hop and the + `DirectEventHandler` payload are public integration points, but they have only + been exercised against this pair. diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 70fb30b..dfc1997 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.1): + - Hinges (0.1.0-alpha.2): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1836,7 +1836,7 @@ PODS: - React-utils (= 0.88.0-rc.1) - ReactNativeDependencies - ReactNativeDependencies (0.88.0-rc.1) - - ReservedRegions (0.1.0-alpha.1): + - ReservedRegions (0.1.0-alpha.2): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2328,7 +2328,7 @@ SPEC CHECKSUMS: fmt: ba0886e864ebb21d14c98b2e12e164b8002f5b93 glog: 3cf7cb46f743c4bc758ae3e6d9c15bd1b4cfd6d4 hermes-engine: 7e5f90072b91f62ead9584eebdced74fa9c742a0 - Hinges: 8564ab447c0d7fefa06843ddb26a25e5469f7677 + Hinges: 0aa917de16d8777f334ab5124fdc336180606e46 RCT-Folly: 82921e585c0400eda46fac1068297aa9dfb035e4 RCTDeprecation: 791606bb67573791862d7af9a787878fefcf1aff RCTRequired: 57e3c3f5e7102529bf9a42348ff77153b01cfa0b @@ -2407,7 +2407,7 @@ SPEC CHECKSUMS: ReactCodegen: 30f23f4b49be969efe5b85109e751434b714c533 ReactCommon: cb43d161764e3bac17dac2a80dce2b4edf5b3a2f ReactNativeDependencies: dabb9681fdf66316a899de1ce3ef247606b513a8 - ReservedRegions: 84a8a06479c5a6c74934587a4c78111bb7a89a94 + ReservedRegions: fcfabb0bff4b0916df575a1aeaf5088e5aec79c7 RNReanimated: fc7ee08e781ec9e8bbf45359c34ef5eaef56f560 RNWorklets: c3a33449674e0847c3d05bb730f6443290f19e6e SocketRocket: 37aec555668fb852ec12e3c0de59a86ca58f0871 diff --git a/example/src/App.tsx b/example/src/App.tsx index 31d5715..a07b086 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 { useAnimatedHinges } from 'react-native-hinges/reanimated'; +import { AnimatedHingesProvider, useAnimatedHinges } from 'react-native-hinges/reanimated'; import { useHingeTelemetry } from './useHingeTelemetry'; import { FieldNotes } from './FieldNotes'; @@ -215,21 +215,23 @@ 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/ios/HingesModule.mm b/ios/HingesModule.mm index 42a0999..3a6edbe 100644 --- a/ios/HingesModule.mm +++ b/ios/HingesModule.mm @@ -1,7 +1,5 @@ #import "HingesModule.h" -#import -#import #import #import #import @@ -23,7 +21,6 @@ @implementation HingesModule { NSMutableDictionary *_observations; NSMutableDictionary *> *_snapshots; std::atomic _invalidated; - __weak id _reanimatedObserver; __weak RCTSurfacePresenter *_surfacePresenter; } @@ -32,8 +29,6 @@ + (NSString *)moduleName return @"NativeHinges"; } -@synthesize moduleRegistry = _moduleRegistry; - + (BOOL)requiresMainQueueSetup { return NO; @@ -64,12 +59,7 @@ - (NSDictionary *)getSnapshot:(double)rootTag - (void)emitSnapshotForRoot:(NSNumber *)rootTag hinges:(NSArray *)hinges { if (_invalidated) return; - NSDictionary *body = @{@"rootTag": rootTag, @"hinges": hinges}; - id dispatcher = [_moduleRegistry moduleForName:"EventDispatcher"]; - [dispatcher notifyObserversOfEvent:[[RCTComponentEvent alloc] initWithName:@"onHingesChange" - viewTag:rootTag - body:body]]; - [self emitOnHingesChange:body]; + [self emitOnHingesChange:@{@"rootTag": rootTag, @"hinges": hinges}]; } - (void)updateRoot:(NSNumber *)rootTag @@ -88,13 +78,6 @@ - (void)startObserving:(double)rootTag { RCTExecuteOnMainQueue(^{ if (self->_invalidated) return; - id reanimated = [self->_moduleRegistry moduleForName:"ReanimatedModule" lazilyLoadIfNecessary:NO]; - if (reanimated != self->_reanimatedObserver && [reanimated conformsToProtocol:@protocol(RCTEventDispatcherObserver)]) { - // Reanimated 4.7 registers in setBridge before RN 0.88 injects its module registry. - id dispatcher = [self->_moduleRegistry moduleForName:"EventDispatcher"]; - [dispatcher addDispatchObserver:reanimated]; - self->_reanimatedObserver = reanimated; - } NSNumber *tag = @(rootTag); HingeRootObservation *observation = self->_observations[tag]; if (observation == nil) { diff --git a/ios/HingesObserverView.h b/ios/HingesObserverView.h new file mode 100644 index 0000000..f600630 --- /dev/null +++ b/ios/HingesObserverView.h @@ -0,0 +1,8 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface HingesObserverView : RCTViewComponentView +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/HingesObserverView.mm b/ios/HingesObserverView.mm new file mode 100644 index 0000000..c8386a0 --- /dev/null +++ b/ios/HingesObserverView.mm @@ -0,0 +1,86 @@ +#import "HingesObserverView.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; +#if defined(__IPHONE_27_1) && __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_27_1 + if (@available(iOS 27.1, *)) { + __weak HingesObserverView *weakSelf = self; + UIHingeInteraction *interaction = [[UIHingeInteraction alloc] + initWithUpdateHandler:^(UIHingeInteraction *interaction, UIHingeInteractionUpdate *update) { + HingesObserverView *strongSelf = weakSelf; + if (strongSelf == nil) return; + HingesObserverViewEventEmitter::OnHingesChange snapshot; + UIHinge *hinge = update.hinge; + if (hinge != nil) { + std::string status = "unknown"; + switch (hinge.status) { + case UIHingeStatusClosed: status = "closed"; break; + case UIHingeStatusPartiallyOpen: status = "partiallyOpen"; break; + case UIHingeStatusFullyOpen: status = "fullyOpen"; break; + case UIHingeStatusUnknown: break; + } + snapshot.hinges.push_back({status, hinge.angle, true}); + } + strongSelf->_snapshot = std::move(snapshot); + [strongSelf emitSnapshot]; + }]; + [self addInteraction:interaction]; + } +#endif + } + 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 4e60332..6ecb6cc 100644 --- a/package.json +++ b/package.json @@ -123,7 +123,7 @@ "packageManager": "pnpm@12.5.1", "codegenConfig": { "name": "HingesSpec", - "type": "modules", + "type": "all", "jsSrcsDir": "src", "android": { "javaPackageName": "com.appandflow.hinges" @@ -133,6 +133,11 @@ "NativeHinges": { "className": "HingesModule" } + }, + "components": { + "HingesObserverView": { + "className": "HingesObserverView" + } } } }, diff --git a/src/HingesObserverViewNativeComponent.ts b/src/HingesObserverViewNativeComponent.ts new file mode 100644 index 0000000..543e4c0 --- /dev/null +++ b/src/HingesObserverViewNativeComponent.ts @@ -0,0 +1,27 @@ +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 a8df527..c5af706 100644 --- a/src/__tests__/reanimated.test.tsx +++ b/src/__tests__/reanimated.test.tsx @@ -1,12 +1,10 @@ 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 { useAnimatedHinges } from '../reanimated'; +import { AnimatedHingesProvider, useAnimatedHinges } from '../reanimated'; import type { Hinge } from '../index'; -import NativeHinges from '../HingesModule'; -import type { HingesChangeEvent } from '../NativeHinges'; +import { Commands, type HingesObserverChangeEvent } from '../HingesObserverViewNativeComponent'; const mockUIQueue: (() => void)[] = []; const mockRNQueue: (() => void)[] = []; @@ -19,10 +17,12 @@ function flushSetup() { while (mockRNQueue.length) mockRNQueue.shift()?.(); } -const mockWorklets = new Map void>>(); +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; @@ -33,125 +33,99 @@ jest.mock('react-native-reanimated', () => { }, }; }, []), - useEvent: (handler: (event: HingesChangeEvent) => void) => - react.useMemo( - () => ({ - workletEventHandler: { - registerForEvents: (root: number) => - mockUIQueue.push(() => { - const handlers = mockWorklets.get(root) ?? new Set(); - handlers.add(handler); - mockWorklets.set(root, handlers); - }), - unregisterFromEvents: (root: number) => - mockUIQueue.push(() => { - mockWorklets.get(root)?.delete(handler); - }), - }, - }), - [], - ), + useEvent: (handler: (event: HingesObserverChangeEvent) => void) => + react.useMemo(() => { + mockWorkletHandlers.add(handler); + return handler; + }, []), }; }); -jest.mock('../HingesModule', () => ({ +jest.mock('../HingesModule', () => ({ __esModule: true, default: {} })); +jest.mock('../HingesObserverViewNativeComponent', () => ({ __esModule: true, - default: { - getSnapshot: () => ({ hinges: [] }), - startObserving: jest.fn((rootTag: number) => { - for (const handler of mockWorklets.get(rootTag) ?? []) - handler({ rootTag, hinges: [{ status: 'closed', angle: 0, hasAngle: true }] }); - }), - stopObserving: jest.fn(), - }, + default: 'HingesObserverView', + Commands: { refresh: jest.fn() }, })); + +const mockNativeView = { id: 'hinges-observer-view' }; +function render(element: React.ReactElement) { + return create(element, { createNodeMock: () => mockNativeView }); +} +function emit(hinges: HingesObserverChangeEvent['hinges']) { + for (const handler of mockWorkletHandlers) handler({ hinges }); +} + beforeEach(() => { - mockWorklets.clear(); + mockWorkletHandlers.clear(); mockUIQueue.length = 0; mockRNQueue.length = 0; jest.clearAllMocks(); }); -it('registers before acquiring native observation, isolates roots, and retains updates without React renders', async () => { - const values = new Map>(); +it('seeds an empty shared value, then updates it from native events without a React render', async () => { + let value: SharedValue | undefined; let renders = 0; - function Consumer({ name }: { name: string }) { + function Consumer() { const hinges = useAnimatedHinges(); React.useEffect(() => { - values.set(name, hinges); + value = hinges; renders++; }); return null; } - const first = ; - const tree = (two: boolean) => ( - <> - - {first} - {two && } - - - - - - ); - let renderer: ReturnType; await act(() => { - renderer = create(tree(true)); + render( + + + , + ); }); - expect(NativeHinges.startObserving).not.toHaveBeenCalled(); - await act(flushSetup); - expect(values.get('first')?.get()).toEqual([{ status: 'closed', angle: 0 }]); + expect(value?.get()).toEqual([]); + const previousRenders = renders; - for (const handler of mockWorklets.get(1) ?? []) - handler({ rootTag: 1, hinges: [{ status: 'future', angle: 123, hasAngle: false }] }); - expect(values.get('first')?.get()).toEqual([{ status: 'unknown', angle: null }]); - expect(values.get('second')?.get()).toEqual([{ status: 'unknown', angle: null }]); - expect(values.get('other')?.get()).toEqual([{ status: 'closed', angle: 0 }]); + 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 }]); + expect(value?.get()).toEqual([{ status: 'unknown', angle: null }]); expect(renders).toBe(previousRenders); - await act(() => renderer.update(tree(false))); +}); + +it('requests the current snapshot only after the worklet registration hop', async () => { + await act(() => { + render(); + }); + expect(Commands.refresh).not.toHaveBeenCalled(); await act(flushSetup); - expect(mockWorklets.get(1)?.size).toBe(1); - expect(NativeHinges.stopObserving).toHaveBeenCalledWith(1); + expect(Commands.refresh).toHaveBeenCalledWith(mockNativeView); +}); + +it('does not request a snapshot when unmounted before the hop completes', async () => { + let renderer: ReturnType; + await act(() => { + renderer = render(); + }); await act(() => renderer.unmount()); await act(flushSetup); - expect(mockWorklets.get(1)?.size).toBe(0); - expect(mockWorklets.get(2)?.size).toBe(0); - expect(values.get('first')?.get()).toEqual([{ status: 'unknown', angle: null }]); + expect(Commands.refresh).not.toHaveBeenCalled(); }); -function SetupConsumer() { +function OrphanConsumer() { useAnimatedHinges(); return null; } -it('throws a clear error when useAnimatedHinges renders without a RootTagContext provider', () => { +it('throws a clear error when useAnimatedHinges renders outside the provider', () => { const error = jest.spyOn(console, 'error').mockImplementation(() => {}); try { expect(() => act(() => { - create(); + render(); }), ).toThrow( - 'useAnimatedHinges must render inside a React Native root: RootTagContext is 0 (or invalid). ' + - 'In tests, wrap the tree in .', + 'useAnimatedHinges must render inside an AnimatedHingesProvider from react-native-hinges/reanimated. ' + + 'Wrap the tree in at or above this component.', ); } finally { error.mockRestore(); } }); - -it('does not acquire observation if unmounted before the UI registration acknowledgment', async () => { - let renderer: ReturnType; - await act(() => { - renderer = create( - - - , - ); - }); - await act(() => renderer.unmount()); - await act(flushSetup); - expect(NativeHinges.startObserving).not.toHaveBeenCalled(); - expect(NativeHinges.stopObserving).not.toHaveBeenCalled(); - expect(mockWorklets.get(1)?.size).toBe(0); -}); diff --git a/src/reanimated.tsx b/src/reanimated.tsx index e6334d6..cc93fd2 100644 --- a/src/reanimated.tsx +++ b/src/reanimated.tsx @@ -1,55 +1,79 @@ -import { useLayoutEffect, useMemo } from 'react'; +import { createContext, useContext, useEffect, useRef, type ComponentRef, type ReactNode } from 'react'; +import { StyleSheet } from 'react-native'; import { scheduleOnRN, scheduleOnUI } from 'react-native-worklets'; -import { useEvent, useSharedValue, type SharedValue } from 'react-native-reanimated'; -import { createHingeObserver, mapHinges, type Hinge } from './HingeObserver'; -import { useRootTag } from './useRootTag'; -import NativeHinges from './HingesModule'; -import type { HingesChangeEvent } from './NativeHinges'; +import { createAnimatedComponent, useEvent, useSharedValue, type SharedValue } from 'react-native-reanimated'; +import { mapHinges, type Hinge } from './HingeObserver'; +import HingesObserverView, { + Commands, + type HingesObserverChangeEvent, + type NativeProps, +} from './HingesObserverViewNativeComponent'; + +const HingesSharedValueContext = createContext | null>(null); +const AnimatedHingesObserverView = createAnimatedComponent(HingesObserverView); +const styles = StyleSheet.create({ + observer: { position: 'absolute', width: 0, height: 0 }, +}); /** - * Returns root-scoped native hinge snapshots on the UI runtime, without a provider. - * 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. - * The shared value starts from the native cache and retains its last value after unmount. + * 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 useAnimatedHinges(): SharedValue { - const rootTag = useRootTag('useAnimatedHinges'); - const observer = useMemo(() => createHingeObserver(rootTag), [rootTag]); - const hinges = useSharedValue(observer.get()); - const event = useEvent( - (update) => { +export function AnimatedHingesProvider({ children }: { children?: ReactNode }) { + const hinges = useSharedValue([]); + const view = useRef | null>(null); + const onHingesChange = useEvent( + (event) => { 'worklet'; - hinges.set(mapHinges(update.hinges)); + hinges.set(mapHinges(event.hinges)); }, - ['onHingesChange', 'topHingesChange'], + ['onHingesChange'], ); - // Reanimated 4.7 useEvent disguises its WorkletEventHandler as a callback; root events have no component prop to attach it. - const { workletEventHandler } = event as unknown as { - workletEventHandler: { - registerForEvents(tag: number): void; - unregisterFromEvents(tag: number): void; - }; - }; - useLayoutEffect(() => { - hinges.set(observer.get()); - workletEventHandler.registerForEvents(rootTag); + useEffect(() => { let cancelled = false; - let started = false; - const start = () => { - if (cancelled) return; - started = true; - NativeHinges.startObserving(rootTag); + // 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 events asynchronously on the UI scheduler. Acquire after it runs so initial replay is observed. + // 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(start); + scheduleOnRN(requestSnapshot); }); return () => { cancelled = true; - workletEventHandler.unregisterFromEvents(rootTag); - if (started) NativeHinges.stopObserving(rootTag); }; - }, [rootTag, observer, hinges, workletEventHandler]); + }, []); + + return ( + + {children} + + + ); +} + +/** + * 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. + */ +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.', + ); + } return hinges; } diff --git a/website/docs/api.md b/website/docs/api.md index 14c7892..b27705f 100644 --- a/website/docs/api.md +++ b/website/docs/api.md @@ -60,12 +60,22 @@ 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; `get()` returns the latest observed snapshot and subscriptions keep it current. 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 a shared value initialized from this React root's native cache or `[]`. Requires the optional Reanimated and Worklets peers. Native events update it directly on the UI runtime. No provider is required. Read with `get()` inside worklets and do not mutate it. +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`. -Each hook owns its shared value and native subscription. A retained shared value keeps its last snapshot after unmount. 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). diff --git a/website/docs/example.md b/website/docs/example.md index c591d42..876f9e4 100644 --- a/website/docs/example.md +++ b/website/docs/example.md @@ -76,7 +76,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. See [the optional integration](./reanimated.md) for how its animated and ordinary consumers share native root observation. +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. ## Native testing diff --git a/website/docs/platforms.md b/website/docs/platforms.md index 4a01346..3050cb1 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` on 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. +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. | UIKit status | Public status | | -------------- | --------------- | @@ -34,7 +34,7 @@ The host app must adopt the UIScene lifecycle; the example uses `UISceneDelegate ## Android -The native module 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 combined snapshot is associated with that root's 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 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. | WindowManager state | Public status | | --------------------- | --------------- | diff --git a/website/docs/reanimated.md b/website/docs/reanimated.md index 0570cc3..32deed8 100644 --- a/website/docs/reanimated.md +++ b/website/docs/reanimated.md @@ -23,7 +23,7 @@ This integration is included in `0.1.0-alpha.2`. See [installation](./installati ```tsx import Animated, { useAnimatedStyle } from 'react-native-reanimated'; -import { useAnimatedHinges } from 'react-native-hinges/reanimated'; +import { AnimatedHingesProvider, useAnimatedHinges } from 'react-native-hinges/reanimated'; function HingeCard() { const hinges = useAnimatedHinges(); @@ -39,15 +39,19 @@ 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. -No provider is needed. The regular hook, explicit observers, and animated hooks share native observation for the same React root. Each animated hook owns a shared value. +`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. -`useAnimatedHinges()` throws if rendered outside a React Native root, where `RootTagContext` is still its default of `0`. In tests without an `AppContainer`, wrap the tree in ``. +The provider is independent of `RootTagContext`, so a test can render it without an `AppContainer`. ## Shared-value contract @@ -55,7 +59,7 @@ No provider is needed. The regular hook, explicit observers, and animated hooks function useAnimatedHinges(): SharedValue; ``` -The shared value starts from the native cache or `[]`. Hinge status and raw radians have the same meaning as the ordinary API; unavailable angles remain `null`. +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`. 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/). @@ -79,10 +83,14 @@ Keeping the animated subtree mounted avoided this failure in the tested case; apps that gate its mount need to apply the patch, rebuild the native app, and validate it themselves. -## Lifetime and validation limits +## 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. -Unmounting unregisters the root-tag event handler and releases the native subscription. An externally retained shared value keeps its last snapshot. The native cache is cleared when the root has no subscribers. +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. + +## Lifetime and validation limits -This prototype registers the handler returned by Reanimated 4.7's `useEvent` directly against the React root tag. Android uses RN 0.88's internal Fabric event emitter type to route the event only to native observers; iOS uses `notifyObserversOfEvent` and explicitly registers an already-loaded Reanimated module with that dispatcher. These integration points need revalidation when upgrading React Native or Reanimated. They avoid delivering angle updates through the JavaScript thread first. +Unmounting the provider removes the native view, which ends its observation. An externally retained shared value keeps its last snapshot. -The root-scoped Android implementation has been tested with fixed-angle cold launches and native angle updates during a one-second JS stall. These emulator checks do not establish physical-device rates or first-frame availability. A simulated-preview animation does not verify sensor delivery. +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.