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
148 changes: 148 additions & 0 deletions src/__tests__/RecyclerView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,154 @@ describe("RecyclerView", () => {
});
});

describe("scrollToIndex max offset includes content after the items", () => {
// 20 items of 100px in an 899px window: items end at 2000, so the
// layout-based max offset is 2000 - 899 = 1101.
// Centering the last item needs 1900 - (899 - 100) * 0.5 = 1500.5.
const data = Array.from({ length: 20 }, (_, i) => i);

const { measureItemLayout } = jest.requireMock(
"../recyclerview/utils/measureLayout"
) as { measureItemLayout: jest.Mock };

beforeEach(() => {
measureItemLayout.mockImplementation(() => ({
x: 0,
y: 0,
width: 100,
height: 100,
}));
});

const renderList = ({
onContentSizeChange = jest.fn(),
horizontal = false,
}: { onContentSizeChange?: jest.Mock; horizontal?: boolean } = {}) => {
const ref = createRef<FlashListRef<number>>();
const result = render(
<FlashList
ref={ref}
data={data}
horizontal={horizontal}
renderItem={({ item }) => <Text>{item}</Text>}
onContentSizeChange={onContentSizeChange}
/>
);
const scrollToSpy = jest.fn();
const nativeScrollRef = ref.current?.getNativeScrollRef() as any;
expect(nativeScrollRef).toBeTruthy();
nativeScrollRef.scrollTo = scrollToSpy;
return { result, ref, scrollToSpy };
};

const reportContentSize = (
result: ReturnType<typeof render>,
width: number,
height: number
) => {
// The scroll view is the node that receives both onScroll and onContentSizeChange
const scrollable = result.findWhere(
(node: any) => node.props.onScroll && node.props.onContentSizeChange
);
if (!scrollable) throw new Error("Could not find scrollable component");
const onContentSizeChange: any = scrollable.prop(
"onContentSizeChange" as never
);
result.act(() => {
onContentSizeChange(width, height);
});
};

const centerLastItem = (
result: ReturnType<typeof render>,
ref: React.RefObject<FlashListRef<number>>
) => {
result.act(() => {
ref.current?.scrollToIndex({
index: data.length - 1,
viewPosition: 0.5,
animated: false,
});
});
jest.runAllTimers();
};

const lastScrollTo = (scrollToSpy: jest.Mock) =>
scrollToSpy.mock.calls[scrollToSpy.mock.calls.length - 1][0];

const lastScrollToY = (scrollToSpy: jest.Mock) =>
lastScrollTo(scrollToSpy).y;

it("falls back to the layout size before a content size is reported", () => {
const { result, ref, scrollToSpy } = renderList();

centerLastItem(result, ref);

expect(lastScrollToY(scrollToSpy)).toBe(1101);
});

it("scrolls into a footer or bottom padding reported by the scroll view", () => {
const { result, ref, scrollToSpy } = renderList();

// 400px of footer/padding after the items
reportContentSize(result, 399, 2400);
centerLastItem(result, ref);

expect(lastScrollToY(scrollToSpy)).toBe(1500.5);
});

it("still clamps to the end of the reported content", () => {
const { result, ref, scrollToSpy } = renderList();

// Only 200px after the items: not enough room to center the last item
reportContentSize(result, 399, 2200);
centerLastItem(result, ref);

expect(lastScrollToY(scrollToSpy)).toBe(2200 - 899);
});

it("uses the layout size when the reported content size is smaller (stale)", () => {
const { result, ref, scrollToSpy } = renderList();

reportContentSize(result, 399, 1500);
centerLastItem(result, ref);

expect(lastScrollToY(scrollToSpy)).toBe(1101);
});

it("ignores invalid content sizes", () => {
const { result, ref, scrollToSpy } = renderList();

reportContentSize(result, 399, NaN);
reportContentSize(result, 399, -1);
centerLastItem(result, ref);

expect(lastScrollToY(scrollToSpy)).toBe(1101);
});

it("uses the content width for horizontal lists", () => {
// 20 items of 100px in a 399px wide window: layout-based max offset is
// 2000 - 399 = 1601. Centering the last item needs 1900 - (399 - 100) * 0.5 = 1750.5.
const { result, ref, scrollToSpy } = renderList({ horizontal: true });

centerLastItem(result, ref);
expect(lastScrollTo(scrollToSpy).x).toBe(1601);

reportContentSize(result, 2400, 899);
centerLastItem(result, ref);
expect(lastScrollTo(scrollToSpy).x).toBe(1750.5);
});

it("forwards onContentSizeChange to the user's handler", () => {
const onContentSizeChange = jest.fn();
const { result } = renderList({ onContentSizeChange });

reportContentSize(result, 399, 2400);

expect(onContentSizeChange).toHaveBeenCalledWith(399, 2400);
});
});

