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
182 changes: 115 additions & 67 deletions __tests__/core/contentMetrics.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it, spyOn } from "bun:test";
import { clampScrollOffset } from "../../src/core/clampScrollOffset";
import { ScrollAdjustHandler } from "../../src/core/ScrollAdjustHandler";
import { setContentInsetOverride, setFooterSize, setHeaderSize } from "../../src/core/updateContentMetrics";
import { updateContentMetricsState } from "../../src/core/updateContentMetricsState";
import { Platform } from "../../src/platform/Platform";
Expand Down Expand Up @@ -201,42 +202,87 @@ describe("updateContentMetrics", () => {
expect(ctx.values.get("footerSize")).toBe(12);
});

it("compensates web MVCP when a measured header changes above the viewport", () => {
const prevPlatform = Platform.OS;
Platform.OS = "web";
const requestAdjustSpy = spyOn(requestAdjustModule, "requestAdjust");
const ctx = createMockContext(
{
headerSize: 60,
readyToRender: true,
totalSize: 1000,
},
{
didContainersLayout: true,
didFinishInitialScroll: true,
props: {
data: [1],
maintainVisibleContentPosition: { data: false, size: true },
for (const platformOS of ["web", "ios", "android"] as const) {
it(`compensates MVCP on ${platformOS} when a measured header changes above the viewport`, () => {
const prevPlatform = Platform.OS;
Platform.OS = platformOS;
const requestAdjustSpy = spyOn(requestAdjustModule, "requestAdjust");
const ctx = createMockContext(
{
headerSize: 60,
readyToRender: true,
totalSize: 1000,
},
scroll: 200,
scrollLength: 500,
totalSize: 1000,
},
);

try {
setHeaderSize(ctx, 60);
expect(requestAdjustSpy).not.toHaveBeenCalled();

requestAdjustSpy.mockClear();
setHeaderSize(ctx, 120);

expect(requestAdjustSpy).toHaveBeenCalledWith(ctx, 60);
} finally {
requestAdjustSpy.mockRestore();
Platform.OS = prevPlatform;
}
});
{
didContainersLayout: true,
didFinishInitialScroll: true,
props: {
data: [1],
maintainVisibleContentPosition: { data: false, size: true },
},
scroll: 200,
scrollLength: 500,
totalSize: 1000,
},
);

try {
setHeaderSize(ctx, 60);
expect(requestAdjustSpy).not.toHaveBeenCalled();

requestAdjustSpy.mockClear();
setHeaderSize(ctx, 120);

expect(requestAdjustSpy).toHaveBeenCalledWith(ctx, 60);

requestAdjustSpy.mockClear();
setHeaderSize(ctx, 60);

expect(requestAdjustSpy).toHaveBeenCalledWith(ctx, -60);
} finally {
requestAdjustSpy.mockRestore();
Platform.OS = prevPlatform;
}
});

it(`settles the scroll position on ${platformOS} when a measured header grows above the viewport`, () => {
const prevPlatform = Platform.OS;
Platform.OS = platformOS;
const ctx = createMockContext(
{
headerSize: 60,
readyToRender: true,
totalSize: 1000,
},
{
didContainersLayout: true,
didFinishInitialScroll: true,
props: {
data: [1],
maintainVisibleContentPosition: { data: false, size: true },
},
scroll: 200,
scrollLength: 500,
totalSize: 1000,
},
);
// Drive the real adjust handler so the assertion is about the resulting scroll
// position the user sees, not just that an adjustment was requested.
ctx.state.scrollAdjustHandler = new ScrollAdjustHandler(ctx);

try {
setHeaderSize(ctx, 60);
setHeaderSize(ctx, 120);

// The header grew by 60 above the viewport, so the same content stays put.
expect(ctx.state.scroll).toBe(260);
expect(ctx.values.get("scrollAdjust")).toBe(60);
} finally {
ctx.state.scheduledWork.dispose();
Platform.OS = prevPlatform;
}
});
}

it("does not compensate the initial web MVCP header measurement", () => {
const prevPlatform = Platform.OS;
Expand Down Expand Up @@ -339,37 +385,39 @@ describe("updateContentMetrics", () => {
}
});

it("does not compensate web MVCP header changes while the header is visible", () => {
const prevPlatform = Platform.OS;
Platform.OS = "web";
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 },
for (const platformOS of ["web", "ios", "android"] as const) {
it(`does not compensate MVCP header changes on ${platformOS} while the header is visible`, () => {
const prevPlatform = Platform.OS;
Platform.OS = platformOS;
const requestAdjustSpy = spyOn(requestAdjustModule, "requestAdjust");
const ctx = createMockContext(
{
headerSize: 60,
readyToRender: true,
totalSize: 1000,
},
scroll: 20,
scrollLength: 500,
totalSize: 1000,
},
);

try {
setHeaderSize(ctx, 120);

expect(requestAdjustSpy).not.toHaveBeenCalled();
} finally {
requestAdjustSpy.mockRestore();
Platform.OS = prevPlatform;
}
});
{
didContainersLayout: true,
didFinishInitialScroll: true,
didMeasureHeader: true,
props: {
data: [1],
maintainVisibleContentPosition: { data: false, size: true },
},
scroll: 20,
scrollLength: 500,
totalSize: 1000,
},
);

try {
setHeaderSize(ctx, 120);

expect(requestAdjustSpy).not.toHaveBeenCalled();
} finally {
requestAdjustSpy.mockRestore();
Platform.OS = prevPlatform;
}
});
}
});
135 changes: 135 additions & 0 deletions example/screens/fixtures/header-mvcp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { useRef, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";

import { LegendList, type LegendListRef } from "@legendapp/list/react-native";

const ROW_HEIGHT = 72;
const INITIAL_HEADER_HEIGHT = 96;
const ANCHOR_ROW_INDEX = 20;
const DATA = Array.from({ length: 80 }, (_, index) => ({
id: String(index),
title: `Row ${index}`,
}));

type RowItem = (typeof DATA)[number];

function Header({ height }: { height: number }) {
return (
<View style={[styles.header, { height }]}>
<Text style={styles.headerTitle}>Measured ListHeaderComponent</Text>
<Text style={styles.headerSubtitle}>height: {height}</Text>
</View>
);
}

export default function HeaderMvcpFixture() {
const listRef = useRef<LegendListRef>(null);
const [headerHeight, setHeaderHeight] = useState(INITIAL_HEADER_HEIGHT);
const [scrollOffset, setScrollOffset] = useState(0);

return (
<View style={styles.container}>
<LegendList<RowItem>
data={DATA}
estimatedItemSize={ROW_HEIGHT}
initialScrollIndex={ANCHOR_ROW_INDEX}
keyExtractor={(item) => item.id}
ListHeaderComponent={<Header height={headerHeight} />}
maintainVisibleContentPosition={{ data: false, size: true }}
onScroll={(event) => setScrollOffset(event.nativeEvent.contentOffset.y)}
recycleItems
ref={listRef}
renderItem={({ item }) => (
<View style={styles.row}>
<Text style={styles.rowText}>{item.title}</Text>
</View>
)}
style={styles.list}
/>

<View style={styles.controls}>
<Text style={styles.readout}>scrollOffset: {Math.round(scrollOffset)}</Text>
<View style={styles.buttonRow}>
<Pressable onPress={() => setHeaderHeight((value) => value + 80)} style={styles.button}>
<Text style={styles.buttonText}>Grow header</Text>
</Pressable>
<Pressable
onPress={() => setHeaderHeight((value) => Math.max(24, value - 80))}
style={styles.button}
>
<Text style={styles.buttonText}>Shrink header</Text>
</Pressable>
<Pressable
onPress={() => listRef.current?.scrollToOffset({ animated: false, offset: 0 })}
style={styles.button}
>
<Text style={styles.buttonText}>Show header</Text>
</Pressable>
</View>
</View>
</View>
);
}

const styles = StyleSheet.create({
button: {
backgroundColor: "#1e3a8a",
borderRadius: 6,
paddingHorizontal: 12,
paddingVertical: 8,
},
buttonRow: {
flexDirection: "row",
gap: 8,
},
buttonText: {
color: "#ffffff",
fontSize: 13,
},
container: {
backgroundColor: "#ffffff",
flex: 1,
},
controls: {
backgroundColor: "#f8fafc",
borderTopColor: "#cbd5e1",
borderTopWidth: 1,
gap: 8,
padding: 12,
},
header: {
backgroundColor: "#dbeafe",
borderBottomColor: "#bfdbfe",
borderBottomWidth: 1,
justifyContent: "center",
paddingHorizontal: 20,
},
headerSubtitle: {
color: "#1e3a8a",
fontSize: 12,
},
headerTitle: {
color: "#1e3a8a",
fontSize: 14,
fontWeight: "600",
},
list: {
flex: 1,
},
readout: {
color: "#0f172a",
fontSize: 13,
fontVariant: ["tabular-nums"],
},
row: {
borderBottomColor: "#dbe3ef",
borderBottomWidth: 1,
height: ROW_HEIGHT,
justifyContent: "center",
paddingHorizontal: 20,
},
rowText: {
color: "#0f172a",
fontSize: 15,
},
});
10 changes: 10 additions & 0 deletions example/screens/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import CountriesWithHeadersFixedFixture from "~/screens/fixtures/countries-with-
import CountriesWithHeadersStickyFixture from "~/screens/fixtures/countries-with-headers-sticky";
import ExtraDataFixture from "~/screens/fixtures/extra-data";
import FilterElementsFixture from "~/screens/fixtures/filter-elements";
import HeaderMvcpFixture from "~/screens/fixtures/header-mvcp";
import HorizontalAlignItemsFixture from "~/screens/fixtures/horizontal-align-items";
import HorizontalCrossAxisFixture from "~/screens/fixtures/horizontal-cross-axis";
import InitialScrollAtEndEmptyFixture from "~/screens/fixtures/initial-scroll-at-end-empty";
Expand Down Expand Up @@ -259,6 +260,15 @@ export const FIXTURE_ROUTES: FixtureRouteDefinition[] = [
slug: "mvcp-test",
title: "MVCP Test",
},
{
component: HeaderMvcpFixture,
description: "Grows and shrinks a measured header while it is above the viewport.",
groupKey: "scroll",
groupTitle: "Scroll & Position",
kind: "fixture",
slug: "header-mvcp",
title: "Header MVCP",
},
{
component: AlwaysRenderFixture,
description: "Keeps nearby cells mounted to inspect render-window behavior.",
Expand Down
2 changes: 0 additions & 2 deletions src/core/updateContentMetrics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Platform } from "@/platform/Platform";
import { peek$, type StateContext, set$ } from "@/state/state";
import type { Insets } from "@/types.base";
import { requestAdjust } from "@/utils/requestAdjust";
Expand All @@ -25,7 +24,6 @@ function shouldAdjustForHeaderSizeChange(ctx: StateContext, previousHeaderSize:
const previousHeaderEnd = (leadingPadding || 0) + previousHeaderSize;

return (
Platform.OS === "web" &&
props.maintainVisibleContentPosition.size &&
didContainersLayout &&
didFinishInitialScroll &&
Expand Down