diff --git a/apps/mobile/modules/t3-native-controls/android/build.gradle b/apps/mobile/modules/t3-native-controls/android/build.gradle index e4e2e317cacf7..01e035450f0ed 100644 --- a/apps/mobile/modules/t3-native-controls/android/build.gradle +++ b/apps/mobile/modules/t3-native-controls/android/build.gradle @@ -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' } diff --git a/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3FoldPostureModule.kt b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3FoldPostureModule.kt new file mode 100644 index 0000000000000..7646f2e678c5f --- /dev/null +++ b/apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3FoldPostureModule.kt @@ -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? = 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().firstOrNull { + it.orientation == FoldingFeature.Orientation.VERTICAL + } + listen(fold != null) + report() + } + } + } + } + + 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) + } +} diff --git a/apps/mobile/modules/t3-native-controls/expo-module.config.json b/apps/mobile/modules/t3-native-controls/expo-module.config.json index 08ac3e0785344..22fcb74a36cfb 100644 --- a/apps/mobile/modules/t3-native-controls/expo-module.config.json +++ b/apps/mobile/modules/t3-native-controls/expo-module.config.json @@ -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" ] } } diff --git a/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx b/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx index dd5c4b4651a57..a5015d98ea4b7 100644 --- a/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx +++ b/apps/mobile/src/components/ui/AndroidAnchoredMenu.tsx @@ -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"; @@ -46,10 +48,7 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { const [path, setPath] = useState([]); const [rootHeight, setRootHeight] = useState(null); const [overlay, setOverlay] = useState(null); - const menuWidth = - overlay === null - ? desiredMenuWidth - : Math.min(desiredMenuWidth, Math.max(0, overlay.width - 2 * SCREEN_MARGIN)); + const windowHinge = use(WindowHingeContext); const anchorRef = useRef(null); const overlayRef = useRef(null); @@ -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. @@ -174,7 +174,9 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) { {!placeable || local === null ? null : !anchor.keyboardWasVisible ? ( deriveLayout({ width: viewportWidth, height, nativeMetrics }), [height, viewportWidth, nativeMetrics], ); - const layout = baseLayout; - const shouldRenderNavigationRail = Platform.OS === "android" && layout.usesSplitView; + const shouldRenderNavigationRail = Platform.OS === "android" && baseLayout.usesSplitView; const navigationRailWidth = shouldRenderNavigationRail && showPrimarySidebar ? ANDROID_WORKSPACE_RAIL_WIDTH * Math.max(1, androidControlScale) : 0; + const [hinge, setHinge] = useState(null); + const [windowHinge, setWindowHinge] = useState(null); + const handleHingeChange = useCallback( + (next: VerticalHinge | null, nextWindow: VerticalHinge | null) => { + setHinge(next); + setWindowHinge(nextWindow); + }, + [], + ); + const layout = useMemo( + () => + alignListPaneToHinge({ + layout: baseLayout, + hinge, + viewportWidth, + leadingWidth: navigationRailWidth, + }), + [baseLayout, hinge, viewportWidth, navigationRailWidth], + ); const width = viewportWidth - navigationRailWidth; const shouldRenderPrimarySidebar = layout.usesSplitView; const fileInspector = useMemo( @@ -481,38 +503,55 @@ function AdaptiveWorkspaceLayoutContent( target: contentTargetWidth, sidebarVisible: panes.primarySidebarVisible, width: contentTargetWidth, + listPaneTarget: layout.listPaneWidth, + listPaneFrom: layout.listPaneWidth, revision: 0, }); if ( Platform.OS === "android" && (contentSizing.viewportWidth !== viewportWidth || contentSizing.target !== contentTargetWidth || - contentSizing.sidebarVisible !== panes.primarySidebarVisible) + contentSizing.sidebarVisible !== panes.primarySidebarVisible || + contentSizing.listPaneTarget !== layout.listPaneWidth) ) { + const sameViewport = contentSizing.viewportWidth === viewportWidth; const openingSidebar = - Platform.OS === "android" && - contentSizing.viewportWidth === viewportWidth && - !contentSizing.sidebarVisible && + sameViewport && !contentSizing.sidebarVisible && panes.primarySidebarVisible; + // A book-posture hinge resizes the visible list pane instead of revealing it. + const resizingSidebar = + sameViewport && + contentSizing.sidebarVisible && panes.primarySidebarVisible && + contentSizing.listPaneTarget !== layout.listPaneWidth; + const keepWide = + (openingSidebar || resizingSidebar) && contentSizing.width !== null && contentTargetWidth !== null; setContentSizing({ viewportWidth, target: contentTargetWidth, sidebarVisible: panes.primarySidebarVisible, - width: openingSidebar + // Clip the wide navigator while the sidebar opens or widens, then reflow once at the + // end. Narrowing it first exposes an empty strip before the sidebar arrives. + width: keepWide ? Math.max(contentSizing.width ?? 0, contentTargetWidth ?? 0) : contentTargetWidth, + listPaneTarget: layout.listPaneWidth, + // While a hinge resizes the list, its width follows the pane edge between these two. + listPaneFrom: resizingSidebar ? contentSizing.listPaneTarget : layout.listPaneWidth, revision: contentSizing.revision + 1, }); } const settleContentWidth = useCallback((revision: number) => { setContentSizing((current) => - current.revision !== revision || current.width === current.target + current.revision !== revision || + (current.width === current.target && current.listPaneFrom === current.listPaneTarget) ? current - : { ...current, width: current.target }, + : { ...current, width: current.target, listPaneFrom: current.listPaneTarget }, ); }, []); + const listPaneFloor = Math.min(contentSizing.listPaneFrom ?? 0, layout.listPaneWidth ?? 0); + const listPaneCeiling = Math.max(contentSizing.listPaneFrom ?? 0, layout.listPaneWidth ?? 0); const contentSettledWidth = Platform.OS === "android" ? contentSizing.width : contentTargetWidth; const contentRevision = contentSizing.revision; @@ -541,6 +580,15 @@ function AdaptiveWorkspaceLayoutContent( contentRevision, ]); const railAnimatedStyle = useAnimatedStyle(() => ({ width: renderedRailWidth.value })); + // Showing or hiding the sidebar clips a fixed-width list. A hinge resize instead tracks the + // animated edge, so the header actions slide with it rather than jumping or being covered. + const listPaneGap = layout.listPaneGap ?? 0; + const listPaneAnimatedStyle = useAnimatedStyle(() => ({ + width: Math.min( + listPaneCeiling, + Math.max(listPaneFloor, renderedSidebarWidth.value - listPaneGap), + ), + })); const sidebarAnimatedStyle = useAnimatedStyle(() => ({ // Fading a UIKit glass ancestor can leave remounted header effects missing. opacity: Platform.OS === "ios" ? 1 : Math.min(1, renderedSidebarWidth.value / 80), @@ -667,102 +715,105 @@ function AdaptiveWorkspaceLayoutContent( savedFilters={props.savedThreadListFilters} > - - {shouldRenderNavigationRail ? ( - 0 ? "auto" : "no-hide-descendants"} - pointerEvents={navigationRailWidth > 0 ? "auto" : "none"} - style={railAnimatedStyle} - > - - - ) : null} - {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( - - - ( - - )} - > - - - - - - - - - ) : null} + + + {shouldRenderNavigationRail ? ( + 0 ? "auto" : "no-hide-descendants"} + pointerEvents={navigationRailWidth > 0 ? "auto" : "none"} + style={railAnimatedStyle} + > + + + ) : null} + {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( + + + ( + + )} + > + + + + + + + + + ) : null} - - {props.children} - + + {props.children} + + + - - + ); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 7afe454ccb69d..66be152db074b 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -929,7 +929,8 @@ function ThreadNavigationSidebarPane( className={ Platform.OS === "android" ? "flex-1 bg-header" : "flex-1 border-r border-border bg-drawer" } - style={{ width: props.width }} + // Android fills its pane, which animates its width while a hinge resizes the split. + style={Platform.OS === "android" ? undefined : { width: props.width }} > { }); }); }); + +describe("alignListPaneToHinge", () => { + const layout = deriveLayout({ width: 852, height: 880 }); + + it("ends the list pane at a book-posture hinge after the rail", () => { + expect( + alignListPaneToHinge({ + layout, + hinge: { x: 426, width: 0 }, + viewportWidth: 852, + leadingWidth: 96, + }), + ).toMatchObject({ listPaneWidth: 330, listPaneGap: 0 }); + }); + + it("keeps the default split when either side of the hinge would be too narrow", () => { + const narrowList = { + layout, + hinge: { x: 300, width: 0 }, + viewportWidth: 852, + leadingWidth: 96, + }; + const narrowDetail = { + layout, + hinge: { x: 600, width: 0 }, + viewportWidth: 852, + leadingWidth: 0, + }; + expect(alignListPaneToHinge(narrowList)).toBe(layout); + expect(alignListPaneToHinge(narrowDetail)).toBe(layout); + }); +}); diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index 49592afc6236b..46995759f22ef 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -131,6 +131,33 @@ export function deriveLayout(input: { }; } +export interface VerticalHinge { + readonly x: number; + readonly width: number; +} + +/** + * Moves the list/detail split onto a book-posture hinge so neither pane straddles the crease. + * `leadingWidth` is chrome left of the list pane, such as the Android navigation rail. + */ +export function alignListPaneToHinge(input: { + readonly layout: Layout; + readonly hinge: VerticalHinge | null; + readonly viewportWidth: number; + readonly leadingWidth: number; +}): Layout { + const { layout, hinge } = input; + if (!hinge || !layout.usesSplitView) return layout; + const listPaneWidth = hinge.x - input.leadingWidth; + if ( + listPaneWidth < SPLIT_SIDEBAR_MIN_WIDTH || + input.viewportWidth - hinge.x - hinge.width < 320 + ) { + return layout; + } + return { ...layout, listPaneWidth, listPaneGap: hinge.width }; +} + export function deriveWorkspacePaneLayout(input: { readonly layout: Layout; readonly viewportWidth: number; diff --git a/apps/mobile/src/lib/menu-placement.test.ts b/apps/mobile/src/lib/menu-placement.test.ts new file mode 100644 index 0000000000000..35721d910f5ea --- /dev/null +++ b/apps/mobile/src/lib/menu-placement.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { deriveMenuHorizontalPlacement } from "./menu-placement"; + +const base = { anchorWidth: 48, overlayWidth: 852, desiredWidth: 224, margin: 12 }; + +describe("deriveMenuHorizontalPlacement", () => { + it("opens toward the roomier side of the whole screen without a hinge", () => { + expect(deriveMenuHorizontalPlacement({ ...base, anchorX: 300, hinge: null })).toEqual({ + left: 300, + width: 224, + }); + }); + + it("keeps a menu on its anchor's side of a creased hinge", () => { + // The filter button sits near the crease, so the menu right-aligns to it instead of crossing. + expect( + deriveMenuHorizontalPlacement({ ...base, anchorX: 340, hinge: { x: 426, width: 0 } }), + ).toEqual({ left: 164, width: 224 }); + expect( + deriveMenuHorizontalPlacement({ ...base, anchorX: 430, hinge: { x: 426, width: 0 } }), + ).toEqual({ left: 438, width: 224 }); + }); +}); diff --git a/apps/mobile/src/lib/menu-placement.ts b/apps/mobile/src/lib/menu-placement.ts new file mode 100644 index 0000000000000..4de71bdea416b --- /dev/null +++ b/apps/mobile/src/lib/menu-placement.ts @@ -0,0 +1,28 @@ +import type { VerticalHinge } from "./layout"; + +/** + * Places a menu beside its anchor within the screen region that holds it. A creased hinge splits + * the overlay into two regions, so a menu never straddles the fold. + */ +export function deriveMenuHorizontalPlacement(input: { + readonly anchorX: number; + readonly anchorWidth: number; + readonly overlayWidth: number; + readonly desiredWidth: number; + readonly margin: number; + /** In the overlay's coordinates. */ + readonly hinge: VerticalHinge | null; +}) { + const center = input.anchorX + input.anchorWidth / 2; + let start = 0; + let end = input.overlayWidth; + if (input.hinge) { + if (center < input.hinge.x) end = input.hinge.x; + else start = input.hinge.x + input.hinge.width; + } + const width = Math.min(input.desiredWidth, Math.max(0, end - start - 2 * input.margin)); + const preferredLeft = + center <= (start + end) / 2 ? input.anchorX : input.anchorX + input.anchorWidth - width; + const left = Math.min(Math.max(preferredLeft, start + input.margin), end - width - input.margin); + return { left, width }; +} diff --git a/apps/mobile/src/native/AndroidHingeObserver.android.tsx b/apps/mobile/src/native/AndroidHingeObserver.android.tsx new file mode 100644 index 0000000000000..39a1594b536d2 --- /dev/null +++ b/apps/mobile/src/native/AndroidHingeObserver.android.tsx @@ -0,0 +1,29 @@ +import { requireNativeView } from "expo"; +import type { NativeSyntheticEvent, ViewProps } from "react-native"; +import type { VerticalHinge } from "../lib/layout"; + +interface ObserverProps extends ViewProps { + readonly onHingeChange: ( + event: NativeSyntheticEvent>, + ) => void; +} + +const Observer = requireNativeView("T3FoldPosture"); + +/** Reports a vertical hinge in this view's coordinates while the device is bent like a book. */ +export function AndroidHingeObserver(props: { + /** Receives the hinge in this view's coordinates and in window coordinates. */ + readonly onChange: (hinge: VerticalHinge | null, windowHinge: VerticalHinge | null) => void; +}) { + return ( + + x === undefined || width === undefined || windowX === undefined + ? props.onChange(null, null) + : props.onChange({ x, width }, { x: windowX, width }) + } + style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }} + /> + ); +} diff --git a/apps/mobile/src/native/AndroidHingeObserver.tsx b/apps/mobile/src/native/AndroidHingeObserver.tsx new file mode 100644 index 0000000000000..164ce6211a51f --- /dev/null +++ b/apps/mobile/src/native/AndroidHingeObserver.tsx @@ -0,0 +1,7 @@ +import type { VerticalHinge } from "../lib/layout"; + +export function AndroidHingeObserver(_props: { + readonly onChange: (hinge: VerticalHinge | null, windowHinge: VerticalHinge | null) => void; +}) { + return null; +} diff --git a/apps/mobile/src/native/window-hinge-context.ts b/apps/mobile/src/native/window-hinge-context.ts new file mode 100644 index 0000000000000..f2e031b335d0a --- /dev/null +++ b/apps/mobile/src/native/window-hinge-context.ts @@ -0,0 +1,5 @@ +import { createContext } from "react"; +import type { VerticalHinge } from "../lib/layout"; + +/** A creased vertical hinge in window coordinates, for overlays placed in window space. */ +export const WindowHingeContext = createContext(null);