@shopify/flash-list
Version:
FlashList is a more performant FlatList replacement
284 lines (259 loc) • 9.17 kB
text/typescript
import { Dimension, Layout } from "recyclerlistview";
import CustomError from "../errors/CustomError";
import ExceptionList from "../errors/ExceptionList";
import ViewabilityHelper from "../viewability/ViewabilityHelper";
describe("ViewabilityHelper", () => {
const viewableIndicesChanged = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
jest.useFakeTimers();
});
it("does not report any changes when indices have not changed", () => {
const viewabilityHelper = new ViewabilityHelper(
null,
viewableIndicesChanged
);
viewabilityHelper.possiblyViewableIndices = [0, 1, 2];
updateViewableItems({ viewabilityHelper });
// Initial call
expect(viewableIndicesChanged).toHaveBeenCalledWith(
[0, 1, 2],
[0, 1, 2],
[]
);
// No changes
viewableIndicesChanged.mockReset();
updateViewableItems({ viewabilityHelper });
expect(viewableIndicesChanged).not.toHaveBeenCalled();
});
it("reports only viewable indices", () => {
const viewabilityHelper = new ViewabilityHelper(
null,
viewableIndicesChanged
);
viewabilityHelper.possiblyViewableIndices = [0, 1, 2, 3];
updateViewableItems({ viewabilityHelper });
// Items 0, 1, 2 are initially viewable
expect(viewableIndicesChanged).toHaveBeenCalledWith(
[0, 1, 2],
[0, 1, 2],
[]
);
// After scroll, item 3 becomes viewable, too
updateViewableItems({ viewabilityHelper, scrollOffset: 50 });
expect(viewableIndicesChanged).toHaveBeenCalledWith([0, 1, 2, 3], [3], []);
// After additional scroll, the first item is no longer viewable
updateViewableItems({ viewabilityHelper, scrollOffset: 100 });
expect(viewableIndicesChanged).toHaveBeenCalledWith([1, 2, 3], [], [0]);
});
it("reports only viewable indices when horizontal", () => {
const viewabilityHelper = new ViewabilityHelper(
null,
viewableIndicesChanged
);
viewabilityHelper.possiblyViewableIndices = [0, 1, 2, 3];
const getLayout = (index: number) => {
return { x: index * 100, y: 0, height: 300, width: 100 } as Layout;
};
updateViewableItems({ viewabilityHelper, horizontal: true, getLayout });
expect(viewableIndicesChanged).toHaveBeenCalledWith(
[0, 1, 2],
[0, 1, 2],
[]
);
updateViewableItems({
viewabilityHelper,
horizontal: true,
scrollOffset: 50,
getLayout,
});
expect(viewableIndicesChanged).toHaveBeenCalledWith([0, 1, 2, 3], [3], []);
updateViewableItems({
viewabilityHelper,
horizontal: true,
scrollOffset: 100,
getLayout,
});
expect(viewableIndicesChanged).toHaveBeenCalledWith([1, 2, 3], [], [0]);
});
it("reports items only after minimumViewTime has elapsed", () => {
const viewabilityHelper = new ViewabilityHelper(
{ minimumViewTime: 500 },
viewableIndicesChanged
);
viewabilityHelper.possiblyViewableIndices = [0, 1, 2, 3];
updateViewableItems({ viewabilityHelper, runAllTimers: false });
expect(viewableIndicesChanged).not.toHaveBeenCalled();
jest.advanceTimersByTime(400);
expect(viewableIndicesChanged).not.toHaveBeenCalled();
jest.advanceTimersByTime(100);
expect(viewableIndicesChanged).toHaveBeenCalledWith(
[0, 1, 2],
[0, 1, 2],
[]
);
viewableIndicesChanged.mockReset();
updateViewableItems({
viewabilityHelper,
scrollOffset: 50,
runAllTimers: false,
});
expect(viewableIndicesChanged).not.toHaveBeenCalled();
jest.advanceTimersByTime(500);
expect(viewableIndicesChanged).toHaveBeenCalledWith([0, 1, 2, 3], [3], []);
viewableIndicesChanged.mockReset();
updateViewableItems({
viewabilityHelper,
scrollOffset: 100,
runAllTimers: false,
});
expect(viewableIndicesChanged).not.toHaveBeenCalled();
jest.advanceTimersByTime(500);
expect(viewableIndicesChanged).toHaveBeenCalledWith([1, 2, 3], [], [0]);
});
it("reports items that only satisfy itemVisiblePercentThreshold", () => {
const viewabilityHelper = new ViewabilityHelper(
{ itemVisiblePercentThreshold: 50 },
viewableIndicesChanged
);
viewabilityHelper.possiblyViewableIndices = [0, 1, 2, 3];
updateViewableItems({
viewabilityHelper,
});
expect(viewableIndicesChanged).toHaveBeenCalledWith(
[0, 1, 2],
[0, 1, 2],
[]
);
viewableIndicesChanged.mockReset();
// User scrolled by 50 pixels, making both first and last item visible from 50 %
updateViewableItems({
viewabilityHelper,
scrollOffset: 50,
});
expect(viewableIndicesChanged).toHaveBeenCalledWith([0, 1, 2, 3], [3], []);
viewableIndicesChanged.mockReset();
// User scrolled by 55 pixels, first item no longer satisfies threshold
updateViewableItems({
viewabilityHelper,
scrollOffset: 55,
});
expect(viewableIndicesChanged).toHaveBeenCalledWith([1, 2, 3], [], [0]);
});
it("reports items that only satisfy viewAreaCoveragePercentThreshold", () => {
const getLayout = (index: number) => {
if (index === 4) {
return { x: 0, y: index * 100, width: 100, height: 25 } as Layout;
}
return { x: 0, y: index * 100, height: 100, width: 300 } as Layout;
};
const viewabilityHelper = new ViewabilityHelper(
{ viewAreaCoveragePercentThreshold: 25 },
viewableIndicesChanged
);
viewabilityHelper.possiblyViewableIndices = [0, 1, 2, 3];
updateViewableItems({
viewabilityHelper,
getLayout,
});
expect(viewableIndicesChanged).toHaveBeenCalledWith(
[0, 1, 2],
[0, 1, 2],
[]
);
viewableIndicesChanged.mockReset();
// User scrolled by 75 pixels.
// First item is visible only from 25 pixels, not meeting the threshold.
// The last item is visible from 75 pixels, which is exactly the threshold (300 / 4 = 75 where 300 is height of the list)
updateViewableItems({
viewabilityHelper,
scrollOffset: 75,
getLayout,
});
expect(viewableIndicesChanged).toHaveBeenCalledWith([1, 2, 3], [3], [0]);
viewableIndicesChanged.mockReset();
// User scrolled by 110 pixels, making the last small item only partially visible, not meeting the threshold.
viewabilityHelper.possiblyViewableIndices = [1, 2, 3, 4];
updateViewableItems({
viewabilityHelper,
scrollOffset: 110,
getLayout,
});
expect(viewableIndicesChanged).not.toHaveBeenCalled();
// User scrolled by 125 pixels, making the last small item completely visible, even when it is not meeting the threshold.
viewabilityHelper.possiblyViewableIndices = [1, 2, 3, 4];
updateViewableItems({
viewabilityHelper,
scrollOffset: 125,
getLayout,
});
expect(viewableIndicesChanged).toHaveBeenCalledWith([1, 2, 3, 4], [4], []);
});
it("reports viewable items only after interaction if waitForInteraction is set to true", () => {
const viewabilityHelper = new ViewabilityHelper(
{ waitForInteraction: true },
viewableIndicesChanged
);
// Even when elements are visible, viewableIndicesChanged will not be called since interaction has not been recorded, yet
viewabilityHelper.possiblyViewableIndices = [0, 1, 2, 3];
updateViewableItems({
viewabilityHelper,
});
// View is scrolled but programatically - not resulting in an interaction
updateViewableItems({
viewabilityHelper,
scrollOffset: 50,
});
expect(viewableIndicesChanged).not.toHaveBeenCalled();
// Interaction is recorded, leading to trigger of viewableIndicesChanged
viewabilityHelper.hasInteracted = true;
updateViewableItems({
viewabilityHelper,
scrollOffset: 50,
});
expect(viewableIndicesChanged).toHaveBeenCalledWith(
[0, 1, 2, 3],
[0, 1, 2, 3],
[]
);
});
it("throws multipleViewabilityThresholdTypesNotSupported exception when both viewAreaCoveragePercentThreshold and itemVisiblePercentThreshold are defined", () => {
const viewabilityHelper = new ViewabilityHelper(
{ viewAreaCoveragePercentThreshold: 1, itemVisiblePercentThreshold: 1 },
viewableIndicesChanged
);
expect(() => updateViewableItems({ viewabilityHelper })).toThrow(
new CustomError(
ExceptionList.multipleViewabilityThresholdTypesNotSupported
)
);
});
const updateViewableItems = ({
viewabilityHelper,
horizontal,
scrollOffset,
listSize,
getLayout,
runAllTimers,
}: {
viewabilityHelper: ViewabilityHelper;
horizontal?: boolean;
scrollOffset?: number;
listSize?: Dimension;
getLayout?: (index: number) => Layout | undefined;
runAllTimers?: boolean;
}) => {
viewabilityHelper.updateViewableItems(
horizontal ?? false,
scrollOffset ?? 0,
listSize ?? { height: 300, width: 300 },
getLayout ??
((index) => {
return { x: 0, y: index * 100, height: 100, width: 300 } as Layout;
})
);
if (runAllTimers ?? true) {
jest.runAllTimers();
}
};
});