@shopify/flash-list
Version:
FlashList is a more performant FlatList replacement
144 lines • 6.49 kB
JavaScript
/**
* StickyHeaders component manages the sticky header behavior in a FlashList.
* It handles the animation and positioning of headers that should remain fixed
* at the top of the list while scrolling.
*/
import React, { useRef, useState, useMemo, useImperativeHandle, useCallback, useEffect, } from "react";
import { ViewHolder } from "../ViewHolder";
import { CompatAnimatedView } from "./CompatView";
export const StickyHeaders = ({ stickyHeaderIndices, stickyHeaderOffset, renderItem, stickyHeaderRef, recyclerViewManager, scrollY, data, extraData, onChangeStickyIndex, }) => {
const [stickyHeaderState, setStickyHeaderState] = useState({
currentStickyIndex: -1,
pushStartsAt: Number.MAX_SAFE_INTEGER,
});
const { currentStickyIndex, pushStartsAt } = stickyHeaderState;
// sort indices and memoize compute
const sortedIndices = useMemo(() => {
return [...stickyHeaderIndices].sort((first, second) => first - second);
}, [stickyHeaderIndices]);
const legthInvalid = sortedIndices.length === 0 ||
recyclerViewManager.getDataLength() <=
sortedIndices[sortedIndices.length - 1];
const compute = useCallback(() => {
var _a, _b, _c, _d, _e, _f;
if (legthInvalid) {
return;
}
const adjustedScrollOffset = recyclerViewManager.getLastScrollOffset();
// Binary search for current sticky index
const currentIndexInArray = findCurrentStickyIndex(sortedIndices, adjustedScrollOffset + stickyHeaderOffset, (index) => recyclerViewManager.getLayout(index).y);
const newStickyIndex = (_a = sortedIndices[currentIndexInArray]) !== null && _a !== void 0 ? _a : -1;
let newNextStickyIndex = (_b = sortedIndices[currentIndexInArray + 1]) !== null && _b !== void 0 ? _b : -1;
if (newNextStickyIndex > recyclerViewManager.getEngagedIndices().endIndex) {
newNextStickyIndex = -1;
}
// Calculate when the next sticky header should start pushing the current one
// The next header starts pushing when it reaches the bottom of the current sticky header
const newNextStickyY = newNextStickyIndex === -1
? Number.MAX_SAFE_INTEGER
: ((_d = (_c = recyclerViewManager.tryGetLayout(newNextStickyIndex)) === null || _c === void 0 ? void 0 : _c.y) !== null && _d !== void 0 ? _d : 0) +
recyclerViewManager.firstItemOffset;
const newCurrentStickyHeight = (_f = (_e = recyclerViewManager.tryGetLayout(newStickyIndex)) === null || _e === void 0 ? void 0 : _e.height) !== null && _f !== void 0 ? _f : 0;
// Push should start when the next header reaches the bottom of the current sticky header
const newPushStartsAt = newNextStickyY - newCurrentStickyHeight;
if (newStickyIndex !== currentStickyIndex ||
newPushStartsAt !== pushStartsAt) {
setStickyHeaderState({
currentStickyIndex: newStickyIndex,
pushStartsAt: newPushStartsAt - stickyHeaderOffset,
});
}
if (newStickyIndex !== currentStickyIndex) {
onChangeStickyIndex === null || onChangeStickyIndex === void 0 ? void 0 : onChangeStickyIndex(newStickyIndex);
}
}, [
legthInvalid,
recyclerViewManager,
sortedIndices,
currentStickyIndex,
pushStartsAt,
onChangeStickyIndex,
stickyHeaderOffset,
]);
useEffect(() => {
compute();
}, [compute]);
// Optimized scroll handler using binary search pattern
useImperativeHandle(stickyHeaderRef, () => ({
reportScrollEvent: () => {
compute();
},
}), [compute]);
const refHolder = useRef(new Map()).current;
const { translateY, opacity } = useMemo(() => {
var _a, _b;
const currentStickyHeight = (_b = (_a = recyclerViewManager.tryGetLayout(currentStickyIndex)) === null || _a === void 0 ? void 0 : _a.height) !== null && _b !== void 0 ? _b : 0;
return {
translateY: scrollY.interpolate({
inputRange: [pushStartsAt, pushStartsAt + currentStickyHeight],
outputRange: [0, -currentStickyHeight],
extrapolate: "clamp",
}),
opacity: stickyHeaderOffset > 0
? scrollY.interpolate({
inputRange: [pushStartsAt, pushStartsAt + currentStickyHeight],
outputRange: [1, 0],
extrapolate: "clamp",
})
: undefined,
};
}, [
recyclerViewManager,
currentStickyIndex,
scrollY,
pushStartsAt,
stickyHeaderOffset,
]);
// Memoize header content
const headerContent = useMemo(() => {
return (React.createElement(CompatAnimatedView, { style: {
position: "absolute",
top: stickyHeaderOffset,
left: 0,
right: 0,
zIndex: 2,
transform: [{ translateY }],
opacity,
} }, currentStickyIndex !== -1 && currentStickyIndex < data.length ? (React.createElement(ViewHolder, { index: currentStickyIndex, item: data[currentStickyIndex], renderItem: renderItem, layout: { x: 0, y: 0, width: 0, height: 0 }, refHolder: refHolder, extraData: extraData, trailingItem: null, target: "StickyHeader", hidden: false })) : null));
}, [
translateY,
opacity,
currentStickyIndex,
data,
renderItem,
refHolder,
extraData,
stickyHeaderOffset,
]);
return headerContent;
};
/**
* Binary search utility to find the current sticky header index based on scroll position
* @param sortedIndices - Array of indices sorted by Y position
* @param adjustedValue - Current scroll position
* @param getY - Function to get Y position for an index
* @returns Index of the current sticky header
*/
function findCurrentStickyIndex(sortedIndices, adjustedValue, getY) {
let left = 0;
let right = sortedIndices.length - 1;
let result = -1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
const currentY = getY(sortedIndices[mid]);
if (currentY <= adjustedValue) {
result = mid;
left = mid + 1;
}
else {
right = mid - 1;
}
}
return result;
}
//# sourceMappingURL=StickyHeaders.js.map