diff --git a/__tests__/core/contentMetrics.test.ts b/__tests__/core/contentMetrics.test.ts index 001937ec..743a8ec9 100644 --- a/__tests__/core/contentMetrics.test.ts +++ b/__tests__/core/contentMetrics.test.ts @@ -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"; @@ -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; @@ -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; + } + }); + } }); diff --git a/example/screens/fixtures/header-mvcp.tsx b/example/screens/fixtures/header-mvcp.tsx new file mode 100644 index 00000000..f2f07b6d --- /dev/null +++ b/example/screens/fixtures/header-mvcp.tsx @@ -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 ( + + Measured ListHeaderComponent + height: {height} + + ); +} + +export default function HeaderMvcpFixture() { + const listRef = useRef(null); + const [headerHeight, setHeaderHeight] = useState(INITIAL_HEADER_HEIGHT); + const [scrollOffset, setScrollOffset] = useState(0); + + return ( + + + data={DATA} + estimatedItemSize={ROW_HEIGHT} + initialScrollIndex={ANCHOR_ROW_INDEX} + keyExtractor={(item) => item.id} + ListHeaderComponent={
} + maintainVisibleContentPosition={{ data: false, size: true }} + onScroll={(event) => setScrollOffset(event.nativeEvent.contentOffset.y)} + recycleItems + ref={listRef} + renderItem={({ item }) => ( + + {item.title} + + )} + style={styles.list} + /> + + + scrollOffset: {Math.round(scrollOffset)} + + setHeaderHeight((value) => value + 80)} style={styles.button}> + Grow header + + setHeaderHeight((value) => Math.max(24, value - 80))} + style={styles.button} + > + Shrink header + + listRef.current?.scrollToOffset({ animated: false, offset: 0 })} + style={styles.button} + > + Show header + + + + + ); +} + +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, + }, +}); diff --git a/example/screens/routes.tsx b/example/screens/routes.tsx index e9d86267..80c96b40 100644 --- a/example/screens/routes.tsx +++ b/example/screens/routes.tsx @@ -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"; @@ -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.", diff --git a/src/core/updateContentMetrics.ts b/src/core/updateContentMetrics.ts index 717b2aeb..a011e0f3 100644 --- a/src/core/updateContentMetrics.ts +++ b/src/core/updateContentMetrics.ts @@ -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"; @@ -25,7 +24,6 @@ function shouldAdjustForHeaderSizeChange(ctx: StateContext, previousHeaderSize: const previousHeaderEnd = (leadingPadding || 0) + previousHeaderSize; return ( - Platform.OS === "web" && props.maintainVisibleContentPosition.size && didContainersLayout && didFinishInitialScroll &&