Skip to content
Merged
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
88 changes: 86 additions & 2 deletions src/__tests__/RecyclerView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ jest.mock("../recyclerview/utils/measureLayout", () => {
return {
...originalModule,
measureParentSize: jest.fn().mockImplementation(() => ({
x: 0,
y: 0,
width: 399,
height: 899,
})),
Expand Down Expand Up @@ -141,4 +139,90 @@ describe("RecyclerView", () => {
expect(ref.current?.props.data).toEqual([0, 1, 2, 3]);
});
});

describe("Sticky headers with content above FlashList", () => {
// Each item is 100px tall (from measureItemLayout mock).
// With stickyHeaderIndices=[0, 5, 10, 15], header 5 sits at y=500.
//
// On Fabric, measureParentSize returns the view's position in its parent
// instead of (0,0). The old code subtracted this from firstItemOffset,
// making sticky headers activate prematurely.
//
// These tests simulate Fabric by mocking measureParentSize to return
// non-zero y, then scroll just before header 5 and assert it hasn't
// activated yet.
const { measureParentSize } = jest.requireMock(
"../recyclerview/utils/measureLayout"
) as { measureParentSize: jest.Mock };

afterEach(() => {
measureParentSize.mockImplementation(() => ({
width: 399,
height: 899,
}));
});

const renderFlashListWithStickyHeaders = (parentViewY: number) => {
measureParentSize.mockImplementation(() => ({
x: 0,
y: parentViewY,
width: 399,
height: 899,
}));

const onChangeStickyIndex = jest.fn();
const result = render(
<FlashList
data={[
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18,
19,
]}
renderItem={({ item }) => <Text>{item}</Text>}
stickyHeaderIndices={[0, 5, 10, 15]}
onChangeStickyIndex={onChangeStickyIndex}
overrideProps={{ initialDrawBatchSize: 1 }}
drawDistance={0}
/>
);

return { result, onChangeStickyIndex };
};

const scrollTo = (root: ReturnType<typeof render>, y: number) => {
const scrollable = root.findWhere((node: any) => node.props.onScroll);
if (!scrollable) throw new Error("Could not find scrollable component");

const onScroll: any = scrollable.prop("onScroll" as never);
root.act(() => {
onScroll({
nativeEvent: {
contentOffset: { x: 0, y },
contentSize: { width: 399, height: 2000 },
layoutMeasurement: { width: 399, height: 899 },
},
});
});
};

it("no content above - header 5 should not activate before y=500", () => {
const { result, onChangeStickyIndex } =
renderFlashListWithStickyHeaders(0);
scrollTo(result, 450);
expect(onChangeStickyIndex).toHaveBeenLastCalledWith(0, -1);
});

it("50px content above - header 5 should not activate before y=500", () => {
const { result, onChangeStickyIndex } =
renderFlashListWithStickyHeaders(50);
scrollTo(result, 450);
expect(onChangeStickyIndex).toHaveBeenLastCalledWith(0, -1);
});

it("100px content above - header 5 should not activate before y=500", () => {
const { result, onChangeStickyIndex } =
renderFlashListWithStickyHeaders(100);
scrollTo(result, 400);
expect(onChangeStickyIndex).toHaveBeenLastCalledWith(0, -1);
});
});
});
19 changes: 9 additions & 10 deletions src/recyclerview/RecyclerView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,29 +157,28 @@ const RecyclerViewComponent = <T,>(
*/
useLayoutEffect(() => {
if (internalViewRef.current && firstChildViewRef.current) {
// Measure the outer and inner container layouts
const outerViewLayout = measureParentSize(internalViewRef.current);
// Measure the outer container size and inner container layout
const outerViewSize = measureParentSize(internalViewRef.current);
const firstChildViewLayout = measureFirstChildLayout(
firstChildViewRef.current,
internalViewRef.current
);

containerViewSizeRef.current = outerViewLayout;
containerViewSizeRef.current = outerViewSize;

// Calculate offset of first item
// firstChildViewLayout is already relative to the outer container,
// so its x/y directly gives the first item offset.
const firstItemOffset = horizontal
? firstChildViewLayout.x - outerViewLayout.x
: firstChildViewLayout.y - outerViewLayout.y;
? firstChildViewLayout.x
: firstChildViewLayout.y;

// Update the RecyclerView manager with window dimensions
recyclerViewManager.updateLayoutParams(
{
width: horizontal
? outerViewLayout.width
: firstChildViewLayout.width,
width: horizontal ? outerViewSize.width : firstChildViewLayout.width,
height: horizontal
? firstChildViewLayout.height
: outerViewLayout.height,
: outerViewSize.height,
},
isHorizontalRTL && recyclerViewManager.hasLayout()
? firstItemOffset -
Expand Down
18 changes: 12 additions & 6 deletions src/recyclerview/utils/measureLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ interface Layout {
height: number;
}

interface Size {
width: number;
height: number;
}

/**
* Measures the layout of a view relative to itselft.
* Using measure wasn't returing accurate values but this workaround does.
Expand Down Expand Up @@ -89,14 +94,15 @@ export function roundOffPixel(value: number): number {
}

/**
* Specific method for easier mocking
* Measures the layout of parent of RecyclerView
* Returns the x, y coordinates and dimensions of the view.
* Measures the size of the RecyclerView's outer container.
* Uses a self-relative measureLayout call to get width/height synchronously.
*
* @param view - The React Native View component to measure
* @returns An object containing x, y, width, and height measurements
* @returns An object containing width and height
*/
export function measureParentSize(view: View): Layout {
return measureLayout(view, undefined);
export function measureParentSize(view: View): Size {
const layout = measureLayout(view, undefined);
return { width: layout.width, height: layout.height };
}

/**
Expand Down
11 changes: 7 additions & 4 deletions src/recyclerview/utils/measureLayout.web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ interface Layout {
height: number;
}

interface Size {
width: number;
height: number;
}

/**
* Gets scroll offsets from up to 3 parent elements
*/
Expand Down Expand Up @@ -43,12 +48,10 @@ export function roundOffPixel(value: number): number {
}

/**
* Measures the layout of parent of RecyclerView
* Measures the size of the RecyclerView's outer container.
*/
export function measureParentSize(view: Element): Layout {
export function measureParentSize(view: Element): Size {
return {
x: 0,
y: 0,
width: view.clientWidth,
height: view.clientHeight,
};
Expand Down