From f0dfc520e368f6795973a1a6ae665adc39d95bc3 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Thu, 15 Oct 2026 11:49:24 -0700 Subject: [PATCH 1/2] fix(android): keep the split view and menus off the fold crease When a foldable with a vertical hinge is bent like a book, the list and detail panes now split exactly at the crease, and anchored menus stay on their anchor's side of it. This mirrors the existing iOS division handling in deriveLayout. A native view reads the fold from Jetpack WindowManager and the hinge angle sensor. It snaps at 170 degrees and releases at 175: Pixel foldables report the hinge in 5 degree steps and rest at 175 when nearly flat. Devices without a hinge sensor fall back to the half-opened posture. --- .../t3-native-controls/android/build.gradle | 1 + .../t3nativecontrols/T3FoldPostureModule.kt | 122 +++++++++++ .../expo-module.config.json | 3 +- .../src/components/ui/AndroidAnchoredMenu.tsx | 32 +-- .../layout/AdaptiveWorkspaceLayout.tsx | 205 ++++++++++-------- apps/mobile/src/lib/layout.test.ts | 33 +++ apps/mobile/src/lib/layout.ts | 27 +++ apps/mobile/src/lib/menu-placement.test.ts | 24 ++ apps/mobile/src/lib/menu-placement.ts | 28 +++ .../native/AndroidHingeObserver.android.tsx | 29 +++ .../src/native/AndroidHingeObserver.tsx | 7 + .../mobile/src/native/window-hinge-context.ts | 5 + 12 files changed, 410 insertions(+), 106 deletions(-) create mode 100644 apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3FoldPostureModule.kt create mode 100644 apps/mobile/src/lib/menu-placement.test.ts create mode 100644 apps/mobile/src/lib/menu-placement.ts create mode 100644 apps/mobile/src/native/AndroidHingeObserver.android.tsx create mode 100644 apps/mobile/src/native/AndroidHingeObserver.tsx create mode 100644 apps/mobile/src/native/window-hinge-context.ts 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( @@ -667,102 +689,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/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index 7d288b1352a52..b67e1bca54fb0 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + alignListPaneToHinge, constrainAuxiliaryPaneWidth, deriveCenteredContentHorizontalPadding, deriveFileInspectorPaneLayout, @@ -384,3 +385,35 @@ describe("deriveWorkspacePaneLayout", () => { }); }); }); + +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); From f7c1dd21ffe8cca9b93c3a1a157ad89dacf6ead1 Mon Sep 17 00:00:00 2001 From: Pixel Perfect Date: Thu, 15 Oct 2026 12:23:19 -0700 Subject: [PATCH 2/2] fix(android): keep list and detail in step when the hinge resizes the split While a book-posture hinge resizes the split, the list's width follows the animated pane edge, so its header actions slide with it instead of jumping ahead or sliding under the detail pane. Showing and hiding the sidebar still clip a fixed-width list. The detail keeps its wider width until the animation ends and then reflows once. --- .../layout/AdaptiveWorkspaceLayout.tsx | 44 +++++++++++++++---- .../threads/ThreadNavigationSidebar.tsx | 3 +- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index 1207cb8ef5f41..d316ddbcb0263 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -503,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; @@ -563,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), @@ -717,7 +743,7 @@ function AdaptiveWorkspaceLayoutContent( pointerEvents={panes.primarySidebarVisible ? "auto" : "none"} style={sidebarAnimatedStyle} > - + ( - + ) : null}