Skip to content

Commit c7d8814

Browse files
fix(mobile): Duo lists no longer stutter when the header minimizes
On the iPhone Duo, iOS 27 minimizes the navigation bar on scroll down by default, and #24235 registered Home's list with its screen so UIKit now does. While the bar minimizes, the safe-area top shrinks frame by frame. Home and the pull request screens set their scroll insets and resting offset from that value, so each frame moved the content, UIKit read the jump as a reversed scroll and restored the bar, and the list stuttered in place. These screens now take the expanded bar's top inset, held until the window resizes (folding, rotation), which is the only time it drops for good. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 22dc1da commit c7d8814

7 files changed

Lines changed: 63 additions & 13 deletions

File tree

‎apps/mobile/src/features/home/HomeScreen.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
import { ScrollViewMarker } from "react-native-screens";
3232
import { GestureDetector, useNativeGesture } from "react-native-gesture-handler";
3333
import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
34-
import { useSafeAreaInsets } from "react-native-safe-area-context";
34+
import { useScrollSafeAreaInsets } from "../../native/useScrollSafeAreaInsets";
3535

3636
import { cn } from "../../lib/cn";
3737
import {
@@ -218,7 +218,7 @@ function HomeTopContentSpacer() {
218218
}
219219

220220
function HomeScrollView(props: ComponentProps<typeof ScrollView>) {
221-
const insets = useSafeAreaInsets();
221+
const insets = useScrollSafeAreaInsets();
222222
const primaryColumn = use(NativePrimaryColumnContext);
223223
const restingOffset = useRestingTopOffset(insets.top, props);
224224
if (Platform.OS !== "ios") return <ScrollView {...props} />;
@@ -271,7 +271,7 @@ export function HomeScreen(props: HomeScreenProps) {
271271
const queuedThreadKeys = useQueuedThreadKeys();
272272
const openSwipeableRef = useRef<SwipeableMethods | null>(null);
273273
const homeScrollGesture = useNativeGesture();
274-
const insets = useSafeAreaInsets();
274+
const insets = useScrollSafeAreaInsets();
275275
const { fabClearance } = useAndroidControlSizing();
276276
const screenMetrics = useNativeLayoutMetrics();
277277
const contentSideInsets = usesNativeWorkspaceColumns

‎apps/mobile/src/features/pull-requests/PullRequestReviewersScreen.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
View,
1616
type ScrollViewProps,
1717
} from "react-native";
18-
import { useSafeAreaInsets } from "react-native-safe-area-context";
18+
import { useScrollSafeAreaInsets } from "../../native/useScrollSafeAreaInsets";
1919
import { ScrollViewMarker } from "react-native-screens";
2020
import { AppText as Text, AppTextInput as TextInput } from "../../components/ui/AppText";
2121
import { EmptyState } from "../../components/ui/EmptyState";
@@ -35,7 +35,7 @@ import { PullRequestAvatar } from "./PullRequestPresentation";
3535
import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff";
3636

3737
function ReviewerScrollView(props: ScrollViewProps) {
38-
const insets = useSafeAreaInsets();
38+
const insets = useScrollSafeAreaInsets();
3939
const restingOffset = useRestingTopOffset(insets.top, props);
4040
return (
4141
<ScrollViewMarker style={{ flex: 1 }}>
@@ -52,7 +52,7 @@ function ReviewerScrollView(props: ScrollViewProps) {
5252

5353
export function PullRequestReviewersScreen({ route }: StaticScreenProps<PullRequestParams>) {
5454
const navigation = useNavigation();
55-
const insets = useSafeAreaInsets();
55+
const insets = useScrollSafeAreaInsets();
5656
const listRef = useRef<FlatList<PullRequestReviewerCandidate>>(null);
5757
const nativeMetrics = useNativeLayoutMetrics();
5858
const integratedSearch =

‎apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ import {
5757
} from "react-native";
5858
import { NativeScrollViewMarker as ScrollViewMarker } from "../../native/NativeScrollViewMarker";
5959
import { useRestingTopOffset } from "../../native/useRestingTopOffset";
60-
import { useSafeAreaInsets } from "react-native-safe-area-context";
60+
import { useScrollSafeAreaInsets } from "../../native/useScrollSafeAreaInsets";
6161

6262
import { AppText as Text } from "../../components/ui/AppText";
6363
import { AndroidScreenHeader } from "../../components/ui/AndroidScreenHeader";
@@ -117,7 +117,7 @@ import { usePullRequestEnvironmentFallback } from "./usePullRequestEnvironmentFa
117117
const EMPTY_IDS: readonly string[] = [];
118118

119119
function PullRequestTabScrollView(props: ScrollViewProps) {
120-
const insets = useSafeAreaInsets();
120+
const insets = useScrollSafeAreaInsets();
121121
const restingOffset = useRestingTopOffset(insets.top, props);
122122
return (
123123
<ScrollViewMarker style={{ flex: 1 }}>
@@ -282,7 +282,7 @@ export function PullRequestRouteScreen({ route }: StaticScreenProps<PullRequestP
282282
const returnToList = useAndroidWorkspaceBack("pullRequests");
283283
const { layout } = useAdaptiveWorkspaceLayout();
284284
const navigation = useNavigation();
285-
const insets = useSafeAreaInsets();
285+
const insets = useScrollSafeAreaInsets();
286286
const [toolbarHeight, setToolbarHeight] = usePullRequestToolbarHeight(insets.bottom);
287287
const params = route.params;
288288
usePullRequestEnvironmentFallback(params);
@@ -768,7 +768,7 @@ export function PullRequestFileRouteScreen({
768768
>) {
769769
const { layout } = useAdaptiveWorkspaceLayout();
770770
const navigation = useNavigation();
771-
const insets = useSafeAreaInsets();
771+
const insets = useScrollSafeAreaInsets();
772772
const [toolbarHeight, setToolbarHeight] = usePullRequestToolbarHeight(insets.bottom);
773773
const commit = route.params.commit;
774774
const result = usePullRequestDiff(route.params);

‎apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
type ScrollViewProps,
1818
} from "react-native";
1919
import { ScrollViewMarker } from "react-native-screens";
20-
import { useSafeAreaInsets } from "react-native-safe-area-context";
20+
import { useScrollSafeAreaInsets } from "../../native/useScrollSafeAreaInsets";
2121

2222
import { AppText as Text } from "../../components/ui/AppText";
2323
import { EmptyState } from "../../components/ui/EmptyState";
@@ -46,7 +46,7 @@ import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext";
4646
import { useAndroidWorkspaceSelection } from "../workspace/useAndroidWorkspaceSelection";
4747

4848
function PullRequestScrollView(props: ScrollViewProps) {
49-
const insets = useSafeAreaInsets();
49+
const insets = useScrollSafeAreaInsets();
5050
const restingOffset = useRestingTopOffset(insets.top, props);
5151
return (
5252
<ScrollViewMarker style={{ flex: 1 }}>
@@ -94,7 +94,7 @@ export function PullRequestsScreen(
9494
const workspace = useWorkspaceTabs()!;
9595
const { environments } = useWorkspaceState();
9696
const configs = useAtomValue(environmentServerConfigsAtom);
97-
const insets = useSafeAreaInsets();
97+
const insets = useScrollSafeAreaInsets();
9898
const [initialOffset] = useState(() => workspace.getScrollOffset("pullRequests"));
9999
const listRef = useRef<LegendListRef>(null);
100100
const tab = workspace.state.tabs.pullRequests;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { describe, expect, it } from "vite-plus/test";
2+
3+
import { holdExpandedTopInset } from "./holdExpandedTopInset";
4+
5+
describe("holdExpandedTopInset", () => {
6+
it("keeps the expanded bar's inset while it minimizes, and drops it when the window resizes", () => {
7+
const unfolded = { width: 669, height: 951 };
8+
// Top insets the Duo's Home reported while its bar minimized mid-drag.
9+
const held = [82, 61, 46, 78].reduce(
10+
(current, top) => holdExpandedTopInset(current, { ...unfolded, top }),
11+
{ ...unfolded, top: 0 },
12+
);
13+
expect(held.top).toBe(82);
14+
expect(holdExpandedTopInset(held, { width: 466, height: 678, top: 54 }).top).toBe(54);
15+
});
16+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Kept free of react-native imports so it stays unit-testable in node.
2+
3+
export type HeldTopInset = {
4+
readonly width: number;
5+
readonly height: number;
6+
readonly top: number;
7+
};
8+
9+
/**
10+
* A minimizing navigation bar (iOS 27, the Duo's default) lowers the safe-area top on every
11+
* scroll frame. Only a window resize, from folding or rotation, lowers it for good.
12+
*/
13+
export function holdExpandedTopInset(held: HeldTopInset, next: HeldTopInset): HeldTopInset {
14+
const sameWindow = held.width === next.width && held.height === next.height;
15+
return sameWindow && held.top >= next.top ? held : next;
16+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { useState } from "react";
2+
import { useWindowDimensions } from "react-native";
3+
import { useSafeAreaInsets } from "react-native-safe-area-context";
4+
import { holdExpandedTopInset, type HeldTopInset } from "./holdExpandedTopInset";
5+
6+
/**
7+
* Safe-area insets for scroll views that set their own top inset. Following the live top while
8+
* the bar minimizes moves the content under the finger, which UIKit reads as a reversed scroll
9+
* that restores the bar, and the list stutters between the two.
10+
*/
11+
export function useScrollSafeAreaInsets() {
12+
const insets = useSafeAreaInsets();
13+
const { width, height } = useWindowDimensions();
14+
const [held, setHeld] = useState<HeldTopInset>({ width, height, top: insets.top });
15+
const next = holdExpandedTopInset(held, { width, height, top: insets.top });
16+
if (next !== held) setHeld(next);
17+
return next.top === insets.top ? insets : { ...insets, top: next.top };
18+
}

0 commit comments

Comments
 (0)