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
25 changes: 18 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -105,15 +108,23 @@ function useHingeCardStyle() {
};
});
}

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

No animated provider is needed. The hook returns a read-only-by-contract
`SharedValue<readonly Hinge[]>`, 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<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.

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.

Expand Down
117 changes: 117 additions & 0 deletions android/src/main/java/com/appandflow/hinges/HingeSource.kt
Original file line number Diff line number Diff line change
@@ -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<HingeState>) -> Unit) :
SensorEventListener {
var hinges: List<HingeState> = 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<FoldingFeature> = emptyList()
private var active = false
private val layoutInfoConsumer = Consumer<WindowLayoutInfo> { info ->
if (active) {
foldingFeatures = info.displayFeatures.filterIsInstance<FoldingFeature>()
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<FoldingFeature>()
}
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<HingeState>): 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)
})
}
})
}
45 changes: 0 additions & 45 deletions android/src/main/java/com/appandflow/hinges/HingesChangeEvent.java

This file was deleted.

115 changes: 9 additions & 106 deletions android/src/main/java/com/appandflow/hinges/HingesModule.kt
Original file line number Diff line number Diff line change
@@ -1,31 +1,13 @@
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
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<Int, Observation>()
Expand Down Expand Up @@ -89,110 +71,31 @@ class HingesModule(context: ReactApplicationContext) : NativeHingesSpec(context)

private fun emit(rootTag: Int, hinges: List<HingeState>) {
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<FoldingFeature> = emptyList()
private var active = false
private val layoutInfoConsumer = Consumer<WindowLayoutInfo> { info ->
if (active) {
foldingFeatures = info.displayFeatures.filterIsInstance<FoldingFeature>()
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<FoldingFeature>()
}
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<HingeState>): 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)
})
}
})
}
Loading
Loading