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
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ dependencies {
implementation project(':expo-modules-core')
implementation 'androidx.compose.ui:ui-android:1.10.6'
implementation 'androidx.compose.material3:material3-android:1.5.0-alpha17'
implementation 'androidx.window:window:1.5.1'
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package expo.modules.t3nativecontrols

import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.os.Build
import androidx.window.layout.FoldingFeature
import androidx.window.layout.WindowInfoTracker
import expo.modules.kotlin.AppContext
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.viewevent.EventDispatcher
import expo.modules.kotlin.views.ExpoView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.MainScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch

class T3FoldPostureModule : Module() {
override fun definition() = ModuleDefinition {
Name("T3FoldPosture")

View(T3FoldPostureView::class) {
Events("onHingeChange")
}
}
}

/**
* Reports a vertical hinge, in this view's dp coordinates, while the device is bent like a book.
* The hinge sensor is only registered while a vertical fold is on screen.
*/
class T3FoldPostureView(
context: Context,
appContext: AppContext
) : ExpoView(context, appContext), SensorEventListener {
private val onHingeChange by EventDispatcher()
private val sensorManager = context.getSystemService(SensorManager::class.java)
private val hingeSensor =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
sensorManager?.getDefaultSensor(Sensor.TYPE_HINGE_ANGLE)
} else {
null
}
private var scope: CoroutineScope? = null
private var fold: FoldingFeature? = null
private var listening = false
private var bent = false
private var reported: Map<String, Any>? = null

override fun onAttachedToWindow() {
super.onAttachedToWindow()
val activity = appContext.currentActivity ?: return
scope = MainScope().also { scope ->
scope.launch {
WindowInfoTracker.getOrCreate(activity).windowLayoutInfo(activity).collect { info ->
fold = info.displayFeatures.filterIsInstance<FoldingFeature>().firstOrNull {
it.orientation == FoldingFeature.Orientation.VERTICAL
}
listen(fold != null)
report()
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

override fun onDetachedFromWindow() {
scope?.cancel()
scope = null
listen(false)
super.onDetachedFromWindow()
}

override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
if (changed) report()
}

override fun onSensorChanged(event: SensorEvent) {
// Pixel foldables report the hinge in 5° steps and rest at 175° when nearly flat. Snapping at
// 170° and releasing at 175° keeps a resting hinge from flapping the layout.
val angle = event.values[0]
bent = if (bent) angle < 175f else angle <= 170f
report()
}

override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit

private fun listen(enabled: Boolean) {
if (enabled == listening || hingeSensor == null) return
listening = enabled
if (enabled) {
sensorManager.registerListener(this, hingeSensor, SensorManager.SENSOR_DELAY_UI)
} else {
sensorManager.unregisterListener(this)
bent = false
}
}

private fun report() {
val feature = fold
// Without a hinge sensor, the platform's half-opened posture is the closest signal.
val aligned = feature != null &&
if (hingeSensor != null) bent else feature.state == FoldingFeature.State.HALF_OPENED
val hinge = if (aligned && feature != null) {
val origin = IntArray(2).also { getLocationInWindow(it) }
val density = resources.displayMetrics.density
mapOf(
"x" to (feature.bounds.left - origin[0]) / density,
"windowX" to feature.bounds.left / density,
"width" to feature.bounds.width() / density
)
} else {
emptyMap()
}
if (hinge == reported) return
reported = hinge
onHingeChange(hinge)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"modules": [
"expo.modules.t3nativecontrols.T3NativeControlsModule",
"expo.modules.t3nativecontrols.T3WorkspaceNavigationModule",
"expo.modules.t3nativecontrols.T3KeyboardCommandsModule"
"expo.modules.t3nativecontrols.T3KeyboardCommandsModule",
"expo.modules.t3nativecontrols.T3FoldPostureModule"
]
}
}
32 changes: 17 additions & 15 deletions apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { MenuAction, MenuComponentProps } from "@react-native-menu/menu";
import type { ReactNode } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { use, useCallback, useEffect, useRef, useState } from "react";
import type { StyleProp, ViewInstance, ViewStyle } from "react-native";
import { BackHandler, Pressable, ScrollView, View } from "react-native";
import { useKeyboardState } from "react-native-keyboard-controller";
import Animated, { FadeIn } from "react-native-reanimated";

import { deriveMenuHorizontalPlacement } from "../../lib/menu-placement";
import { WindowHingeContext } from "../../native/window-hinge-context";
import { OverlayPortal } from "./OverlayPortal";
import { useAndroidControlSizing } from "./useAndroidControlSizing";
import { MenuPopup } from "./MenuPopup";
Expand Down Expand Up @@ -46,10 +48,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
const [path, setPath] = useState<readonly MenuAction[]>([]);
const [rootHeight, setRootHeight] = useState<number | null>(null);
const [overlay, setOverlay] = useState<OverlayFrame | null>(null);
const menuWidth =
overlay === null
? desiredMenuWidth
: Math.min(desiredMenuWidth, Math.max(0, overlay.width - 2 * SCREEN_MARGIN));
const windowHinge = use(WindowHingeContext);
const anchorRef = useRef<ViewInstance>(null);
const overlayRef = useRef<ViewInstance>(null);

Expand Down Expand Up @@ -105,16 +104,17 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
width: anchor.width,
height: anchor.height,
};
const preferredLeft =
const { left, width: menuWidth } =
local === null || overlay === null
? 0
: local.x + local.width / 2 <= overlay.width / 2
? local.x
: local.x + local.width - menuWidth;
const left =
overlay === null
? 0
: Math.min(Math.max(preferredLeft, SCREEN_MARGIN), overlay.width - menuWidth - SCREEN_MARGIN);
? { left: 0, width: desiredMenuWidth }
: deriveMenuHorizontalPlacement({
anchorX: local.x,
anchorWidth: local.width,
overlayWidth: overlay.width,
desiredWidth: desiredMenuWidth,
margin: SCREEN_MARGIN,
hinge: windowHinge && { x: windowHinge.x - overlay.x, width: windowHinge.width },
});
// The keyboard stays up while the menu is open (in-window overlay, no
// focus change), so the space it covers is not usable — without this the
// composer-pill menus "open down" into the IME and can't be tapped.
Expand Down Expand Up @@ -174,7 +174,9 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
<Pressable accessible={false} className="absolute inset-0" onPress={close} />
{!placeable || local === null ? null : !anchor.keyboardWasVisible ? (
<MenuPopup
anchor={local}
// Compose aligns its dropdown to the trigger's start whenever it fits, so a
// trigger at the hinge-safe position keeps the menu on the anchor's side.
anchor={windowHinge ? { ...local, x: left, width: menuWidth } : local}
menuWidth={menuWidth}
actions={levelActions}
title={props.title}
Expand Down
Loading
Loading