describe("autoscrolling to bottom is suppressed while offset projection is disabled", () => {
const scrollTo = (root: ReturnType<typeof render>, y: number) => {
const scrollable = root.findWhere((node: any) => node.props.onScroll);
Expand Down
13 changes: 13 additions & 0 deletions src/recyclerview/RecyclerView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,18 @@ const RecyclerViewComponent = <T,>(
]
);

/**
* Tracks the scroll view's content size so scrollToIndex can scroll into
* content after the items (footer, padding). Forwards to the user's handler.
*/
const onContentSizeChangeHandler = useCallback(
(width: number, height: number) => {
recyclerViewManager.updateScrollContentSize(width, height);
recyclerViewManager.props.onContentSizeChange?.(width, height);
},
[recyclerViewManager]
);

const parentRecyclerViewContext = useRecyclerViewContext();
const recyclerViewId = useId();

Expand Down Expand Up @@ -568,6 +580,7 @@ const RecyclerViewComponent = <T,>(
horizontal={horizontal}
ref={scrollViewRef}
onScroll={animatedEvent}
onContentSizeChange={onContentSizeChangeHandler}
maintainVisibleContentPosition={
maintainVisibleContentPositionInternal
}
Expand Down
38 changes: 31 additions & 7 deletions src/recyclerview/RecyclerViewManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ export class RecyclerViewManager<T> {
private _isDisposed = false;
private _isLayoutManagerDirty = false;
private _animationOptimizationsEnabled = false;
// Content size reported by the scroll view. Unlike the layout manager's size,
// it includes content after the items (footer, contentContainerStyle padding).
private scrollContentSize?: RVDimension;

public firstItemOffset = 0;
public ignoreScrollEvents = false;
Expand Down Expand Up @@ -182,18 +185,39 @@ export class RecyclerViewManager<T> {
}

getMaxScrollOffset() {
const horizontal = this.propsRef.horizontal;
const layoutContentLength =
(horizontal
? this.getChildContainerDimensions().width
: this.getChildContainerDimensions().height) + this.firstItemOffset;
const scrollContentLength =
(horizontal
? this.scrollContentSize?.width
: this.scrollContentSize?.height) ?? 0;
const windowLength = horizontal
? this.getWindowSize().width
: this.getWindowSize().height;
// The reported content size can lag behind the layout (e.g. right after
// items are appended), so use whichever is larger.
return Math.max(
0,
(this.propsRef.horizontal
? this.getChildContainerDimensions().width
: this.getChildContainerDimensions().height) -
(this.propsRef.horizontal
? this.getWindowSize().width
: this.getWindowSize().height) +
this.firstItemOffset
Math.max(layoutContentLength, scrollContentLength) - windowLength
);
}

updateScrollContentSize(width: number, height: number) {
// Ignore invalid sizes instead of throwing: this comes from a native event
if (
!Number.isFinite(width) ||
!Number.isFinite(height) ||
width < 0 ||
height < 0
) {
return;
}
this.scrollContentSize = { width, height };
}

// Doesn't include first item offset correction
getAbsoluteLastScrollOffset() {
return this.engagedIndicesTracker.scrollOffset + this.firstItemOffset;
Expand Down
Loading