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)); } }