From e56e135b54d151a50f4e5f1c513b0a84d115aed6 Mon Sep 17 00:00:00 2001 From: Christoph Pader Date: Wed, 11 Nov 2026 12:37:31 +0100 Subject: [PATCH] fix: keep native content in place when a header above the viewport resizes setHeaderSize only compensated header size changes on web. On native, maintainVisibleContentPosition anchors to the absolutely positioned ScrollAdjust view, which a header resize does not move, so the native scroll offset stayed where it was and the visible items moved by the size difference. For example, removing a loading placeholder rendered as the list header shifted the rows the user was reading. Header changes above the viewport now request the same adjustment on every platform. On Android, shrinking content can first clamp the scroll offset to the new end. The MVCP item-resize path already handled that clamp, so it moves to a shared helper that both paths use. Co-Authored-By: Claude Opus 5.5 --- __tests__/core/contentMetrics.test.ts | 74 +++++++++++++++++++++++++++ src/core/clampScrollOffset.ts | 23 +++++++++ src/core/mvcp.ts | 13 ++--- src/core/updateContentMetrics.ts | 5 +- 4 files changed, 103 insertions(+), 12 deletions(-) diff --git a/__tests__/core/contentMetrics.test.ts b/__tests__/core/contentMetrics.test.ts index 75b16d78..3e09504b 100644 --- a/__tests__/core/contentMetrics.test.ts +++ b/__tests__/core/contentMetrics.test.ts @@ -511,6 +511,80 @@ describe("updateContentMetrics", () => { } }); + for (const os of ["ios", "android"] as const) { + it(`compensates native MVCP when a measured header changes above the viewport (${os})`, () => { + const prevPlatform = Platform.OS; + Platform.OS = os; + const requestAdjustSpy = spyOn(requestAdjustModule, "requestAdjust"); + const ctx = createMockContext( + { + headerSize: 60, + readyToRender: true, + totalSize: 1000, + }, + { + didContainersLayout: true, + didFinishInitialScroll: true, + didMeasureHeader: true, + props: { + data: [1], + maintainVisibleContentPosition: { data: false, size: true }, + }, + scroll: 200, + scrollLength: 500, + totalSize: 1000, + }, + ); + + try { + setHeaderSize(ctx, 120); + + expect(requestAdjustSpy).toHaveBeenCalledWith(ctx, 60); + } finally { + requestAdjustSpy.mockRestore(); + Platform.OS = prevPlatform; + } + }); + } + + it("syncs the Android clamp before compensating a header that shrinks near the end", () => { + const prevPlatform = Platform.OS; + Platform.OS = "android"; + const requestAdjustSpy = spyOn(requestAdjustModule, "requestAdjust"); + const ctx = createMockContext( + { + headerSize: 300, + readyToRender: true, + totalSize: 700, + }, + { + didContainersLayout: true, + didFinishInitialScroll: true, + didMeasureHeader: true, + lastLayout: { height: 500, width: 300, x: 0, y: 0 }, + props: { + data: [1], + maintainVisibleContentPosition: { data: false, size: true }, + }, + scroll: 450, + scrollLength: 500, + totalSize: 700, + }, + ); + + try { + setHeaderSize(ctx, 100); + + // The header and rows fill 1000 points. The 200-point shrink leaves 800, so Android clamps the offset to 300 before the + // adjustment lands. Only the remaining 50 points are adjusted, which ends at the kept position, 250. + expect(requestAdjustSpy).toHaveBeenCalledWith(ctx, -50); + expect(ctx.state.scroll).toBe(250); + } finally { + requestAdjustSpy.mockRestore(); + Platform.OS = prevPlatform; + } + }); + it("does not compensate the initial web MVCP header measurement", () => { const prevPlatform = Platform.OS; Platform.OS = "web"; diff --git a/src/core/clampScrollOffset.ts b/src/core/clampScrollOffset.ts index a963eb96..d4cc6e1b 100644 --- a/src/core/clampScrollOffset.ts +++ b/src/core/clampScrollOffset.ts @@ -3,6 +3,8 @@ import { getContentSize } from "@/state/getContentSize"; import type { StateContext } from "@/state/state"; import type { ScrollTarget } from "@/types.internal"; +const ANDROID_SHRINK_CLAMP_EPSILON = 0.1; + export function getContentInsetTop(ctx: StateContext) { const state = ctx.state; if (Platform.OS !== "ios" || state.props.horizontal) { @@ -16,6 +18,27 @@ export function getScrollRangeEnd(ctx: StateContext, contentSize = getContentSiz return Math.max(0 - getContentInsetTop(ctx), contentSize - ctx.state.scrollLength); } +/** + * Android clamps the scroll offset when content shrinks, before native MVCP applies an anchor move. Syncs the JS + * scroll state to that clamp and returns the part of the adjustment the clamp did not already apply. + */ +export function getAdjustAfterAndroidShrinkClamp(ctx: StateContext, positionDiff: number) { + const state = ctx.state; + if (Platform.OS !== "android") { + return positionDiff; + } + + const maxScroll = getScrollRangeEnd(ctx); + if (state.scroll <= maxScroll + ANDROID_SHRINK_CLAMP_EPSILON) { + return positionDiff; + } + + const targetScroll = Math.max(0, Math.min(state.scroll + positionDiff, maxScroll)); + state.scroll = maxScroll; + state.scrollPending = maxScroll; + return targetScroll - maxScroll; +} + export function clampScrollOffset(ctx: StateContext, offset: number, scrollTarget?: Partial) { const state = ctx.state; const minOffset = 0 - getContentInsetTop(ctx); diff --git a/src/core/mvcp.ts b/src/core/mvcp.ts index 2540d42f..7340a34d 100644 --- a/src/core/mvcp.ts +++ b/src/core/mvcp.ts @@ -1,6 +1,6 @@ import { IsNewArchitecture } from "@/constants-platform"; import { getViewPositionOffset } from "@/core/calculateOffsetWithOffsetPosition"; -import { getContentInsetTop, getScrollRangeEnd } from "@/core/clampScrollOffset"; +import { getAdjustAfterAndroidShrinkClamp, getContentInsetTop, getScrollRangeEnd } from "@/core/clampScrollOffset"; import { shouldMaintainScrollAtEndOnDataChange } from "@/core/shouldMaintainScrollAtEndOnDataChange"; import { Platform } from "@/platform/Platform"; import { getContentInsetEnd } from "@/state/getContentInsetEnd"; @@ -465,15 +465,8 @@ export function prepareMVCP( positionDiff, }); - if (Platform.OS === "android" && !dataChanged && scrollTarget === undefined) { - const maxScroll = getScrollRangeEnd(ctx); - if (state.scroll > maxScroll + MVCP_POSITION_EPSILON) { - // Android clamps shrinking content before native MVCP applies the anchor delta. - const targetScroll = Math.max(0, Math.min(state.scroll + positionDiff, maxScroll)); - state.scroll = maxScroll; - state.scrollPending = maxScroll; - positionDiff = targetScroll - maxScroll; - } + if (!dataChanged && scrollTarget === undefined) { + positionDiff = getAdjustAfterAndroidShrinkClamp(ctx, positionDiff); } if ( diff --git a/src/core/updateContentMetrics.ts b/src/core/updateContentMetrics.ts index d6e2e643..5fba4d1d 100644 --- a/src/core/updateContentMetrics.ts +++ b/src/core/updateContentMetrics.ts @@ -1,3 +1,4 @@ +import { getAdjustAfterAndroidShrinkClamp } from "@/core/clampScrollOffset"; import { Platform } from "@/platform/Platform"; import { peek$, type StateContext, set$ } from "@/state/state"; import type { Insets } from "@/types.base"; @@ -26,7 +27,6 @@ function shouldAdjustForHeaderSizeChange(ctx: StateContext, previousHeaderSize: const previousHeaderEnd = (leadingPadding || 0) + previousHeaderSize; return ( - Platform.OS === "web" && props.maintainVisibleContentPosition.size && didContainersLayout && didFinishInitialScroll && @@ -49,8 +49,9 @@ export function setHeaderSize(ctx: StateContext, size: number) { // Do not compensate the initial measurement from no known header. // If a zero/estimated header was already recorded, this layout change // is a real content shift above the viewport and should preserve MVCP. + // Native MVCP anchors to an absolutely positioned view that a header resize does not move, so move it here. if (hasMeasuredOrEstimatedHeaderBaseline && shouldAdjustForHeaderSizeChange(ctx, previousHeaderSize, size)) { - requestAdjust(ctx, size - previousHeaderSize); + requestAdjust(ctx, getAdjustAfterAndroidShrinkClamp(ctx, size - previousHeaderSize)); } }