Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions __tests__/core/contentMetrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
23 changes: 23 additions & 0 deletions src/core/clampScrollOffset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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<ScrollTarget>) {
const state = ctx.state;
const minOffset = 0 - getContentInsetTop(ctx);
Expand Down
13 changes: 3 additions & 10 deletions src/core/mvcp.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 (
Expand Down
5 changes: 3 additions & 2 deletions src/core/updateContentMetrics.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -26,7 +27,6 @@ function shouldAdjustForHeaderSizeChange(ctx: StateContext, previousHeaderSize:
const previousHeaderEnd = (leadingPadding || 0) + previousHeaderSize;

return (
Platform.OS === "web" &&
props.maintainVisibleContentPosition.size &&
didContainersLayout &&
didFinishInitialScroll &&
Expand All @@ -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));
}
}

Expand Down